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::Meta => "Meta Model API".to_string(),
1868 DriverId::LlmSim => "LLM Simulator".to_string(),
1869 DriverId::External(id) => id.to_string(),
1870 }
1871}
1872
1873fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1874 match id {
1875 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1877 _ => CredentialFormSchema::api_key(String::new()),
1878 }
1879}
1880
1881#[derive(Clone, Default)]
1901pub struct DriverRegistry {
1902 descriptors: HashMap<DriverId, DriverDescriptor>,
1903}
1904
1905impl DriverRegistry {
1906 pub fn new() -> Self {
1908 Self {
1909 descriptors: HashMap::new(),
1910 }
1911 }
1912
1913 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1919 if self.descriptors.contains_key(&descriptor.id) {
1920 panic!(
1921 "driver already registered for provider '{}'; \
1922 use register_descriptor_or_replace to overwrite intentionally",
1923 descriptor.id
1924 );
1925 }
1926 self.descriptors.insert(descriptor.id.clone(), descriptor);
1927 }
1928
1929 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1931 self.descriptors.insert(descriptor.id.clone(), descriptor);
1932 }
1933
1934 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1940 where
1941 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1942 {
1943 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1944 }
1945
1946 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1951 where
1952 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1953 {
1954 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1955 }
1956
1957 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1962 where
1963 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1964 {
1965 self.register(DriverId::external(id), factory);
1966 }
1967
1968 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1977 let requires_api_key = !matches!(
1981 config.provider_type,
1982 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
1983 );
1984 if requires_api_key && config.api_key.is_none() {
1985 return Err(AgentLoopError::llm(
1986 "API key is required. Configure the API key in provider settings.",
1987 ));
1988 }
1989
1990 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1992 AgentLoopError::driver_not_registered(config.provider_type.to_string())
1993 })?;
1994 let factory = descriptor.chat.as_ref().ok_or_else(|| {
1995 AgentLoopError::llm(format!(
1996 "Provider driver '{}' does not implement the chat service.",
1997 config.provider_type
1998 ))
1999 })?;
2000
2001 let driver_config = DriverConfig::from_provider_config(config);
2003 Ok(factory(&driver_config))
2004 }
2005
2006 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2008 self.descriptors.contains_key(provider_type)
2009 }
2010
2011 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2013 self.descriptors.get(provider_type)
2014 }
2015
2016 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2018 self.descriptors
2019 .get(provider_type)
2020 .is_some_and(|d| d.supports(service))
2021 }
2022
2023 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2025 self.descriptors
2026 .values()
2027 .filter(|d| d.supports(service))
2028 .map(|d| d.id.clone())
2029 .collect()
2030 }
2031
2032 pub fn registered_providers(&self) -> Vec<DriverId> {
2034 self.descriptors.keys().cloned().collect()
2035 }
2036
2037 pub fn create_embeddings_driver(
2045 &self,
2046 config: &ProviderConfig,
2047 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2048 let requires_api_key = !matches!(
2049 config.provider_type,
2050 DriverId::LlmSim | DriverId::External(_)
2051 );
2052 if requires_api_key && config.api_key.is_none() {
2053 return Err(EmbeddingsDriverError::Provider(
2054 "API key is required. Configure the API key in provider settings.".to_string(),
2055 ));
2056 }
2057 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2058 EmbeddingsDriverError::Provider(format!(
2059 "No driver registered for provider '{}'",
2060 config.provider_type
2061 ))
2062 })?;
2063 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2064 EmbeddingsDriverError::Provider(format!(
2065 "Provider driver '{}' does not implement the embeddings service.",
2066 config.provider_type
2067 ))
2068 })?;
2069 let driver_config = DriverConfig::from_provider_config(config);
2070 Ok(factory(&driver_config))
2071 }
2072}
2073
2074const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2079
2080const TRUNCATION_SUFFIX: &str =
2081 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2082
2083pub fn truncate_tool_result(text: String) -> String {
2084 if text.len() <= MAX_TOOL_RESULT_BYTES {
2085 return text;
2086 }
2087 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2088 let mut end = content_budget;
2089 while end > 0 && !text.is_char_boundary(end) {
2090 end -= 1;
2091 }
2092 let mut truncated = text[..end].to_string();
2093 truncated.push_str(TRUNCATION_SUFFIX);
2094 truncated
2095}
2096
2097#[cfg(test)]
2102mod tests {
2103 use super::*;
2104
2105 #[test]
2106 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2107 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2110 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2112 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2113 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2115 }
2116
2117 #[test]
2118 fn test_chat_driver_defaults_are_conservative_and_boxed_capabilities_forward() {
2119 struct DefaultDriver;
2121 #[async_trait]
2122 impl ChatDriver for DefaultDriver {
2123 async fn chat_completion_stream(
2124 &self,
2125 _messages: Vec<LlmMessage>,
2126 _config: &LlmCallConfig,
2127 ) -> Result<LlmResponseStream> {
2128 unreachable!()
2129 }
2130 }
2131 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2132 assert!(!DefaultDriver.supports_stateful_responses());
2133
2134 struct StatefulDriver;
2135 #[async_trait]
2136 impl ChatDriver for StatefulDriver {
2137 async fn chat_completion_stream(
2138 &self,
2139 _messages: Vec<LlmMessage>,
2140 _config: &LlmCallConfig,
2141 ) -> Result<LlmResponseStream> {
2142 unreachable!()
2143 }
2144
2145 fn supports_stateful_responses(&self) -> bool {
2146 true
2147 }
2148 }
2149 let boxed: BoxedChatDriver = Box::new(StatefulDriver);
2150 assert!(boxed.supports_stateful_responses());
2151 }
2152
2153 #[test]
2154 fn test_fold_system_messages_none_when_absent() {
2155 let messages = vec![
2156 LlmMessage::text(LlmMessageRole::User, "hi"),
2157 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2158 ];
2159 assert_eq!(fold_system_messages(&messages), None);
2160 }
2161
2162 #[test]
2163 fn test_fold_system_messages_single() {
2164 let messages = vec![
2165 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2166 LlmMessage::text(LlmMessageRole::User, "hi"),
2167 ];
2168 assert_eq!(
2169 fold_system_messages(&messages),
2170 Some("AGENT-PROMPT".to_string())
2171 );
2172 }
2173
2174 #[test]
2175 fn test_fold_system_messages_accumulates_in_order() {
2176 let messages = vec![
2180 LlmMessage::text(LlmMessageRole::System, "A"),
2181 LlmMessage::text(LlmMessageRole::User, "hi"),
2182 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2183 LlmMessage::text(LlmMessageRole::System, "B"),
2184 ];
2185 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2186 }
2187
2188 #[test]
2189 fn test_fold_system_messages_concatenates_parts() {
2190 let messages = vec![LlmMessage::parts(
2191 LlmMessageRole::System,
2192 vec![
2193 LlmContentPart::text("foo"),
2194 LlmContentPart::image("data:image/png;base64,xxx"),
2195 LlmContentPart::text("bar"),
2196 ],
2197 )];
2198 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2199 }
2200
2201 #[test]
2202 fn test_openrouter_fallback_models_empty_is_empty() {
2203 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2204
2205 assert!(routing.is_empty());
2206 assert_eq!(routing.route, None);
2207 }
2208
2209 #[test]
2210 fn test_openrouter_routing_validates_primary_model() {
2211 let routing = OpenRouterRoutingConfig::fallback_models([
2212 "openai/gpt-5-mini",
2213 "anthropic/claude-sonnet-4.5",
2214 ]);
2215
2216 assert!(
2217 routing
2218 .validate_for_primary_model("openai/gpt-5-mini")
2219 .is_ok()
2220 );
2221 let err = routing
2222 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2223 .unwrap_err();
2224 assert!(err.contains("models[0]"));
2225 }
2226
2227 #[test]
2228 fn test_openrouter_routing_rejects_fallback_without_models() {
2229 let routing = OpenRouterRoutingConfig {
2230 route: Some(OpenRouterRoute::Fallback),
2231 ..Default::default()
2232 };
2233
2234 let err = routing
2235 .validate_for_primary_model("openai/gpt-5-mini")
2236 .unwrap_err();
2237 assert!(err.contains("requires at least one model"));
2238 }
2239
2240 #[test]
2241 fn test_openrouter_routing_serializes_request_fields() {
2242 let routing = OpenRouterRoutingConfig {
2243 models: vec![
2244 "openai/gpt-5-mini".to_string(),
2245 "anthropic/claude-sonnet-4.5".to_string(),
2246 ],
2247 route: Some(OpenRouterRoute::Fallback),
2248 provider: Some(OpenRouterProviderRouting {
2249 order: vec!["anthropic".to_string(), "openai".to_string()],
2250 allow_fallbacks: Some(false),
2251 require_parameters: Some(true),
2252 data_collection: Some(OpenRouterDataCollection::Deny),
2253 zdr: Some(true),
2254 sort: Some(OpenRouterProviderSort::Advanced(
2255 OpenRouterProviderSortOptions {
2256 by: OpenRouterProviderSortBy::Throughput,
2257 partition: Some(OpenRouterSortPartition::None),
2258 },
2259 )),
2260 max_price: Some(OpenRouterMaxPrice {
2261 prompt: Some(1.0),
2262 completion: Some(2.0),
2263 ..Default::default()
2264 }),
2265 ..Default::default()
2266 }),
2267 ..Default::default()
2268 };
2269
2270 let json = serde_json::to_value(routing).unwrap();
2271
2272 assert_eq!(
2273 json,
2274 serde_json::json!({
2275 "models": [
2276 "openai/gpt-5-mini",
2277 "anthropic/claude-sonnet-4.5"
2278 ],
2279 "route": "fallback",
2280 "provider": {
2281 "order": ["anthropic", "openai"],
2282 "allow_fallbacks": false,
2283 "require_parameters": true,
2284 "data_collection": "deny",
2285 "zdr": true,
2286 "sort": {
2287 "by": "throughput",
2288 "partition": "none"
2289 },
2290 "max_price": {
2291 "prompt": 1.0,
2292 "completion": 2.0
2293 }
2294 }
2295 })
2296 );
2297 }
2298
2299 #[test]
2300 fn test_provider_type_parsing() {
2301 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2302 assert_eq!(
2303 "openrouter".parse::<DriverId>().unwrap(),
2304 DriverId::OpenRouter
2305 );
2306 assert_eq!(
2307 "openai_completions".parse::<DriverId>().unwrap(),
2308 DriverId::OpenAICompletions
2309 );
2310 assert_eq!(
2311 "azure_openai".parse::<DriverId>().unwrap(),
2312 DriverId::AzureOpenAI
2313 );
2314 assert_eq!(
2315 "anthropic".parse::<DriverId>().unwrap(),
2316 DriverId::Anthropic
2317 );
2318 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2319 assert_eq!(
2321 "ollama".parse::<DriverId>().unwrap(),
2322 DriverId::external("ollama")
2323 );
2324 assert_eq!(
2325 "custom".parse::<DriverId>().unwrap(),
2326 DriverId::external("custom")
2327 );
2328 }
2329
2330 #[test]
2331 fn test_external_provider_id_is_case_insensitive() {
2332 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2335 assert_eq!(
2336 "Ollama".parse::<DriverId>().unwrap(),
2337 "ollama".parse::<DriverId>().unwrap()
2338 );
2339 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2340 assert_eq!(
2342 DriverId::external("MyProvider"),
2343 "myprovider".parse::<DriverId>().unwrap()
2344 );
2345 }
2346
2347 #[test]
2348 fn test_provider_type_display() {
2349 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2350 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2351 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2352 assert_eq!(
2353 DriverId::OpenAICompletions.to_string(),
2354 "openai_completions"
2355 );
2356 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2357 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2358 }
2359
2360 #[test]
2361 fn test_provider_config_builder() {
2362 let config = ProviderConfig::new(DriverId::Anthropic)
2363 .with_api_key("test-key")
2364 .with_base_url("https://custom.api.com");
2365
2366 assert_eq!(config.provider_type, DriverId::Anthropic);
2367 assert_eq!(config.api_key, Some("test-key".to_string()));
2368 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2369 }
2370
2371 #[test]
2372 fn test_driver_registry_requires_api_key() {
2373 let mut registry = DriverRegistry::new();
2375 registry.register(DriverId::OpenAI, |_config| {
2376 struct MockDriver;
2378 #[async_trait]
2379 impl ChatDriver for MockDriver {
2380 async fn chat_completion_stream(
2381 &self,
2382 _messages: Vec<LlmMessage>,
2383 _config: &LlmCallConfig,
2384 ) -> Result<LlmResponseStream> {
2385 unimplemented!()
2386 }
2387 }
2388 Box::new(MockDriver)
2389 });
2390
2391 let config = ProviderConfig::new(DriverId::OpenAI);
2393 let result = registry.create_chat_driver(&config);
2394 assert!(result.is_err());
2395
2396 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2398 let result = registry.create_chat_driver(&config_with_key);
2399 assert!(result.is_ok());
2400 }
2401
2402 #[test]
2403 fn test_driver_registry_returns_error_for_unregistered_provider() {
2404 let registry = DriverRegistry::new();
2405 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2406
2407 let result = registry.create_chat_driver(&config);
2408
2409 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2411 assert_eq!(provider, "anthropic");
2412 } else {
2413 panic!("Expected DriverNotRegistered error");
2414 }
2415 }
2416
2417 #[test]
2418 fn test_driver_registry_registration() {
2419 let mut registry = DriverRegistry::new();
2420
2421 assert!(!registry.has_driver(&DriverId::OpenAI));
2422 assert!(!registry.has_driver(&DriverId::Anthropic));
2423
2424 registry.register(DriverId::OpenAI, |_config| {
2425 struct MockDriver;
2426 #[async_trait]
2427 impl ChatDriver for MockDriver {
2428 async fn chat_completion_stream(
2429 &self,
2430 _messages: Vec<LlmMessage>,
2431 _config: &LlmCallConfig,
2432 ) -> Result<LlmResponseStream> {
2433 unimplemented!()
2434 }
2435 }
2436 Box::new(MockDriver)
2437 });
2438
2439 assert!(registry.has_driver(&DriverId::OpenAI));
2440 assert!(!registry.has_driver(&DriverId::Anthropic));
2441 }
2442
2443 #[test]
2444 fn test_register_external_and_create_driver_without_api_key() {
2445 struct MockDriver;
2446 #[async_trait]
2447 impl ChatDriver for MockDriver {
2448 async fn chat_completion_stream(
2449 &self,
2450 _messages: Vec<LlmMessage>,
2451 _config: &LlmCallConfig,
2452 ) -> Result<LlmResponseStream> {
2453 unimplemented!()
2454 }
2455 }
2456
2457 let mut registry = DriverRegistry::new();
2458 registry.register_external("openai-codex", |config| {
2459 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2461 Box::new(MockDriver)
2462 });
2463
2464 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2465
2466 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2468 ProviderMetadata {
2469 refresh_token: Some("rt".into()),
2470 ..Default::default()
2471 },
2472 );
2473 assert!(registry.create_chat_driver(&config).is_ok());
2474 }
2475
2476 #[test]
2477 fn test_register_defaults_to_chat_only_descriptor() {
2478 struct MockDriver;
2479 #[async_trait]
2480 impl ChatDriver for MockDriver {
2481 async fn chat_completion_stream(
2482 &self,
2483 _messages: Vec<LlmMessage>,
2484 _config: &LlmCallConfig,
2485 ) -> Result<LlmResponseStream> {
2486 unimplemented!()
2487 }
2488 }
2489
2490 let mut registry = DriverRegistry::new();
2491 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2492
2493 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2494 assert_eq!(descriptor.display_name, "Anthropic");
2495 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2496 assert!(descriptor.chat.is_some());
2497 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2499 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2500 assert!(descriptor.credential_schema.fields[0].required);
2501
2502 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2504 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2505 assert!(sim.credential_schema.fields.is_empty());
2506 }
2507
2508 #[test]
2509 fn test_descriptor_services_and_lookup() {
2510 struct MockDriver;
2511 #[async_trait]
2512 impl ChatDriver for MockDriver {
2513 async fn chat_completion_stream(
2514 &self,
2515 _messages: Vec<LlmMessage>,
2516 _config: &LlmCallConfig,
2517 ) -> Result<LlmResponseStream> {
2518 unimplemented!()
2519 }
2520 }
2521
2522 let mut registry = DriverRegistry::new();
2523 registry.register_descriptor(DriverDescriptor {
2524 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2525 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2526 });
2527 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2528
2529 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2530 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2531 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2532 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2533
2534 let realtime = registry.providers_for(ServiceKind::Realtime);
2535 assert_eq!(realtime, vec![DriverId::OpenAI]);
2536 let mut chat = registry.providers_for(ServiceKind::Chat);
2537 chat.sort_by_key(|p| p.to_string());
2538 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2539 }
2540
2541 #[test]
2542 fn test_create_chat_driver_fails_without_chat_factory() {
2543 let mut registry = DriverRegistry::new();
2544 registry.register_descriptor(DriverDescriptor {
2545 id: DriverId::external("embeddings-only"),
2546 display_name: "Embeddings Only".to_string(),
2547 services: vec![ServiceKind::Embeddings],
2548 credential_schema: CredentialFormSchema::empty(),
2549 oauth: None,
2550 chat: None,
2551 embeddings: None,
2552 });
2553
2554 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2555 let err = match registry.create_chat_driver(&config) {
2556 Ok(_) => panic!("expected error for missing chat factory"),
2557 Err(err) => err,
2558 };
2559 assert!(
2560 err.to_string()
2561 .contains("does not implement the chat service"),
2562 "unexpected error: {err}"
2563 );
2564 }
2565
2566 #[test]
2567 #[should_panic(expected = "already registered")]
2568 fn test_register_duplicate_panics() {
2569 struct MockDriver;
2570 #[async_trait]
2571 impl ChatDriver for MockDriver {
2572 async fn chat_completion_stream(
2573 &self,
2574 _messages: Vec<LlmMessage>,
2575 _config: &LlmCallConfig,
2576 ) -> Result<LlmResponseStream> {
2577 unimplemented!()
2578 }
2579 }
2580
2581 let mut registry = DriverRegistry::new();
2582 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2583 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2585 }
2586
2587 #[test]
2588 fn test_register_or_replace_overwrites() {
2589 struct MockDriver;
2590 #[async_trait]
2591 impl ChatDriver for MockDriver {
2592 async fn chat_completion_stream(
2593 &self,
2594 _messages: Vec<LlmMessage>,
2595 _config: &LlmCallConfig,
2596 ) -> Result<LlmResponseStream> {
2597 unimplemented!()
2598 }
2599 }
2600
2601 let mut registry = DriverRegistry::new();
2602 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2603 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2605 assert!(registry.has_driver(&DriverId::LlmSim));
2606 }
2607
2608 #[test]
2609 fn test_prepend_text_prefix_simple_text() {
2610 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2611 msg.prepend_text_prefix("[Alice] ");
2612 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2613 }
2614
2615 #[test]
2616 fn test_prepend_text_prefix_parts() {
2617 let mut msg = LlmMessage::parts(
2618 LlmMessageRole::User,
2619 vec![
2620 LlmContentPart::Text {
2621 text: "Hello".to_string(),
2622 },
2623 LlmContentPart::Image {
2624 url: "data:image/png;base64,abc".to_string(),
2625 },
2626 ],
2627 );
2628 msg.prepend_text_prefix("[Bob] ");
2629 match &msg.content {
2630 LlmMessageContent::Parts(parts) => {
2631 if let LlmContentPart::Text { text } = &parts[0] {
2632 assert_eq!(text, "[Bob] Hello");
2633 } else {
2634 panic!("Expected text part");
2635 }
2636 }
2637 _ => panic!("Expected parts content"),
2638 }
2639 }
2640
2641 #[test]
2642 fn test_prepend_text_prefix_parts_no_text() {
2643 let mut msg = LlmMessage::parts(
2644 LlmMessageRole::User,
2645 vec![LlmContentPart::Image {
2646 url: "data:image/png;base64,abc".to_string(),
2647 }],
2648 );
2649 msg.prepend_text_prefix("[Eve] ");
2650 match &msg.content {
2651 LlmMessageContent::Parts(parts) => {
2652 assert_eq!(parts.len(), 2);
2653 if let LlmContentPart::Text { text } = &parts[0] {
2654 assert_eq!(text, "[Eve] ");
2655 } else {
2656 panic!("Expected prepended text part");
2657 }
2658 }
2659 _ => panic!("Expected parts content"),
2660 }
2661 }
2662
2663 #[test]
2664 fn test_openrouter_plugin_config_is_empty() {
2665 assert!(OpenRouterPluginConfig::default().is_empty());
2666 assert!(
2667 !OpenRouterPluginConfig {
2668 web: Some(OpenRouterWebSearchPlugin::default()),
2669 file: None,
2670 }
2671 .is_empty()
2672 );
2673 assert!(
2674 !OpenRouterPluginConfig {
2675 web: None,
2676 file: Some(OpenRouterFilePlugin {}),
2677 }
2678 .is_empty()
2679 );
2680 }
2681
2682 #[test]
2683 fn test_openrouter_routing_is_empty_with_plugins() {
2684 let with_plugins = OpenRouterRoutingConfig {
2685 plugins: Some(OpenRouterPluginConfig {
2686 web: Some(OpenRouterWebSearchPlugin::default()),
2687 file: None,
2688 }),
2689 ..Default::default()
2690 };
2691 assert!(!with_plugins.is_empty());
2692
2693 let empty_plugins = OpenRouterRoutingConfig {
2694 plugins: Some(OpenRouterPluginConfig::default()),
2695 ..Default::default()
2696 };
2697 assert!(empty_plugins.is_empty());
2698 }
2699
2700 #[test]
2701 fn test_openrouter_web_search_plugin_serialization() {
2702 let plugin = OpenRouterWebSearchPlugin {
2703 max_results: Some(10),
2704 search_prompt: Some("search for Rust crates".to_string()),
2705 };
2706 let json = serde_json::to_value(&plugin).unwrap();
2707 assert_eq!(json["max_results"], 10);
2708 assert_eq!(json["search_prompt"], "search for Rust crates");
2709 }
2710
2711 #[test]
2712 fn test_openrouter_web_search_plugin_omits_none_fields() {
2713 let plugin = OpenRouterWebSearchPlugin::default();
2714 let json = serde_json::to_value(&plugin).unwrap();
2715 assert!(json.get("max_results").is_none());
2716 assert!(json.get("search_prompt").is_none());
2717 }
2718
2719 #[test]
2720 fn test_capacity_strategy_shared_capacity_is_noop() {
2721 let base = OpenRouterRoutingConfig {
2722 models: vec!["openai/gpt-5-mini".to_string()],
2723 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2724 ..Default::default()
2725 };
2726 let result = base.apply_capacity_strategy().unwrap();
2727 assert_eq!(
2728 result.capacity_strategy,
2729 Some(OpenRouterCapacityStrategy::SharedCapacity)
2730 );
2731 assert!(result.provider.is_none());
2732 }
2733
2734 #[test]
2735 fn test_capacity_strategy_none_is_noop() {
2736 let base = OpenRouterRoutingConfig {
2737 models: vec!["openai/gpt-5-mini".to_string()],
2738 capacity_strategy: None,
2739 ..Default::default()
2740 };
2741 let result = base.apply_capacity_strategy().unwrap();
2742 assert!(result.provider.is_none());
2743 }
2744
2745 #[test]
2746 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2747 let base = OpenRouterRoutingConfig {
2748 models: vec!["openai/gpt-5-mini".to_string()],
2749 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2750 ..Default::default()
2751 };
2752 let result = base.apply_capacity_strategy().unwrap();
2753 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2754 assert_eq!(provider.allow_fallbacks, Some(true));
2755 }
2756
2757 #[test]
2758 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2759 let base = OpenRouterRoutingConfig {
2761 models: vec!["openai/gpt-5-mini".to_string()],
2762 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2763 provider: Some(OpenRouterProviderRouting {
2764 allow_fallbacks: Some(false),
2765 ..Default::default()
2766 }),
2767 ..Default::default()
2768 };
2769 let result = base.apply_capacity_strategy().unwrap();
2770 let provider = result.provider.as_ref().unwrap();
2771 assert_eq!(provider.allow_fallbacks, Some(false));
2772 }
2773
2774 #[test]
2775 fn test_capacity_strategy_byok_only_requires_provider_only() {
2776 let base = OpenRouterRoutingConfig {
2777 models: vec!["openai/gpt-5-mini".to_string()],
2778 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2779 ..Default::default()
2780 };
2781 let err = base.apply_capacity_strategy().unwrap_err();
2782 assert!(
2783 err.contains("provider.only"),
2784 "error should mention provider.only: {err}"
2785 );
2786 }
2787
2788 #[test]
2789 fn test_capacity_strategy_byok_only_disables_fallbacks() {
2790 let base = OpenRouterRoutingConfig {
2791 models: vec!["openai/gpt-5-mini".to_string()],
2792 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2793 provider: Some(OpenRouterProviderRouting {
2794 only: vec!["my-byok-provider".to_string()],
2795 ..Default::default()
2796 }),
2797 ..Default::default()
2798 };
2799 let result = base.apply_capacity_strategy().unwrap();
2800 let provider = result.provider.as_ref().unwrap();
2801 assert_eq!(provider.allow_fallbacks, Some(false));
2802 assert_eq!(provider.only, vec!["my-byok-provider"]);
2803 }
2804
2805 #[test]
2806 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
2807 let with_strategy = OpenRouterRoutingConfig {
2808 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2809 ..Default::default()
2810 };
2811 assert!(!with_strategy.is_empty());
2812
2813 let byok_first = OpenRouterRoutingConfig {
2814 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2815 ..Default::default()
2816 };
2817 assert!(!byok_first.is_empty());
2818
2819 let shared = OpenRouterRoutingConfig {
2820 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2821 ..Default::default()
2822 };
2823 assert!(shared.is_empty());
2824 }
2825
2826 #[test]
2833 fn test_preset_no_presets_is_noop() {
2834 let base = OpenRouterRoutingConfig {
2835 models: vec!["openai/gpt-5-mini".to_string()],
2836 ..Default::default()
2837 };
2838 let result = base.apply_presets().unwrap();
2839 assert_eq!(result, base);
2840 }
2841
2842 #[test]
2843 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
2844 let base = OpenRouterRoutingConfig {
2845 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2846 ..Default::default()
2847 };
2848 let result = base.apply_presets().unwrap();
2849 assert!(result.presets.is_empty(), "presets cleared after apply");
2850 let provider = result.provider.expect("provider set by preset");
2851 assert_eq!(provider.require_parameters, Some(true));
2852 assert_eq!(
2853 provider.sort,
2854 Some(OpenRouterProviderSort::Simple(
2855 OpenRouterProviderSortBy::Price
2856 ))
2857 );
2858 }
2859
2860 #[test]
2861 fn test_preset_lowest_latency_review_sets_sort_throughput() {
2862 let base = OpenRouterRoutingConfig {
2863 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
2864 ..Default::default()
2865 };
2866 let result = base.apply_presets().unwrap();
2867 let provider = result.provider.expect("provider set by preset");
2868 assert_eq!(
2869 provider.sort,
2870 Some(OpenRouterProviderSort::Simple(
2871 OpenRouterProviderSortBy::Throughput
2872 ))
2873 );
2874 }
2875
2876 #[test]
2877 fn test_preset_zdr_only_sets_zdr() {
2878 let base = OpenRouterRoutingConfig {
2879 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2880 ..Default::default()
2881 };
2882 let result = base.apply_presets().unwrap();
2883 let provider = result.provider.expect("provider set");
2884 assert_eq!(provider.zdr, Some(true));
2885 }
2886
2887 #[test]
2888 fn test_preset_byok_first_sets_allow_fallbacks() {
2889 let base = OpenRouterRoutingConfig {
2890 presets: vec![OpenRouterRoutingPreset::ByokFirst],
2891 ..Default::default()
2892 };
2893 let result = base.apply_presets().unwrap();
2894 let provider = result.provider.expect("provider set");
2895 assert_eq!(provider.allow_fallbacks, Some(true));
2896 }
2897
2898 #[test]
2899 fn test_preset_no_data_collection_sets_data_collection_deny() {
2900 let base = OpenRouterRoutingConfig {
2901 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
2902 ..Default::default()
2903 };
2904 let result = base.apply_presets().unwrap();
2905 let provider = result.provider.expect("provider set");
2906 assert_eq!(
2907 provider.data_collection,
2908 Some(OpenRouterDataCollection::Deny)
2909 );
2910 }
2911
2912 #[test]
2913 fn test_preset_strict_json_sets_require_parameters() {
2914 let base = OpenRouterRoutingConfig {
2915 presets: vec![OpenRouterRoutingPreset::StrictJson],
2916 ..Default::default()
2917 };
2918 let result = base.apply_presets().unwrap();
2919 let provider = result.provider.expect("provider set");
2920 assert_eq!(provider.require_parameters, Some(true));
2921 }
2922
2923 #[test]
2924 fn test_preset_reasoning_required_sets_require_parameters() {
2925 let base = OpenRouterRoutingConfig {
2926 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
2927 ..Default::default()
2928 };
2929 let result = base.apply_presets().unwrap();
2930 let provider = result.provider.expect("provider set");
2931 assert_eq!(provider.require_parameters, Some(true));
2932 }
2933
2934 #[test]
2935 fn test_preset_max_price_converts_usd_per_million() {
2936 let base = OpenRouterRoutingConfig {
2937 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2938 prompt_usd_per_million: Some(5.0),
2939 completion_usd_per_million: Some(15.0),
2940 }],
2941 ..Default::default()
2942 };
2943 let result = base.apply_presets().unwrap();
2944 let provider = result.provider.expect("provider set");
2945 let max_price = provider.max_price.expect("max_price set");
2946 let prompt = max_price.prompt.expect("prompt set");
2948 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
2949 let completion = max_price.completion.expect("completion set");
2950 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
2951 }
2952
2953 #[test]
2954 fn test_preset_max_price_rejects_negative_values() {
2955 let base = OpenRouterRoutingConfig {
2956 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2957 prompt_usd_per_million: Some(-1.0),
2958 completion_usd_per_million: None,
2959 }],
2960 ..Default::default()
2961 };
2962 let err = base.apply_presets().unwrap_err();
2963 assert!(
2964 err.contains("non-negative"),
2965 "error should mention non-negative: {err}"
2966 );
2967 }
2968
2969 #[test]
2970 fn test_preset_max_price_both_none_no_provider_field() {
2971 let base = OpenRouterRoutingConfig {
2972 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2973 prompt_usd_per_million: None,
2974 completion_usd_per_million: None,
2975 }],
2976 ..Default::default()
2977 };
2978 let result = base.apply_presets().unwrap();
2979 assert!(
2980 result.provider.is_none(),
2981 "MaxPrice with no dimensions should not produce a provider field"
2982 );
2983 }
2984
2985 #[test]
2986 fn test_preset_explicit_provider_overrides_preset() {
2987 let base = OpenRouterRoutingConfig {
2988 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2989 provider: Some(OpenRouterProviderRouting {
2990 sort: Some(OpenRouterProviderSort::Simple(
2992 OpenRouterProviderSortBy::Throughput,
2993 )),
2994 ..Default::default()
2995 }),
2996 ..Default::default()
2997 };
2998 let result = base.apply_presets().unwrap();
2999 let provider = result.provider.expect("provider set");
3000 assert_eq!(
3002 provider.sort,
3003 Some(OpenRouterProviderSort::Simple(
3004 OpenRouterProviderSortBy::Throughput
3005 ))
3006 );
3007 assert_eq!(provider.require_parameters, Some(true));
3009 }
3010
3011 #[test]
3012 fn test_preset_multiple_presets_combined() {
3013 let base = OpenRouterRoutingConfig {
3014 presets: vec![
3015 OpenRouterRoutingPreset::ZdrOnly,
3016 OpenRouterRoutingPreset::NoDataCollection,
3017 OpenRouterRoutingPreset::LowestLatencyReview,
3018 ],
3019 ..Default::default()
3020 };
3021 let result = base.apply_presets().unwrap();
3022 let provider = result.provider.expect("provider set");
3023 assert_eq!(provider.zdr, Some(true));
3024 assert_eq!(
3025 provider.data_collection,
3026 Some(OpenRouterDataCollection::Deny)
3027 );
3028 assert_eq!(
3029 provider.sort,
3030 Some(OpenRouterProviderSort::Simple(
3031 OpenRouterProviderSortBy::Throughput
3032 ))
3033 );
3034 }
3035
3036 #[test]
3037 fn test_preset_later_preset_overrides_sort() {
3038 let base = OpenRouterRoutingConfig {
3039 presets: vec![
3040 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3043 ..Default::default()
3044 };
3045 let result = base.apply_presets().unwrap();
3046 let provider = result.provider.expect("provider set");
3047 assert_eq!(
3049 provider.sort,
3050 Some(OpenRouterProviderSort::Simple(
3051 OpenRouterProviderSortBy::Throughput
3052 ))
3053 );
3054 assert_eq!(provider.require_parameters, Some(true));
3056 }
3057
3058 #[test]
3059 fn test_preset_non_empty_in_is_empty() {
3060 let with_preset = OpenRouterRoutingConfig {
3061 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3062 ..Default::default()
3063 };
3064 assert!(!with_preset.is_empty());
3065
3066 let without = OpenRouterRoutingConfig::default();
3067 assert!(without.is_empty());
3068 }
3069}