1use crate::credential_schema::CredentialFormSchema;
18use crate::error::{AgentLoopError, LlmErrorKind, Result};
19use crate::openresponses_protocol::{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(Debug, Clone, PartialEq, Eq)]
42pub struct LlmStreamError {
43 pub code: Option<String>,
45 pub status: Option<u16>,
47 pub message: String,
49}
50
51impl LlmStreamError {
52 pub fn new(message: impl Into<String>) -> Self {
53 Self {
54 code: None,
55 status: None,
56 message: message.into(),
57 }
58 }
59
60 pub fn provider(
62 code: Option<impl Into<String>>,
63 status: Option<u16>,
64 message: impl Into<String>,
65 ) -> Self {
66 Self {
67 code: code.map(Into::into),
68 status,
69 message: message.into(),
70 }
71 }
72
73 pub fn kind(&self) -> LlmErrorKind {
75 if let Some(code) = self.code.as_deref()
76 && let Some(kind) = LlmErrorKind::from_provider_code(code)
77 {
78 return kind;
79 }
80 if let Some(status) = self.status {
81 return LlmErrorKind::from_provider_status(status, &self.message);
82 }
83 LlmErrorKind::from_error_text(&self.message)
84 }
85}
86
87impl std::error::Error for LlmStreamError {}
88
89impl std::fmt::Display for LlmStreamError {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match (&self.code, self.status) {
92 (Some(code), Some(status)) => write!(f, "{code} ({status}): {}", self.message),
93 (Some(code), None) => write!(f, "{code}: {}", self.message),
94 (None, Some(status)) => write!(f, "({status}): {}", self.message),
95 (None, None) => f.write_str(&self.message),
96 }
97 }
98}
99
100impl From<String> for LlmStreamError {
101 fn from(message: String) -> Self {
102 Self::new(message)
103 }
104}
105
106impl From<&str> for LlmStreamError {
107 fn from(message: &str) -> Self {
108 Self::new(message)
109 }
110}
111
112#[derive(Debug, Clone)]
114pub enum LlmStreamEvent {
115 TextDelta(String),
117 ThinkingDelta(String),
119 ThinkingSignature(String),
122 ReasonItem {
128 provider: String,
130 model: Option<String>,
132 item_id: String,
134 encrypted_content: Option<String>,
136 summary: Vec<String>,
138 token_count: Option<u32>,
140 },
141 ToolCalls(Vec<ToolCall>),
143 MessagePhase(crate::execution_phase::ExecutionPhase),
153 Done(Box<LlmCompletionMetadata>),
155 Error(LlmStreamError),
157}
158
159#[derive(Debug, Clone)]
170pub struct DiscoveredModel {
171 pub model_id: String,
173 pub display_name: Option<String>,
175 pub created_at: Option<DateTime<Utc>>,
177 pub owned_by: Option<String>,
179 pub discovered_profile: Option<crate::model::ModelProfile>,
182}
183
184#[derive(Debug, Clone, Default)]
197pub struct LlmCompletionMetadata {
198 pub total_tokens: Option<u32>,
200 pub prompt_tokens: Option<u32>,
202 pub completion_tokens: Option<u32>,
204 pub cache_read_tokens: Option<u32>,
206 pub cache_creation_tokens: Option<u32>,
208 pub provider_cost_usd: Option<f64>,
212 pub model: Option<String>,
214 pub finish_reason: Option<String>,
216 pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
218 pub response_id: Option<String>,
221 pub phase: Option<String>,
225}
226
227pub fn disjoint_prompt_tokens(reported_input: u32, cache_read: Option<u32>) -> u32 {
237 reported_input.saturating_sub(cache_read.unwrap_or(0))
238}
239
240#[async_trait]
262pub trait ChatDriver: Send + Sync {
263 async fn chat_completion_stream(
265 &self,
266 messages: Vec<LlmMessage>,
267 config: &LlmCallConfig,
268 ) -> Result<LlmResponseStream>;
269
270 async fn chat_completion(
272 &self,
273 messages: Vec<LlmMessage>,
274 config: &LlmCallConfig,
275 ) -> Result<LlmResponse> {
276 use futures::StreamExt;
277
278 let mut stream = self.chat_completion_stream(messages, config).await?;
279 let mut text = String::new();
280 let mut thinking = String::new();
281 let mut thinking_signature: Option<String> = None;
282 let mut tool_calls = Vec::new();
283 let mut metadata = LlmCompletionMetadata::default();
284
285 while let Some(event) = stream.next().await {
286 match event? {
287 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
288 LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
289 LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
290 LlmStreamEvent::ReasonItem {
291 encrypted_content, ..
292 } => {
293 if let Some(sig) = encrypted_content {
294 thinking_signature = Some(sig);
295 }
296 }
297 LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
298 LlmStreamEvent::MessagePhase(_) => {}
301 LlmStreamEvent::Done(meta) => metadata = *meta,
302 LlmStreamEvent::Error(err) => {
303 return Err(crate::error::AgentLoopError::llm_kind(
304 err.kind(),
305 err.to_string(),
306 ));
307 }
308 }
309 }
310
311 Ok(LlmResponse {
312 text,
313 thinking: if thinking.is_empty() {
314 None
315 } else {
316 Some(thinking)
317 },
318 thinking_signature,
319 tool_calls: if tool_calls.is_empty() {
320 None
321 } else {
322 Some(tool_calls)
323 },
324 metadata,
325 })
326 }
327
328 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
336 Ok(None)
338 }
339
340 fn supports_compact(&self) -> bool {
349 false
351 }
352
353 fn supports_stateful_responses(&self) -> bool {
359 false
360 }
361
362 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
373 false
374 }
375
376 async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
396 Ok(None)
398 }
399}
400
401#[async_trait]
403impl ChatDriver for Box<dyn ChatDriver> {
404 async fn chat_completion_stream(
405 &self,
406 messages: Vec<LlmMessage>,
407 config: &LlmCallConfig,
408 ) -> Result<LlmResponseStream> {
409 (**self).chat_completion_stream(messages, config).await
410 }
411
412 async fn chat_completion(
413 &self,
414 messages: Vec<LlmMessage>,
415 config: &LlmCallConfig,
416 ) -> Result<LlmResponse> {
417 (**self).chat_completion(messages, config).await
418 }
419
420 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
421 (**self).list_models().await
422 }
423
424 fn supports_compact(&self) -> bool {
425 (**self).supports_compact()
426 }
427
428 fn supports_stateful_responses(&self) -> bool {
429 (**self).supports_stateful_responses()
430 }
431
432 fn supports_parallel_tool_calls(&self, model: &str) -> bool {
433 (**self).supports_parallel_tool_calls(model)
434 }
435
436 async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
437 (**self).compact(request).await
438 }
439}
440
441#[derive(Debug, Clone)]
447pub struct LlmMessage {
448 pub role: LlmMessageRole,
449 pub content: LlmMessageContent,
450 pub tool_calls: Option<Vec<ToolCall>>,
451 pub tool_call_id: Option<String>,
452 pub phase: Option<crate::execution_phase::ExecutionPhase>,
457 pub thinking: Option<String>,
460 pub thinking_signature: Option<String>,
463}
464
465impl LlmMessage {
466 pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
468 Self {
469 role,
470 content: LlmMessageContent::Text(content.into()),
471 tool_calls: None,
472 tool_call_id: None,
473 phase: None,
474 thinking: None,
475 thinking_signature: None,
476 }
477 }
478
479 pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
481 Self {
482 role,
483 content: LlmMessageContent::Parts(parts),
484 tool_calls: None,
485 tool_call_id: None,
486 phase: None,
487 thinking: None,
488 thinking_signature: None,
489 }
490 }
491
492 pub fn content_as_text(&self) -> String {
494 self.content.to_text()
495 }
496
497 pub fn prepend_text_prefix(&mut self, prefix: &str) {
502 match &mut self.content {
503 LlmMessageContent::Text(text) => {
504 *text = format!("{}{}", prefix, text);
505 }
506 LlmMessageContent::Parts(parts) => {
507 for part in parts.iter_mut() {
508 if let LlmContentPart::Text { text } = part {
509 *text = format!("{}{}", prefix, text);
510 return;
511 }
512 }
513 parts.insert(
515 0,
516 LlmContentPart::Text {
517 text: prefix.to_string(),
518 },
519 );
520 }
521 }
522 }
523}
524
525pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
536 let mut system: Option<String> = None;
537 for msg in messages {
538 if msg.role == LlmMessageRole::System {
539 let text = msg.content.to_text();
540 system = Some(match system.take() {
541 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
542 _ => text,
543 });
544 }
545 }
546 system
547}
548
549#[derive(Debug, Clone)]
551pub enum LlmMessageContent {
552 Text(String),
554 Parts(Vec<LlmContentPart>),
556}
557
558impl LlmMessageContent {
559 pub fn to_text(&self) -> String {
561 match self {
562 LlmMessageContent::Text(s) => s.clone(),
563 LlmMessageContent::Parts(parts) => parts
564 .iter()
565 .filter_map(|p| match p {
566 LlmContentPart::Text { text } => Some(text.clone()),
567 _ => None,
568 })
569 .collect::<Vec<_>>()
570 .join(""),
571 }
572 }
573
574 pub fn is_text(&self) -> bool {
576 matches!(self, LlmMessageContent::Text(_))
577 }
578
579 pub fn is_parts(&self) -> bool {
581 matches!(self, LlmMessageContent::Parts(_))
582 }
583}
584
585impl From<String> for LlmMessageContent {
586 fn from(s: String) -> Self {
587 LlmMessageContent::Text(s)
588 }
589}
590
591impl From<&str> for LlmMessageContent {
592 fn from(s: &str) -> Self {
593 LlmMessageContent::Text(s.to_string())
594 }
595}
596
597#[derive(Debug, Clone)]
599pub enum LlmContentPart {
600 Text { text: String },
602 Image { url: String },
604 Audio { url: String },
606}
607
608impl LlmContentPart {
609 pub fn text(text: impl Into<String>) -> Self {
611 LlmContentPart::Text { text: text.into() }
612 }
613
614 pub fn image(url: impl Into<String>) -> Self {
616 LlmContentPart::Image { url: url.into() }
617 }
618
619 pub fn audio(url: impl Into<String>) -> Self {
621 LlmContentPart::Audio { url: url.into() }
622 }
623}
624
625#[derive(Debug, Clone, PartialEq, Eq)]
627pub enum LlmMessageRole {
628 System,
629 User,
630 Assistant,
631 Tool,
632}
633
634#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
644pub struct ToolSearchConfig {
645 pub enabled: bool,
647 pub threshold: usize,
650}
651
652#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
654#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
655#[serde(rename_all = "snake_case")]
656pub enum PromptCacheStrategy {
657 #[default]
659 Auto,
660}
661
662#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
667#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
668pub struct PromptCacheConfig {
669 pub enabled: bool,
671 #[serde(default)]
673 pub strategy: PromptCacheStrategy,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub gemini_cached_content: Option<String>,
682}
683
684#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
694#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
695#[serde(tag = "kind", rename_all = "snake_case")]
696pub enum OpenRouterRoutingPreset {
697 CheapestWithTools,
699 LowestLatencyReview,
701 ZdrOnly,
703 ByokFirst,
705 NoDataCollection,
707 StrictJson,
709 ReasoningRequired,
711 MaxPrice {
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 prompt_usd_per_million: Option<f64>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
719 completion_usd_per_million: Option<f64>,
720 },
721}
722
723#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
731#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
732#[serde(rename_all = "snake_case")]
733pub enum OpenRouterCapacityStrategy {
734 #[default]
736 SharedCapacity,
737 ByokFirst,
741 ByokOnly,
746}
747
748#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
756#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
757#[serde(rename_all = "snake_case")]
758pub enum OpenRouterServerToolKind {
759 WebSearch,
760 WebFetch,
761 Datetime,
762 ImageGeneration,
763 ApplyPatch,
764 Fusion,
765 Advisor,
766 Subagent,
767}
768
769impl OpenRouterServerToolKind {
770 pub const ALL: [OpenRouterServerToolKind; 8] = [
772 Self::WebSearch,
773 Self::WebFetch,
774 Self::Datetime,
775 Self::ImageGeneration,
776 Self::ApplyPatch,
777 Self::Fusion,
778 Self::Advisor,
779 Self::Subagent,
780 ];
781
782 pub fn name(&self) -> &'static str {
784 match self {
785 Self::WebSearch => "web_search",
786 Self::WebFetch => "web_fetch",
787 Self::Datetime => "datetime",
788 Self::ImageGeneration => "image_generation",
789 Self::ApplyPatch => "apply_patch",
790 Self::Fusion => "fusion",
791 Self::Advisor => "advisor",
792 Self::Subagent => "subagent",
793 }
794 }
795
796 pub fn display_name(&self) -> &'static str {
798 match self {
799 Self::WebSearch => "Web Search",
800 Self::WebFetch => "Web Fetch",
801 Self::Datetime => "Date & Time",
802 Self::ImageGeneration => "Image Generation",
803 Self::ApplyPatch => "Apply Patch",
804 Self::Fusion => "Fusion",
805 Self::Advisor => "Advisor",
806 Self::Subagent => "Subagent",
807 }
808 }
809
810 pub fn wire_type(&self) -> String {
813 format!("openrouter:{}", self.name())
814 }
815
816 pub fn from_name(name: &str) -> Option<Self> {
818 Self::ALL.into_iter().find(|kind| kind.name() == name)
819 }
820}
821
822#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
826#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
827pub struct OpenRouterServerTool {
828 pub kind: OpenRouterServerToolKind,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
830 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
831 pub parameters: Option<serde_json::Value>,
832}
833
834impl OpenRouterServerTool {
835 pub fn new(kind: OpenRouterServerToolKind) -> Self {
837 Self {
838 kind,
839 parameters: None,
840 }
841 }
842
843 pub fn with_parameters(kind: OpenRouterServerToolKind, parameters: serde_json::Value) -> Self {
845 Self {
846 kind,
847 parameters: Some(parameters),
848 }
849 }
850}
851
852#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
855#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
856pub struct OpenRouterRoutingConfig {
857 #[serde(default, skip_serializing_if = "Vec::is_empty")]
859 pub models: Vec<String>,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub route: Option<OpenRouterRoute>,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub provider: Option<OpenRouterProviderRouting>,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub plugins: Option<OpenRouterPluginConfig>,
870 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
875 #[serde(default, skip_serializing_if = "Vec::is_empty")]
879 pub presets: Vec<OpenRouterRoutingPreset>,
880 #[serde(default, skip_serializing_if = "Vec::is_empty")]
883 pub server_tools: Vec<OpenRouterServerTool>,
884}
885
886impl OpenRouterRoutingConfig {
887 pub fn is_empty(&self) -> bool {
888 self.models.is_empty()
889 && self.route.is_none()
890 && self.provider.is_none()
891 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
892 && matches!(
893 self.capacity_strategy,
894 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
895 )
896 && self.presets.is_empty()
897 && self.server_tools.is_empty()
898 }
899
900 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
902 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
903 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
904 Self {
905 models,
906 route,
907 provider: None,
908 plugins: None,
909 capacity_strategy: None,
910 presets: vec![],
911 server_tools: vec![],
912 }
913 }
914
915 pub fn validate_for_primary_model(
916 &self,
917 primary_model: &str,
918 ) -> std::result::Result<(), String> {
919 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
920 return Err(
921 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
922 );
923 }
924
925 if let Some(first_model) = self.models.first()
926 && first_model != primary_model
927 {
928 return Err(format!(
929 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
930 ));
931 }
932
933 Ok(())
934 }
935
936 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
946 match self.capacity_strategy {
947 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
948 Some(OpenRouterCapacityStrategy::ByokFirst) => {
949 let mut result = self.clone();
950 let provider = result.provider.get_or_insert_with(Default::default);
951 if provider.allow_fallbacks.is_none() {
952 provider.allow_fallbacks = Some(true);
953 }
954 Ok(result)
955 }
956 Some(OpenRouterCapacityStrategy::ByokOnly) => {
957 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
958 if only_is_empty {
959 return Err(
960 "OpenRouter BYOK-only strategy requires provider.only to list at least \
961 one upstream provider slug. Configure the provider list to match the \
962 BYOK providers registered in your OpenRouter workspace."
963 .to_string(),
964 );
965 }
966 let mut result = self.clone();
967 let provider = result.provider.get_or_insert_with(Default::default);
968 provider.allow_fallbacks = Some(false);
969 Ok(result)
970 }
971 }
972 }
973
974 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
984 if self.presets.is_empty() {
985 return Ok(self.clone());
986 }
987
988 let mut derived = OpenRouterProviderRouting::default();
989
990 for preset in &self.presets {
991 match preset {
992 OpenRouterRoutingPreset::CheapestWithTools => {
993 derived.require_parameters = Some(true);
994 derived.sort = Some(OpenRouterProviderSort::Simple(
995 OpenRouterProviderSortBy::Price,
996 ));
997 }
998 OpenRouterRoutingPreset::LowestLatencyReview => {
999 derived.sort = Some(OpenRouterProviderSort::Simple(
1000 OpenRouterProviderSortBy::Throughput,
1001 ));
1002 }
1003 OpenRouterRoutingPreset::ZdrOnly => {
1004 derived.zdr = Some(true);
1005 }
1006 OpenRouterRoutingPreset::ByokFirst => {
1007 if derived.allow_fallbacks.is_none() {
1008 derived.allow_fallbacks = Some(true);
1009 }
1010 }
1011 OpenRouterRoutingPreset::NoDataCollection => {
1012 derived.data_collection = Some(OpenRouterDataCollection::Deny);
1013 }
1014 OpenRouterRoutingPreset::StrictJson
1015 | OpenRouterRoutingPreset::ReasoningRequired => {
1016 derived.require_parameters = Some(true);
1017 }
1018 OpenRouterRoutingPreset::MaxPrice {
1019 prompt_usd_per_million,
1020 completion_usd_per_million,
1021 } => {
1022 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
1023 || completion_usd_per_million.is_some_and(|v| v < 0.0)
1024 {
1025 return Err(
1026 "MaxPrice preset values must be non-negative USD per million tokens"
1027 .to_string(),
1028 );
1029 }
1030 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
1031 let mp = derived.max_price.get_or_insert_with(Default::default);
1032 if let Some(p) = prompt_usd_per_million {
1033 mp.prompt = Some(p / 1_000_000.0);
1034 }
1035 if let Some(c) = completion_usd_per_million {
1036 mp.completion = Some(c / 1_000_000.0);
1037 }
1038 }
1039 }
1040 }
1041 }
1042
1043 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
1045
1046 let mut result = self.clone();
1047 result.presets = vec![];
1048 result.provider = if merged.is_empty() {
1049 None
1050 } else {
1051 Some(merged)
1052 };
1053 Ok(result)
1054 }
1055}
1056
1057fn merge_provider_routing(
1061 derived: OpenRouterProviderRouting,
1062 explicit: OpenRouterProviderRouting,
1063) -> OpenRouterProviderRouting {
1064 OpenRouterProviderRouting {
1065 order: if !explicit.order.is_empty() {
1066 explicit.order
1067 } else {
1068 derived.order
1069 },
1070 only: if !explicit.only.is_empty() {
1071 explicit.only
1072 } else {
1073 derived.only
1074 },
1075 ignore: if !explicit.ignore.is_empty() {
1076 explicit.ignore
1077 } else {
1078 derived.ignore
1079 },
1080 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
1081 require_parameters: explicit.require_parameters.or(derived.require_parameters),
1082 data_collection: explicit.data_collection.or(derived.data_collection),
1083 zdr: explicit.zdr.or(derived.zdr),
1084 enforce_distillable_text: explicit
1085 .enforce_distillable_text
1086 .or(derived.enforce_distillable_text),
1087 quantizations: if !explicit.quantizations.is_empty() {
1088 explicit.quantizations
1089 } else {
1090 derived.quantizations
1091 },
1092 sort: explicit.sort.or(derived.sort),
1093 max_price: explicit.max_price.or(derived.max_price),
1094 }
1095}
1096
1097#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1099#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1100#[serde(rename_all = "snake_case")]
1101pub enum OpenRouterRoute {
1102 Fallback,
1103}
1104
1105#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1107#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1108pub struct OpenRouterProviderRouting {
1109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1111 pub order: Vec<String>,
1112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1114 pub only: Vec<String>,
1115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1117 pub ignore: Vec<String>,
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub allow_fallbacks: Option<bool>,
1121 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub require_parameters: Option<bool>,
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub data_collection: Option<OpenRouterDataCollection>,
1127 #[serde(default, skip_serializing_if = "Option::is_none")]
1129 pub zdr: Option<bool>,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub enforce_distillable_text: Option<bool>,
1133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1135 pub quantizations: Vec<String>,
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub sort: Option<OpenRouterProviderSort>,
1139 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub max_price: Option<OpenRouterMaxPrice>,
1142}
1143
1144impl OpenRouterProviderRouting {
1145 pub fn is_empty(&self) -> bool {
1146 self.order.is_empty()
1147 && self.only.is_empty()
1148 && self.ignore.is_empty()
1149 && self.allow_fallbacks.is_none()
1150 && self.require_parameters.is_none()
1151 && self.data_collection.is_none()
1152 && self.zdr.is_none()
1153 && self.enforce_distillable_text.is_none()
1154 && self.quantizations.is_empty()
1155 && self.sort.is_none()
1156 && self.max_price.is_none()
1157 }
1158}
1159
1160#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1163#[serde(rename_all = "snake_case")]
1164pub enum OpenRouterDataCollection {
1165 Allow,
1166 Deny,
1167}
1168
1169#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1171#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1172#[serde(untagged)]
1173pub enum OpenRouterProviderSort {
1174 Simple(OpenRouterProviderSortBy),
1175 Advanced(OpenRouterProviderSortOptions),
1176}
1177
1178#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1180#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1181#[serde(rename_all = "snake_case")]
1182pub enum OpenRouterProviderSortBy {
1183 Price,
1184 Throughput,
1185 Latency,
1186}
1187
1188#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1190#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1191pub struct OpenRouterProviderSortOptions {
1192 pub by: OpenRouterProviderSortBy,
1193 #[serde(default, skip_serializing_if = "Option::is_none")]
1194 pub partition: Option<OpenRouterSortPartition>,
1195}
1196
1197#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1200#[serde(rename_all = "snake_case")]
1201pub enum OpenRouterSortPartition {
1202 Model,
1203 None,
1204}
1205
1206#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1209#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1210pub struct OpenRouterMaxPrice {
1211 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub prompt: Option<f64>,
1213 #[serde(default, skip_serializing_if = "Option::is_none")]
1214 pub completion: Option<f64>,
1215 #[serde(default, skip_serializing_if = "Option::is_none")]
1216 pub request: Option<f64>,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub image: Option<f64>,
1219}
1220
1221#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1227#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1228pub struct OpenRouterWebSearchPlugin {
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub max_results: Option<u32>,
1232 #[serde(default, skip_serializing_if = "Option::is_none")]
1234 pub search_prompt: Option<String>,
1235}
1236
1237#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1242#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1243pub struct OpenRouterFilePlugin {}
1244
1245#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1250#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1251pub struct OpenRouterPluginConfig {
1252 #[serde(default, skip_serializing_if = "Option::is_none")]
1254 pub web: Option<OpenRouterWebSearchPlugin>,
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1257 pub file: Option<OpenRouterFilePlugin>,
1258}
1259
1260impl OpenRouterPluginConfig {
1261 pub fn is_empty(&self) -> bool {
1262 self.web.is_none() && self.file.is_none()
1263 }
1264}
1265
1266pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
1268pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
1270
1271#[derive(Debug, Clone)]
1273pub struct LlmCallConfig {
1274 pub model: String,
1275 pub temperature: Option<f32>,
1276 pub max_tokens: Option<u32>,
1277 pub tools: Vec<ToolDefinition>,
1278 pub reasoning_effort: Option<String>,
1280 pub speed: Option<String>,
1284 pub verbosity: Option<String>,
1288 pub metadata: HashMap<String, String>,
1292 pub previous_response_id: Option<String>,
1295 pub tool_search: Option<ToolSearchConfig>,
1297 pub prompt_cache: Option<PromptCacheConfig>,
1299 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1301 pub parallel_tool_calls: Option<bool>,
1308 pub volatile_suffix_len: usize,
1318}
1319
1320impl LlmCallConfig {
1321 pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1331 if supported {
1332 self.parallel_tool_calls
1333 } else {
1334 None
1335 }
1336 }
1337}
1338
1339#[derive(Debug, Clone)]
1344pub struct LlmResponse {
1345 pub text: String,
1346 pub thinking: Option<String>,
1348 pub thinking_signature: Option<String>,
1350 pub tool_calls: Option<Vec<ToolCall>>,
1351 pub metadata: LlmCompletionMetadata,
1352}
1353
1354pub struct LlmCallConfigBuilder {
1360 config: LlmCallConfig,
1361}
1362
1363impl LlmCallConfigBuilder {
1364 pub fn from_config(config: LlmCallConfig) -> Self {
1366 Self { config }
1367 }
1368
1369 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1371 self.config.reasoning_effort = Some(effort.into());
1372 self
1373 }
1374
1375 pub fn speed(mut self, speed: impl Into<String>) -> Self {
1377 self.config.speed = Some(speed.into());
1378 self
1379 }
1380
1381 pub fn verbosity(mut self, verbosity: impl Into<String>) -> Self {
1383 self.config.verbosity = Some(verbosity.into());
1384 self
1385 }
1386
1387 pub fn model(mut self, model: impl Into<String>) -> Self {
1389 self.config.model = model.into();
1390 self
1391 }
1392
1393 pub fn temperature(mut self, temp: f32) -> Self {
1395 self.config.temperature = Some(temp);
1396 self
1397 }
1398
1399 pub fn max_tokens(mut self, tokens: u32) -> Self {
1401 self.config.max_tokens = Some(tokens);
1402 self
1403 }
1404
1405 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1407 self.config.tools = tools;
1408 self
1409 }
1410
1411 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1416 self.config.metadata = metadata;
1417 self
1418 }
1419
1420 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1422 self.config.metadata.insert(key.into(), value.into());
1423 self
1424 }
1425
1426 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1428 self.config.previous_response_id = id;
1429 self
1430 }
1431
1432 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1434 self.config.tool_search = Some(config);
1435 self
1436 }
1437
1438 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1440 self.config.prompt_cache = Some(config);
1441 self
1442 }
1443
1444 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1446 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1447 self
1448 }
1449
1450 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1452 self.config.parallel_tool_calls = parallel_tool_calls;
1453 self
1454 }
1455
1456 pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1460 self.config.volatile_suffix_len = len;
1461 self
1462 }
1463
1464 pub fn build(self) -> LlmCallConfig {
1466 self.config
1467 }
1468}
1469
1470pub use crate::provider::DriverId;
1479
1480#[derive(Debug, Clone, Default, PartialEq, Eq)]
1486pub struct ProviderMetadata {
1487 pub refresh_token: Option<String>,
1489 pub account_id: Option<String>,
1491 pub extra: Option<serde_json::Value>,
1493}
1494
1495#[derive(Debug, Clone)]
1497pub struct ProviderConfig {
1498 pub provider_type: DriverId,
1500 pub api_key: Option<String>,
1502 pub base_url: Option<String>,
1504 pub metadata: ProviderMetadata,
1506}
1507
1508impl ProviderConfig {
1509 pub fn new(provider_type: DriverId) -> Self {
1511 Self {
1512 provider_type,
1513 api_key: None,
1514 base_url: None,
1515 metadata: ProviderMetadata::default(),
1516 }
1517 }
1518
1519 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1521 self.api_key = Some(api_key.into());
1522 self
1523 }
1524
1525 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1527 self.base_url = Some(base_url.into());
1528 self
1529 }
1530
1531 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1533 self.metadata = metadata;
1534 self
1535 }
1536}
1537
1538#[derive(Debug, Clone)]
1544pub struct DriverConfig {
1545 pub provider_type: DriverId,
1547 pub api_key: Option<String>,
1553 pub credentials: std::collections::BTreeMap<String, String>,
1559 pub base_url: Option<String>,
1561 pub metadata: ProviderMetadata,
1563}
1564
1565impl DriverConfig {
1566 pub fn from_provider_config(config: &ProviderConfig) -> Self {
1572 Self {
1573 provider_type: config.provider_type.clone(),
1574 credentials: crate::credential_schema::parse_credential_document(
1575 config.api_key.as_deref(),
1576 ),
1577 api_key: config.api_key.clone(),
1578 base_url: config.base_url.clone(),
1579 metadata: config.metadata.clone(),
1580 }
1581 }
1582
1583 pub fn credential(&self, name: &str) -> Option<&str> {
1585 self.credentials
1586 .get(name)
1587 .map(String::as_str)
1588 .filter(|s| !s.is_empty())
1589 }
1590}
1591
1592pub type BoxedChatDriver = Box<dyn ChatDriver>;
1597
1598#[derive(Debug, Clone)]
1604pub struct EmbedRequest {
1605 pub texts: Vec<String>,
1607 pub model: String,
1609}
1610
1611#[derive(Debug, Clone)]
1613pub struct EmbedResponse {
1614 pub embeddings: Vec<Vec<f32>>,
1616 pub usage_tokens: Option<u32>,
1619}
1620
1621#[derive(Debug, thiserror::Error)]
1623pub enum EmbeddingsDriverError {
1624 #[error("embeddings provider returned an error: {0}")]
1625 Provider(String),
1626 #[error("embeddings request failed: {0}")]
1627 Transport(String),
1628}
1629
1630#[async_trait]
1636pub trait EmbeddingsDriver: Send + Sync {
1637 async fn embed(
1639 &self,
1640 request: EmbedRequest,
1641 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1642}
1643
1644pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1646
1647pub type EmbeddingsDriverFactory =
1649 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1650
1651pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1660
1661#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1667#[serde(rename_all = "snake_case")]
1668pub enum ServiceKind {
1669 Chat,
1671 Embeddings,
1673 Realtime,
1675 Images,
1677 Rerank,
1679}
1680
1681impl std::fmt::Display for ServiceKind {
1682 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1683 let s = match self {
1684 ServiceKind::Chat => "chat",
1685 ServiceKind::Embeddings => "embeddings",
1686 ServiceKind::Realtime => "realtime",
1687 ServiceKind::Images => "images",
1688 ServiceKind::Rerank => "rerank",
1689 };
1690 f.write_str(s)
1691 }
1692}
1693
1694#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1707pub enum DriverOAuthFlow {
1708 OpenRouterPkce,
1716}
1717
1718#[derive(Debug, Clone)]
1723pub struct DriverOAuthConfig {
1724 pub authorize_url: String,
1726 pub token_url: String,
1728 pub flow: DriverOAuthFlow,
1730}
1731
1732impl DriverOAuthConfig {
1733 pub fn openrouter() -> Self {
1735 Self {
1736 authorize_url: "https://openrouter.ai/auth".to_string(),
1737 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1738 flow: DriverOAuthFlow::OpenRouterPkce,
1739 }
1740 }
1741}
1742
1743#[derive(Clone)]
1750pub struct DriverDescriptor {
1751 pub id: DriverId,
1753 pub display_name: String,
1755 pub services: Vec<ServiceKind>,
1757 pub credential_schema: CredentialFormSchema,
1759 pub oauth: Option<DriverOAuthConfig>,
1762 pub chat: Option<DriverFactory>,
1764 pub embeddings: Option<EmbeddingsDriverFactory>,
1766}
1767
1768impl DriverDescriptor {
1769 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1774 where
1775 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1776 {
1777 let id = id.into();
1778 Self {
1779 display_name: default_display_name(&id),
1780 credential_schema: default_credential_schema(&id),
1781 services: vec![ServiceKind::Chat],
1782 oauth: None,
1783 chat: Some(Arc::new(factory)),
1784 embeddings: None,
1785 id,
1786 }
1787 }
1788
1789 pub fn supports(&self, service: ServiceKind) -> bool {
1791 self.services.contains(&service)
1792 }
1793}
1794
1795impl std::fmt::Debug for DriverDescriptor {
1796 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1797 f.debug_struct("DriverDescriptor")
1798 .field("id", &self.id)
1799 .field("display_name", &self.display_name)
1800 .field("services", &self.services)
1801 .field("oauth", &self.oauth.is_some())
1802 .field("chat", &self.chat.is_some())
1803 .field("embeddings", &self.embeddings.is_some())
1804 .finish()
1805 }
1806}
1807
1808fn default_display_name(id: &DriverId) -> String {
1809 match id {
1810 DriverId::OpenAI => "OpenAI".to_string(),
1811 DriverId::OpenRouter => "OpenRouter".to_string(),
1812 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1813 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1814 DriverId::Anthropic => "Anthropic".to_string(),
1815 DriverId::Gemini => "Google Gemini".to_string(),
1816 DriverId::Bedrock => "AWS Bedrock".to_string(),
1817 DriverId::Mai => "Microsoft MAI".to_string(),
1818 DriverId::Fireworks => "Fireworks AI".to_string(),
1819 DriverId::LlmSim => "LLM Simulator".to_string(),
1820 DriverId::External(id) => id.to_string(),
1821 }
1822}
1823
1824fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1825 match id {
1826 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1828 _ => CredentialFormSchema::api_key(String::new()),
1829 }
1830}
1831
1832#[derive(Clone, Default)]
1852pub struct DriverRegistry {
1853 descriptors: HashMap<DriverId, DriverDescriptor>,
1854}
1855
1856impl DriverRegistry {
1857 pub fn new() -> Self {
1859 Self {
1860 descriptors: HashMap::new(),
1861 }
1862 }
1863
1864 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1870 if self.descriptors.contains_key(&descriptor.id) {
1871 panic!(
1872 "driver already registered for provider '{}'; \
1873 use register_descriptor_or_replace to overwrite intentionally",
1874 descriptor.id
1875 );
1876 }
1877 self.descriptors.insert(descriptor.id.clone(), descriptor);
1878 }
1879
1880 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1882 self.descriptors.insert(descriptor.id.clone(), descriptor);
1883 }
1884
1885 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1891 where
1892 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1893 {
1894 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1895 }
1896
1897 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1902 where
1903 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1904 {
1905 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1906 }
1907
1908 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1913 where
1914 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1915 {
1916 self.register(DriverId::external(id), factory);
1917 }
1918
1919 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1928 let requires_api_key = !matches!(
1932 config.provider_type,
1933 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
1934 );
1935 if requires_api_key && config.api_key.is_none() {
1936 return Err(AgentLoopError::llm(
1937 "API key is required. Configure the API key in provider settings.",
1938 ));
1939 }
1940
1941 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1943 AgentLoopError::driver_not_registered(config.provider_type.to_string())
1944 })?;
1945 let factory = descriptor.chat.as_ref().ok_or_else(|| {
1946 AgentLoopError::llm(format!(
1947 "Provider driver '{}' does not implement the chat service.",
1948 config.provider_type
1949 ))
1950 })?;
1951
1952 let driver_config = DriverConfig::from_provider_config(config);
1954 Ok(factory(&driver_config))
1955 }
1956
1957 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1959 self.descriptors.contains_key(provider_type)
1960 }
1961
1962 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1964 self.descriptors.get(provider_type)
1965 }
1966
1967 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1969 self.descriptors
1970 .get(provider_type)
1971 .is_some_and(|d| d.supports(service))
1972 }
1973
1974 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1976 self.descriptors
1977 .values()
1978 .filter(|d| d.supports(service))
1979 .map(|d| d.id.clone())
1980 .collect()
1981 }
1982
1983 pub fn registered_providers(&self) -> Vec<DriverId> {
1985 self.descriptors.keys().cloned().collect()
1986 }
1987
1988 pub fn create_embeddings_driver(
1996 &self,
1997 config: &ProviderConfig,
1998 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
1999 let requires_api_key = !matches!(
2000 config.provider_type,
2001 DriverId::LlmSim | DriverId::External(_)
2002 );
2003 if requires_api_key && config.api_key.is_none() {
2004 return Err(EmbeddingsDriverError::Provider(
2005 "API key is required. Configure the API key in provider settings.".to_string(),
2006 ));
2007 }
2008 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2009 EmbeddingsDriverError::Provider(format!(
2010 "No driver registered for provider '{}'",
2011 config.provider_type
2012 ))
2013 })?;
2014 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2015 EmbeddingsDriverError::Provider(format!(
2016 "Provider driver '{}' does not implement the embeddings service.",
2017 config.provider_type
2018 ))
2019 })?;
2020 let driver_config = DriverConfig::from_provider_config(config);
2021 Ok(factory(&driver_config))
2022 }
2023}
2024
2025const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2030
2031const TRUNCATION_SUFFIX: &str =
2032 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2033
2034pub fn truncate_tool_result(text: String) -> String {
2035 if text.len() <= MAX_TOOL_RESULT_BYTES {
2036 return text;
2037 }
2038 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2039 let mut end = content_budget;
2040 while end > 0 && !text.is_char_boundary(end) {
2041 end -= 1;
2042 }
2043 let mut truncated = text[..end].to_string();
2044 truncated.push_str(TRUNCATION_SUFFIX);
2045 truncated
2046}
2047
2048#[cfg(test)]
2053mod tests {
2054 use super::*;
2055
2056 #[test]
2057 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2058 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2061 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2063 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2064 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2066 }
2067
2068 #[test]
2069 fn test_chat_driver_defaults_are_conservative_and_boxed_capabilities_forward() {
2070 struct DefaultDriver;
2072 #[async_trait]
2073 impl ChatDriver for DefaultDriver {
2074 async fn chat_completion_stream(
2075 &self,
2076 _messages: Vec<LlmMessage>,
2077 _config: &LlmCallConfig,
2078 ) -> Result<LlmResponseStream> {
2079 unreachable!()
2080 }
2081 }
2082 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2083 assert!(!DefaultDriver.supports_stateful_responses());
2084
2085 struct StatefulDriver;
2086 #[async_trait]
2087 impl ChatDriver for StatefulDriver {
2088 async fn chat_completion_stream(
2089 &self,
2090 _messages: Vec<LlmMessage>,
2091 _config: &LlmCallConfig,
2092 ) -> Result<LlmResponseStream> {
2093 unreachable!()
2094 }
2095
2096 fn supports_stateful_responses(&self) -> bool {
2097 true
2098 }
2099 }
2100 let boxed: BoxedChatDriver = Box::new(StatefulDriver);
2101 assert!(boxed.supports_stateful_responses());
2102 }
2103
2104 #[test]
2105 fn test_fold_system_messages_none_when_absent() {
2106 let messages = vec![
2107 LlmMessage::text(LlmMessageRole::User, "hi"),
2108 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2109 ];
2110 assert_eq!(fold_system_messages(&messages), None);
2111 }
2112
2113 #[test]
2114 fn test_fold_system_messages_single() {
2115 let messages = vec![
2116 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2117 LlmMessage::text(LlmMessageRole::User, "hi"),
2118 ];
2119 assert_eq!(
2120 fold_system_messages(&messages),
2121 Some("AGENT-PROMPT".to_string())
2122 );
2123 }
2124
2125 #[test]
2126 fn test_fold_system_messages_accumulates_in_order() {
2127 let messages = vec![
2131 LlmMessage::text(LlmMessageRole::System, "A"),
2132 LlmMessage::text(LlmMessageRole::User, "hi"),
2133 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2134 LlmMessage::text(LlmMessageRole::System, "B"),
2135 ];
2136 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2137 }
2138
2139 #[test]
2140 fn test_fold_system_messages_concatenates_parts() {
2141 let messages = vec![LlmMessage::parts(
2142 LlmMessageRole::System,
2143 vec![
2144 LlmContentPart::text("foo"),
2145 LlmContentPart::image("data:image/png;base64,xxx"),
2146 LlmContentPart::text("bar"),
2147 ],
2148 )];
2149 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2150 }
2151
2152 #[test]
2153 fn test_openrouter_fallback_models_empty_is_empty() {
2154 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2155
2156 assert!(routing.is_empty());
2157 assert_eq!(routing.route, None);
2158 }
2159
2160 #[test]
2161 fn test_openrouter_routing_validates_primary_model() {
2162 let routing = OpenRouterRoutingConfig::fallback_models([
2163 "openai/gpt-5-mini",
2164 "anthropic/claude-sonnet-4.5",
2165 ]);
2166
2167 assert!(
2168 routing
2169 .validate_for_primary_model("openai/gpt-5-mini")
2170 .is_ok()
2171 );
2172 let err = routing
2173 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2174 .unwrap_err();
2175 assert!(err.contains("models[0]"));
2176 }
2177
2178 #[test]
2179 fn test_openrouter_routing_rejects_fallback_without_models() {
2180 let routing = OpenRouterRoutingConfig {
2181 route: Some(OpenRouterRoute::Fallback),
2182 ..Default::default()
2183 };
2184
2185 let err = routing
2186 .validate_for_primary_model("openai/gpt-5-mini")
2187 .unwrap_err();
2188 assert!(err.contains("requires at least one model"));
2189 }
2190
2191 #[test]
2192 fn test_openrouter_routing_serializes_request_fields() {
2193 let routing = OpenRouterRoutingConfig {
2194 models: vec![
2195 "openai/gpt-5-mini".to_string(),
2196 "anthropic/claude-sonnet-4.5".to_string(),
2197 ],
2198 route: Some(OpenRouterRoute::Fallback),
2199 provider: Some(OpenRouterProviderRouting {
2200 order: vec!["anthropic".to_string(), "openai".to_string()],
2201 allow_fallbacks: Some(false),
2202 require_parameters: Some(true),
2203 data_collection: Some(OpenRouterDataCollection::Deny),
2204 zdr: Some(true),
2205 sort: Some(OpenRouterProviderSort::Advanced(
2206 OpenRouterProviderSortOptions {
2207 by: OpenRouterProviderSortBy::Throughput,
2208 partition: Some(OpenRouterSortPartition::None),
2209 },
2210 )),
2211 max_price: Some(OpenRouterMaxPrice {
2212 prompt: Some(1.0),
2213 completion: Some(2.0),
2214 ..Default::default()
2215 }),
2216 ..Default::default()
2217 }),
2218 ..Default::default()
2219 };
2220
2221 let json = serde_json::to_value(routing).unwrap();
2222
2223 assert_eq!(
2224 json,
2225 serde_json::json!({
2226 "models": [
2227 "openai/gpt-5-mini",
2228 "anthropic/claude-sonnet-4.5"
2229 ],
2230 "route": "fallback",
2231 "provider": {
2232 "order": ["anthropic", "openai"],
2233 "allow_fallbacks": false,
2234 "require_parameters": true,
2235 "data_collection": "deny",
2236 "zdr": true,
2237 "sort": {
2238 "by": "throughput",
2239 "partition": "none"
2240 },
2241 "max_price": {
2242 "prompt": 1.0,
2243 "completion": 2.0
2244 }
2245 }
2246 })
2247 );
2248 }
2249
2250 #[test]
2251 fn test_provider_type_parsing() {
2252 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2253 assert_eq!(
2254 "openrouter".parse::<DriverId>().unwrap(),
2255 DriverId::OpenRouter
2256 );
2257 assert_eq!(
2258 "openai_completions".parse::<DriverId>().unwrap(),
2259 DriverId::OpenAICompletions
2260 );
2261 assert_eq!(
2262 "azure_openai".parse::<DriverId>().unwrap(),
2263 DriverId::AzureOpenAI
2264 );
2265 assert_eq!(
2266 "anthropic".parse::<DriverId>().unwrap(),
2267 DriverId::Anthropic
2268 );
2269 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2270 assert_eq!(
2272 "ollama".parse::<DriverId>().unwrap(),
2273 DriverId::external("ollama")
2274 );
2275 assert_eq!(
2276 "custom".parse::<DriverId>().unwrap(),
2277 DriverId::external("custom")
2278 );
2279 }
2280
2281 #[test]
2282 fn test_external_provider_id_is_case_insensitive() {
2283 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2286 assert_eq!(
2287 "Ollama".parse::<DriverId>().unwrap(),
2288 "ollama".parse::<DriverId>().unwrap()
2289 );
2290 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2291 assert_eq!(
2293 DriverId::external("MyProvider"),
2294 "myprovider".parse::<DriverId>().unwrap()
2295 );
2296 }
2297
2298 #[test]
2299 fn test_provider_type_display() {
2300 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2301 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2302 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2303 assert_eq!(
2304 DriverId::OpenAICompletions.to_string(),
2305 "openai_completions"
2306 );
2307 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2308 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2309 }
2310
2311 #[test]
2312 fn test_provider_config_builder() {
2313 let config = ProviderConfig::new(DriverId::Anthropic)
2314 .with_api_key("test-key")
2315 .with_base_url("https://custom.api.com");
2316
2317 assert_eq!(config.provider_type, DriverId::Anthropic);
2318 assert_eq!(config.api_key, Some("test-key".to_string()));
2319 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2320 }
2321
2322 #[test]
2323 fn test_driver_registry_requires_api_key() {
2324 let mut registry = DriverRegistry::new();
2326 registry.register(DriverId::OpenAI, |_config| {
2327 struct MockDriver;
2329 #[async_trait]
2330 impl ChatDriver for MockDriver {
2331 async fn chat_completion_stream(
2332 &self,
2333 _messages: Vec<LlmMessage>,
2334 _config: &LlmCallConfig,
2335 ) -> Result<LlmResponseStream> {
2336 unimplemented!()
2337 }
2338 }
2339 Box::new(MockDriver)
2340 });
2341
2342 let config = ProviderConfig::new(DriverId::OpenAI);
2344 let result = registry.create_chat_driver(&config);
2345 assert!(result.is_err());
2346
2347 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2349 let result = registry.create_chat_driver(&config_with_key);
2350 assert!(result.is_ok());
2351 }
2352
2353 #[test]
2354 fn test_driver_registry_returns_error_for_unregistered_provider() {
2355 let registry = DriverRegistry::new();
2356 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2357
2358 let result = registry.create_chat_driver(&config);
2359
2360 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2362 assert_eq!(provider, "anthropic");
2363 } else {
2364 panic!("Expected DriverNotRegistered error");
2365 }
2366 }
2367
2368 #[test]
2369 fn test_driver_registry_registration() {
2370 let mut registry = DriverRegistry::new();
2371
2372 assert!(!registry.has_driver(&DriverId::OpenAI));
2373 assert!(!registry.has_driver(&DriverId::Anthropic));
2374
2375 registry.register(DriverId::OpenAI, |_config| {
2376 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 assert!(registry.has_driver(&DriverId::OpenAI));
2391 assert!(!registry.has_driver(&DriverId::Anthropic));
2392 }
2393
2394 #[test]
2395 fn test_register_external_and_create_driver_without_api_key() {
2396 struct MockDriver;
2397 #[async_trait]
2398 impl ChatDriver for MockDriver {
2399 async fn chat_completion_stream(
2400 &self,
2401 _messages: Vec<LlmMessage>,
2402 _config: &LlmCallConfig,
2403 ) -> Result<LlmResponseStream> {
2404 unimplemented!()
2405 }
2406 }
2407
2408 let mut registry = DriverRegistry::new();
2409 registry.register_external("openai-codex", |config| {
2410 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2412 Box::new(MockDriver)
2413 });
2414
2415 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2416
2417 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2419 ProviderMetadata {
2420 refresh_token: Some("rt".into()),
2421 ..Default::default()
2422 },
2423 );
2424 assert!(registry.create_chat_driver(&config).is_ok());
2425 }
2426
2427 #[test]
2428 fn test_register_defaults_to_chat_only_descriptor() {
2429 struct MockDriver;
2430 #[async_trait]
2431 impl ChatDriver for MockDriver {
2432 async fn chat_completion_stream(
2433 &self,
2434 _messages: Vec<LlmMessage>,
2435 _config: &LlmCallConfig,
2436 ) -> Result<LlmResponseStream> {
2437 unimplemented!()
2438 }
2439 }
2440
2441 let mut registry = DriverRegistry::new();
2442 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2443
2444 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2445 assert_eq!(descriptor.display_name, "Anthropic");
2446 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2447 assert!(descriptor.chat.is_some());
2448 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2450 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2451 assert!(descriptor.credential_schema.fields[0].required);
2452
2453 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2455 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2456 assert!(sim.credential_schema.fields.is_empty());
2457 }
2458
2459 #[test]
2460 fn test_descriptor_services_and_lookup() {
2461 struct MockDriver;
2462 #[async_trait]
2463 impl ChatDriver for MockDriver {
2464 async fn chat_completion_stream(
2465 &self,
2466 _messages: Vec<LlmMessage>,
2467 _config: &LlmCallConfig,
2468 ) -> Result<LlmResponseStream> {
2469 unimplemented!()
2470 }
2471 }
2472
2473 let mut registry = DriverRegistry::new();
2474 registry.register_descriptor(DriverDescriptor {
2475 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2476 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2477 });
2478 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2479
2480 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2481 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2482 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2483 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2484
2485 let realtime = registry.providers_for(ServiceKind::Realtime);
2486 assert_eq!(realtime, vec![DriverId::OpenAI]);
2487 let mut chat = registry.providers_for(ServiceKind::Chat);
2488 chat.sort_by_key(|p| p.to_string());
2489 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2490 }
2491
2492 #[test]
2493 fn test_create_chat_driver_fails_without_chat_factory() {
2494 let mut registry = DriverRegistry::new();
2495 registry.register_descriptor(DriverDescriptor {
2496 id: DriverId::external("embeddings-only"),
2497 display_name: "Embeddings Only".to_string(),
2498 services: vec![ServiceKind::Embeddings],
2499 credential_schema: CredentialFormSchema::empty(),
2500 oauth: None,
2501 chat: None,
2502 embeddings: None,
2503 });
2504
2505 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2506 let err = match registry.create_chat_driver(&config) {
2507 Ok(_) => panic!("expected error for missing chat factory"),
2508 Err(err) => err,
2509 };
2510 assert!(
2511 err.to_string()
2512 .contains("does not implement the chat service"),
2513 "unexpected error: {err}"
2514 );
2515 }
2516
2517 #[test]
2518 #[should_panic(expected = "already registered")]
2519 fn test_register_duplicate_panics() {
2520 struct MockDriver;
2521 #[async_trait]
2522 impl ChatDriver for MockDriver {
2523 async fn chat_completion_stream(
2524 &self,
2525 _messages: Vec<LlmMessage>,
2526 _config: &LlmCallConfig,
2527 ) -> Result<LlmResponseStream> {
2528 unimplemented!()
2529 }
2530 }
2531
2532 let mut registry = DriverRegistry::new();
2533 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2534 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2536 }
2537
2538 #[test]
2539 fn test_register_or_replace_overwrites() {
2540 struct MockDriver;
2541 #[async_trait]
2542 impl ChatDriver for MockDriver {
2543 async fn chat_completion_stream(
2544 &self,
2545 _messages: Vec<LlmMessage>,
2546 _config: &LlmCallConfig,
2547 ) -> Result<LlmResponseStream> {
2548 unimplemented!()
2549 }
2550 }
2551
2552 let mut registry = DriverRegistry::new();
2553 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2554 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2556 assert!(registry.has_driver(&DriverId::LlmSim));
2557 }
2558
2559 #[test]
2560 fn test_prepend_text_prefix_simple_text() {
2561 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2562 msg.prepend_text_prefix("[Alice] ");
2563 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2564 }
2565
2566 #[test]
2567 fn test_prepend_text_prefix_parts() {
2568 let mut msg = LlmMessage::parts(
2569 LlmMessageRole::User,
2570 vec![
2571 LlmContentPart::Text {
2572 text: "Hello".to_string(),
2573 },
2574 LlmContentPart::Image {
2575 url: "data:image/png;base64,abc".to_string(),
2576 },
2577 ],
2578 );
2579 msg.prepend_text_prefix("[Bob] ");
2580 match &msg.content {
2581 LlmMessageContent::Parts(parts) => {
2582 if let LlmContentPart::Text { text } = &parts[0] {
2583 assert_eq!(text, "[Bob] Hello");
2584 } else {
2585 panic!("Expected text part");
2586 }
2587 }
2588 _ => panic!("Expected parts content"),
2589 }
2590 }
2591
2592 #[test]
2593 fn test_prepend_text_prefix_parts_no_text() {
2594 let mut msg = LlmMessage::parts(
2595 LlmMessageRole::User,
2596 vec![LlmContentPart::Image {
2597 url: "data:image/png;base64,abc".to_string(),
2598 }],
2599 );
2600 msg.prepend_text_prefix("[Eve] ");
2601 match &msg.content {
2602 LlmMessageContent::Parts(parts) => {
2603 assert_eq!(parts.len(), 2);
2604 if let LlmContentPart::Text { text } = &parts[0] {
2605 assert_eq!(text, "[Eve] ");
2606 } else {
2607 panic!("Expected prepended text part");
2608 }
2609 }
2610 _ => panic!("Expected parts content"),
2611 }
2612 }
2613
2614 #[test]
2615 fn test_openrouter_plugin_config_is_empty() {
2616 assert!(OpenRouterPluginConfig::default().is_empty());
2617 assert!(
2618 !OpenRouterPluginConfig {
2619 web: Some(OpenRouterWebSearchPlugin::default()),
2620 file: None,
2621 }
2622 .is_empty()
2623 );
2624 assert!(
2625 !OpenRouterPluginConfig {
2626 web: None,
2627 file: Some(OpenRouterFilePlugin {}),
2628 }
2629 .is_empty()
2630 );
2631 }
2632
2633 #[test]
2634 fn test_openrouter_routing_is_empty_with_plugins() {
2635 let with_plugins = OpenRouterRoutingConfig {
2636 plugins: Some(OpenRouterPluginConfig {
2637 web: Some(OpenRouterWebSearchPlugin::default()),
2638 file: None,
2639 }),
2640 ..Default::default()
2641 };
2642 assert!(!with_plugins.is_empty());
2643
2644 let empty_plugins = OpenRouterRoutingConfig {
2645 plugins: Some(OpenRouterPluginConfig::default()),
2646 ..Default::default()
2647 };
2648 assert!(empty_plugins.is_empty());
2649 }
2650
2651 #[test]
2652 fn test_openrouter_web_search_plugin_serialization() {
2653 let plugin = OpenRouterWebSearchPlugin {
2654 max_results: Some(10),
2655 search_prompt: Some("search for Rust crates".to_string()),
2656 };
2657 let json = serde_json::to_value(&plugin).unwrap();
2658 assert_eq!(json["max_results"], 10);
2659 assert_eq!(json["search_prompt"], "search for Rust crates");
2660 }
2661
2662 #[test]
2663 fn test_openrouter_web_search_plugin_omits_none_fields() {
2664 let plugin = OpenRouterWebSearchPlugin::default();
2665 let json = serde_json::to_value(&plugin).unwrap();
2666 assert!(json.get("max_results").is_none());
2667 assert!(json.get("search_prompt").is_none());
2668 }
2669
2670 #[test]
2671 fn test_capacity_strategy_shared_capacity_is_noop() {
2672 let base = OpenRouterRoutingConfig {
2673 models: vec!["openai/gpt-5-mini".to_string()],
2674 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2675 ..Default::default()
2676 };
2677 let result = base.apply_capacity_strategy().unwrap();
2678 assert_eq!(
2679 result.capacity_strategy,
2680 Some(OpenRouterCapacityStrategy::SharedCapacity)
2681 );
2682 assert!(result.provider.is_none());
2683 }
2684
2685 #[test]
2686 fn test_capacity_strategy_none_is_noop() {
2687 let base = OpenRouterRoutingConfig {
2688 models: vec!["openai/gpt-5-mini".to_string()],
2689 capacity_strategy: None,
2690 ..Default::default()
2691 };
2692 let result = base.apply_capacity_strategy().unwrap();
2693 assert!(result.provider.is_none());
2694 }
2695
2696 #[test]
2697 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2698 let base = OpenRouterRoutingConfig {
2699 models: vec!["openai/gpt-5-mini".to_string()],
2700 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2701 ..Default::default()
2702 };
2703 let result = base.apply_capacity_strategy().unwrap();
2704 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2705 assert_eq!(provider.allow_fallbacks, Some(true));
2706 }
2707
2708 #[test]
2709 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2710 let base = OpenRouterRoutingConfig {
2712 models: vec!["openai/gpt-5-mini".to_string()],
2713 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2714 provider: Some(OpenRouterProviderRouting {
2715 allow_fallbacks: Some(false),
2716 ..Default::default()
2717 }),
2718 ..Default::default()
2719 };
2720 let result = base.apply_capacity_strategy().unwrap();
2721 let provider = result.provider.as_ref().unwrap();
2722 assert_eq!(provider.allow_fallbacks, Some(false));
2723 }
2724
2725 #[test]
2726 fn test_capacity_strategy_byok_only_requires_provider_only() {
2727 let base = OpenRouterRoutingConfig {
2728 models: vec!["openai/gpt-5-mini".to_string()],
2729 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2730 ..Default::default()
2731 };
2732 let err = base.apply_capacity_strategy().unwrap_err();
2733 assert!(
2734 err.contains("provider.only"),
2735 "error should mention provider.only: {err}"
2736 );
2737 }
2738
2739 #[test]
2740 fn test_capacity_strategy_byok_only_disables_fallbacks() {
2741 let base = OpenRouterRoutingConfig {
2742 models: vec!["openai/gpt-5-mini".to_string()],
2743 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2744 provider: Some(OpenRouterProviderRouting {
2745 only: vec!["my-byok-provider".to_string()],
2746 ..Default::default()
2747 }),
2748 ..Default::default()
2749 };
2750 let result = base.apply_capacity_strategy().unwrap();
2751 let provider = result.provider.as_ref().unwrap();
2752 assert_eq!(provider.allow_fallbacks, Some(false));
2753 assert_eq!(provider.only, vec!["my-byok-provider"]);
2754 }
2755
2756 #[test]
2757 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
2758 let with_strategy = OpenRouterRoutingConfig {
2759 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2760 ..Default::default()
2761 };
2762 assert!(!with_strategy.is_empty());
2763
2764 let byok_first = OpenRouterRoutingConfig {
2765 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2766 ..Default::default()
2767 };
2768 assert!(!byok_first.is_empty());
2769
2770 let shared = OpenRouterRoutingConfig {
2771 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2772 ..Default::default()
2773 };
2774 assert!(shared.is_empty());
2775 }
2776
2777 #[test]
2784 fn test_preset_no_presets_is_noop() {
2785 let base = OpenRouterRoutingConfig {
2786 models: vec!["openai/gpt-5-mini".to_string()],
2787 ..Default::default()
2788 };
2789 let result = base.apply_presets().unwrap();
2790 assert_eq!(result, base);
2791 }
2792
2793 #[test]
2794 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
2795 let base = OpenRouterRoutingConfig {
2796 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2797 ..Default::default()
2798 };
2799 let result = base.apply_presets().unwrap();
2800 assert!(result.presets.is_empty(), "presets cleared after apply");
2801 let provider = result.provider.expect("provider set by preset");
2802 assert_eq!(provider.require_parameters, Some(true));
2803 assert_eq!(
2804 provider.sort,
2805 Some(OpenRouterProviderSort::Simple(
2806 OpenRouterProviderSortBy::Price
2807 ))
2808 );
2809 }
2810
2811 #[test]
2812 fn test_preset_lowest_latency_review_sets_sort_throughput() {
2813 let base = OpenRouterRoutingConfig {
2814 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
2815 ..Default::default()
2816 };
2817 let result = base.apply_presets().unwrap();
2818 let provider = result.provider.expect("provider set by preset");
2819 assert_eq!(
2820 provider.sort,
2821 Some(OpenRouterProviderSort::Simple(
2822 OpenRouterProviderSortBy::Throughput
2823 ))
2824 );
2825 }
2826
2827 #[test]
2828 fn test_preset_zdr_only_sets_zdr() {
2829 let base = OpenRouterRoutingConfig {
2830 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2831 ..Default::default()
2832 };
2833 let result = base.apply_presets().unwrap();
2834 let provider = result.provider.expect("provider set");
2835 assert_eq!(provider.zdr, Some(true));
2836 }
2837
2838 #[test]
2839 fn test_preset_byok_first_sets_allow_fallbacks() {
2840 let base = OpenRouterRoutingConfig {
2841 presets: vec![OpenRouterRoutingPreset::ByokFirst],
2842 ..Default::default()
2843 };
2844 let result = base.apply_presets().unwrap();
2845 let provider = result.provider.expect("provider set");
2846 assert_eq!(provider.allow_fallbacks, Some(true));
2847 }
2848
2849 #[test]
2850 fn test_preset_no_data_collection_sets_data_collection_deny() {
2851 let base = OpenRouterRoutingConfig {
2852 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
2853 ..Default::default()
2854 };
2855 let result = base.apply_presets().unwrap();
2856 let provider = result.provider.expect("provider set");
2857 assert_eq!(
2858 provider.data_collection,
2859 Some(OpenRouterDataCollection::Deny)
2860 );
2861 }
2862
2863 #[test]
2864 fn test_preset_strict_json_sets_require_parameters() {
2865 let base = OpenRouterRoutingConfig {
2866 presets: vec![OpenRouterRoutingPreset::StrictJson],
2867 ..Default::default()
2868 };
2869 let result = base.apply_presets().unwrap();
2870 let provider = result.provider.expect("provider set");
2871 assert_eq!(provider.require_parameters, Some(true));
2872 }
2873
2874 #[test]
2875 fn test_preset_reasoning_required_sets_require_parameters() {
2876 let base = OpenRouterRoutingConfig {
2877 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
2878 ..Default::default()
2879 };
2880 let result = base.apply_presets().unwrap();
2881 let provider = result.provider.expect("provider set");
2882 assert_eq!(provider.require_parameters, Some(true));
2883 }
2884
2885 #[test]
2886 fn test_preset_max_price_converts_usd_per_million() {
2887 let base = OpenRouterRoutingConfig {
2888 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2889 prompt_usd_per_million: Some(5.0),
2890 completion_usd_per_million: Some(15.0),
2891 }],
2892 ..Default::default()
2893 };
2894 let result = base.apply_presets().unwrap();
2895 let provider = result.provider.expect("provider set");
2896 let max_price = provider.max_price.expect("max_price set");
2897 let prompt = max_price.prompt.expect("prompt set");
2899 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
2900 let completion = max_price.completion.expect("completion set");
2901 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
2902 }
2903
2904 #[test]
2905 fn test_preset_max_price_rejects_negative_values() {
2906 let base = OpenRouterRoutingConfig {
2907 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2908 prompt_usd_per_million: Some(-1.0),
2909 completion_usd_per_million: None,
2910 }],
2911 ..Default::default()
2912 };
2913 let err = base.apply_presets().unwrap_err();
2914 assert!(
2915 err.contains("non-negative"),
2916 "error should mention non-negative: {err}"
2917 );
2918 }
2919
2920 #[test]
2921 fn test_preset_max_price_both_none_no_provider_field() {
2922 let base = OpenRouterRoutingConfig {
2923 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2924 prompt_usd_per_million: None,
2925 completion_usd_per_million: None,
2926 }],
2927 ..Default::default()
2928 };
2929 let result = base.apply_presets().unwrap();
2930 assert!(
2931 result.provider.is_none(),
2932 "MaxPrice with no dimensions should not produce a provider field"
2933 );
2934 }
2935
2936 #[test]
2937 fn test_preset_explicit_provider_overrides_preset() {
2938 let base = OpenRouterRoutingConfig {
2939 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2940 provider: Some(OpenRouterProviderRouting {
2941 sort: Some(OpenRouterProviderSort::Simple(
2943 OpenRouterProviderSortBy::Throughput,
2944 )),
2945 ..Default::default()
2946 }),
2947 ..Default::default()
2948 };
2949 let result = base.apply_presets().unwrap();
2950 let provider = result.provider.expect("provider set");
2951 assert_eq!(
2953 provider.sort,
2954 Some(OpenRouterProviderSort::Simple(
2955 OpenRouterProviderSortBy::Throughput
2956 ))
2957 );
2958 assert_eq!(provider.require_parameters, Some(true));
2960 }
2961
2962 #[test]
2963 fn test_preset_multiple_presets_combined() {
2964 let base = OpenRouterRoutingConfig {
2965 presets: vec![
2966 OpenRouterRoutingPreset::ZdrOnly,
2967 OpenRouterRoutingPreset::NoDataCollection,
2968 OpenRouterRoutingPreset::LowestLatencyReview,
2969 ],
2970 ..Default::default()
2971 };
2972 let result = base.apply_presets().unwrap();
2973 let provider = result.provider.expect("provider set");
2974 assert_eq!(provider.zdr, Some(true));
2975 assert_eq!(
2976 provider.data_collection,
2977 Some(OpenRouterDataCollection::Deny)
2978 );
2979 assert_eq!(
2980 provider.sort,
2981 Some(OpenRouterProviderSort::Simple(
2982 OpenRouterProviderSortBy::Throughput
2983 ))
2984 );
2985 }
2986
2987 #[test]
2988 fn test_preset_later_preset_overrides_sort() {
2989 let base = OpenRouterRoutingConfig {
2990 presets: vec![
2991 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
2994 ..Default::default()
2995 };
2996 let result = base.apply_presets().unwrap();
2997 let provider = result.provider.expect("provider set");
2998 assert_eq!(
3000 provider.sort,
3001 Some(OpenRouterProviderSort::Simple(
3002 OpenRouterProviderSortBy::Throughput
3003 ))
3004 );
3005 assert_eq!(provider.require_parameters, Some(true));
3007 }
3008
3009 #[test]
3010 fn test_preset_non_empty_in_is_empty() {
3011 let with_preset = OpenRouterRoutingConfig {
3012 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3013 ..Default::default()
3014 };
3015 assert!(!with_preset.is_empty());
3016
3017 let without = OpenRouterRoutingConfig::default();
3018 assert!(without.is_empty());
3019 }
3020}