1use crate::credential_schema::CredentialFormSchema;
18use crate::error::{AgentLoopError, LlmErrorKind, Result};
19use crate::openresponses_protocol::{CompactRequest, CompactResponse};
20use crate::runtime_agent::RuntimeAgent;
21use crate::tool_types::{ToolCall, ToolDefinition};
22use async_trait::async_trait;
23use chrono::{DateTime, Utc};
24use futures::Stream;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::pin::Pin;
28use std::sync::Arc;
29
30pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LlmStreamError {
44 pub code: Option<String>,
46 pub status: Option<u16>,
48 pub message: String,
50}
51
52impl LlmStreamError {
53 pub fn new(message: impl Into<String>) -> Self {
54 Self {
55 code: None,
56 status: None,
57 message: message.into(),
58 }
59 }
60
61 pub fn provider(
63 code: Option<impl Into<String>>,
64 status: Option<u16>,
65 message: impl Into<String>,
66 ) -> Self {
67 Self {
68 code: code.map(Into::into),
69 status,
70 message: message.into(),
71 }
72 }
73
74 pub fn kind(&self) -> LlmErrorKind {
76 if let Some(code) = self.code.as_deref()
77 && let Some(kind) = LlmErrorKind::from_provider_code(code)
78 {
79 return kind;
80 }
81 if let Some(status) = self.status {
82 return LlmErrorKind::from_provider_status(status, &self.message);
83 }
84 LlmErrorKind::from_error_text(&self.message)
85 }
86}
87
88impl std::error::Error for LlmStreamError {}
89
90impl std::fmt::Display for LlmStreamError {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match (&self.code, self.status) {
93 (Some(code), Some(status)) => write!(f, "{code} ({status}): {}", self.message),
94 (Some(code), None) => write!(f, "{code}: {}", self.message),
95 (None, Some(status)) => write!(f, "({status}): {}", self.message),
96 (None, None) => f.write_str(&self.message),
97 }
98 }
99}
100
101impl From<String> for LlmStreamError {
102 fn from(message: String) -> Self {
103 Self::new(message)
104 }
105}
106
107impl From<&str> for LlmStreamError {
108 fn from(message: &str) -> Self {
109 Self::new(message)
110 }
111}
112
113#[derive(Debug, Clone)]
115pub enum LlmStreamEvent {
116 TextDelta(String),
118 ThinkingDelta(String),
120 ThinkingSignature(String),
123 ReasonItem {
129 provider: String,
131 model: Option<String>,
133 item_id: String,
135 encrypted_content: Option<String>,
137 summary: Vec<String>,
139 token_count: Option<u32>,
141 },
142 ToolCalls(Vec<ToolCall>),
144 MessagePhase(crate::message::ExecutionPhase),
154 Done(Box<LlmCompletionMetadata>),
156 Error(LlmStreamError),
158}
159
160#[derive(Debug, Clone)]
171pub struct DiscoveredModel {
172 pub model_id: String,
174 pub display_name: Option<String>,
176 pub created_at: Option<DateTime<Utc>>,
178 pub owned_by: Option<String>,
180 pub discovered_profile: Option<crate::model::ModelProfile>,
183}
184
185#[derive(Debug, Clone, Default)]
199pub struct LlmCompletionMetadata {
200 pub total_tokens: Option<u32>,
202 pub prompt_tokens: Option<u32>,
204 pub completion_tokens: Option<u32>,
206 pub cache_read_tokens: Option<u32>,
208 pub cache_creation_tokens: Option<u32>,
210 pub provider_cost_usd: Option<f64>,
214 pub model: Option<String>,
216 pub finish_reason: Option<String>,
218 pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
220 pub response_id: Option<String>,
223 pub phase: Option<String>,
227}
228
229pub fn disjoint_prompt_tokens(reported_input: u32, cache_read: Option<u32>) -> u32 {
240 reported_input.saturating_sub(cache_read.unwrap_or(0))
241}
242
243#[async_trait]
265pub trait ChatDriver: Send + Sync {
266 async fn chat_completion_stream(
268 &self,
269 messages: Vec<LlmMessage>,
270 config: &LlmCallConfig,
271 ) -> Result<LlmResponseStream>;
272
273 async fn chat_completion(
275 &self,
276 messages: Vec<LlmMessage>,
277 config: &LlmCallConfig,
278 ) -> Result<LlmResponse> {
279 use futures::StreamExt;
280
281 let mut stream = self.chat_completion_stream(messages, config).await?;
282 let mut text = String::new();
283 let mut thinking = String::new();
284 let mut thinking_signature: Option<String> = None;
285 let mut tool_calls = Vec::new();
286 let mut metadata = LlmCompletionMetadata::default();
287
288 while let Some(event) = stream.next().await {
289 match event? {
290 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
291 LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
292 LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
293 LlmStreamEvent::ReasonItem {
294 encrypted_content, ..
295 } => {
296 if let Some(sig) = encrypted_content {
297 thinking_signature = Some(sig);
298 }
299 }
300 LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
301 LlmStreamEvent::MessagePhase(_) => {}
304 LlmStreamEvent::Done(meta) => metadata = *meta,
305 LlmStreamEvent::Error(err) => {
306 return Err(crate::error::AgentLoopError::llm_kind(
307 err.kind(),
308 err.to_string(),
309 ));
310 }
311 }
312 }
313
314 Ok(LlmResponse {
315 text,
316 thinking: if thinking.is_empty() {
317 None
318 } else {
319 Some(thinking)
320 },
321 thinking_signature,
322 tool_calls: if tool_calls.is_empty() {
323 None
324 } else {
325 Some(tool_calls)
326 },
327 metadata,
328 })
329 }
330
331 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
339 Ok(None)
341 }
342
343 fn supports_compact(&self) -> bool {
352 false
354 }
355
356 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
367 false
368 }
369
370 async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
390 Ok(None)
392 }
393}
394
395#[async_trait]
397impl ChatDriver for Box<dyn ChatDriver> {
398 async fn chat_completion_stream(
399 &self,
400 messages: Vec<LlmMessage>,
401 config: &LlmCallConfig,
402 ) -> Result<LlmResponseStream> {
403 (**self).chat_completion_stream(messages, config).await
404 }
405
406 async fn chat_completion(
407 &self,
408 messages: Vec<LlmMessage>,
409 config: &LlmCallConfig,
410 ) -> Result<LlmResponse> {
411 (**self).chat_completion(messages, config).await
412 }
413
414 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
415 (**self).list_models().await
416 }
417
418 fn supports_compact(&self) -> bool {
419 (**self).supports_compact()
420 }
421
422 fn supports_parallel_tool_calls(&self, model: &str) -> bool {
423 (**self).supports_parallel_tool_calls(model)
424 }
425
426 async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
427 (**self).compact(request).await
428 }
429}
430
431#[derive(Debug, Clone)]
437pub struct LlmMessage {
438 pub role: LlmMessageRole,
439 pub content: LlmMessageContent,
440 pub tool_calls: Option<Vec<ToolCall>>,
441 pub tool_call_id: Option<String>,
442 pub phase: Option<crate::message::ExecutionPhase>,
447 pub thinking: Option<String>,
450 pub thinking_signature: Option<String>,
453}
454
455impl LlmMessage {
456 pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
458 Self {
459 role,
460 content: LlmMessageContent::Text(content.into()),
461 tool_calls: None,
462 tool_call_id: None,
463 phase: None,
464 thinking: None,
465 thinking_signature: None,
466 }
467 }
468
469 pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
471 Self {
472 role,
473 content: LlmMessageContent::Parts(parts),
474 tool_calls: None,
475 tool_call_id: None,
476 phase: None,
477 thinking: None,
478 thinking_signature: None,
479 }
480 }
481
482 pub fn content_as_text(&self) -> String {
484 self.content.to_text()
485 }
486
487 pub fn prepend_text_prefix(&mut self, prefix: &str) {
492 match &mut self.content {
493 LlmMessageContent::Text(text) => {
494 *text = format!("{}{}", prefix, text);
495 }
496 LlmMessageContent::Parts(parts) => {
497 for part in parts.iter_mut() {
498 if let LlmContentPart::Text { text } = part {
499 *text = format!("{}{}", prefix, text);
500 return;
501 }
502 }
503 parts.insert(
505 0,
506 LlmContentPart::Text {
507 text: prefix.to_string(),
508 },
509 );
510 }
511 }
512 }
513}
514
515pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
526 let mut system: Option<String> = None;
527 for msg in messages {
528 if msg.role == LlmMessageRole::System {
529 let text = msg.content.to_text();
530 system = Some(match system.take() {
531 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
532 _ => text,
533 });
534 }
535 }
536 system
537}
538
539#[derive(Debug, Clone)]
541pub enum LlmMessageContent {
542 Text(String),
544 Parts(Vec<LlmContentPart>),
546}
547
548impl LlmMessageContent {
549 pub fn to_text(&self) -> String {
551 match self {
552 LlmMessageContent::Text(s) => s.clone(),
553 LlmMessageContent::Parts(parts) => parts
554 .iter()
555 .filter_map(|p| match p {
556 LlmContentPart::Text { text } => Some(text.clone()),
557 _ => None,
558 })
559 .collect::<Vec<_>>()
560 .join(""),
561 }
562 }
563
564 pub fn is_text(&self) -> bool {
566 matches!(self, LlmMessageContent::Text(_))
567 }
568
569 pub fn is_parts(&self) -> bool {
571 matches!(self, LlmMessageContent::Parts(_))
572 }
573}
574
575impl From<String> for LlmMessageContent {
576 fn from(s: String) -> Self {
577 LlmMessageContent::Text(s)
578 }
579}
580
581impl From<&str> for LlmMessageContent {
582 fn from(s: &str) -> Self {
583 LlmMessageContent::Text(s.to_string())
584 }
585}
586
587#[derive(Debug, Clone)]
589pub enum LlmContentPart {
590 Text { text: String },
592 Image { url: String },
594 Audio { url: String },
596}
597
598impl LlmContentPart {
599 pub fn text(text: impl Into<String>) -> Self {
601 LlmContentPart::Text { text: text.into() }
602 }
603
604 pub fn image(url: impl Into<String>) -> Self {
606 LlmContentPart::Image { url: url.into() }
607 }
608
609 pub fn audio(url: impl Into<String>) -> Self {
611 LlmContentPart::Audio { url: url.into() }
612 }
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
617pub enum LlmMessageRole {
618 System,
619 User,
620 Assistant,
621 Tool,
622}
623
624#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
634pub struct ToolSearchConfig {
635 pub enabled: bool,
637 pub threshold: usize,
640}
641
642#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
644#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
645#[serde(rename_all = "snake_case")]
646pub enum PromptCacheStrategy {
647 #[default]
649 Auto,
650}
651
652#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
657#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
658pub struct PromptCacheConfig {
659 pub enabled: bool,
661 #[serde(default)]
663 pub strategy: PromptCacheStrategy,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub gemini_cached_content: Option<String>,
672}
673
674#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
684#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
685#[serde(tag = "kind", rename_all = "snake_case")]
686pub enum OpenRouterRoutingPreset {
687 CheapestWithTools,
689 LowestLatencyReview,
691 ZdrOnly,
693 ByokFirst,
695 NoDataCollection,
697 StrictJson,
699 ReasoningRequired,
701 MaxPrice {
704 #[serde(default, skip_serializing_if = "Option::is_none")]
706 prompt_usd_per_million: Option<f64>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
709 completion_usd_per_million: Option<f64>,
710 },
711}
712
713#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
721#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
722#[serde(rename_all = "snake_case")]
723pub enum OpenRouterCapacityStrategy {
724 #[default]
726 SharedCapacity,
727 ByokFirst,
731 ByokOnly,
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
746#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
747#[serde(rename_all = "snake_case")]
748pub enum OpenRouterServerToolKind {
749 WebSearch,
750 WebFetch,
751 Datetime,
752 ImageGeneration,
753 ApplyPatch,
754 Fusion,
755 Advisor,
756 Subagent,
757}
758
759impl OpenRouterServerToolKind {
760 pub const ALL: [OpenRouterServerToolKind; 8] = [
762 Self::WebSearch,
763 Self::WebFetch,
764 Self::Datetime,
765 Self::ImageGeneration,
766 Self::ApplyPatch,
767 Self::Fusion,
768 Self::Advisor,
769 Self::Subagent,
770 ];
771
772 pub fn name(&self) -> &'static str {
774 match self {
775 Self::WebSearch => "web_search",
776 Self::WebFetch => "web_fetch",
777 Self::Datetime => "datetime",
778 Self::ImageGeneration => "image_generation",
779 Self::ApplyPatch => "apply_patch",
780 Self::Fusion => "fusion",
781 Self::Advisor => "advisor",
782 Self::Subagent => "subagent",
783 }
784 }
785
786 pub fn display_name(&self) -> &'static str {
788 match self {
789 Self::WebSearch => "Web Search",
790 Self::WebFetch => "Web Fetch",
791 Self::Datetime => "Date & Time",
792 Self::ImageGeneration => "Image Generation",
793 Self::ApplyPatch => "Apply Patch",
794 Self::Fusion => "Fusion",
795 Self::Advisor => "Advisor",
796 Self::Subagent => "Subagent",
797 }
798 }
799
800 pub fn wire_type(&self) -> String {
803 format!("openrouter:{}", self.name())
804 }
805
806 pub fn from_name(name: &str) -> Option<Self> {
808 Self::ALL.into_iter().find(|kind| kind.name() == name)
809 }
810}
811
812#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
816#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
817pub struct OpenRouterServerTool {
818 pub kind: OpenRouterServerToolKind,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
820 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
821 pub parameters: Option<serde_json::Value>,
822}
823
824impl OpenRouterServerTool {
825 pub fn new(kind: OpenRouterServerToolKind) -> Self {
827 Self {
828 kind,
829 parameters: None,
830 }
831 }
832
833 pub fn with_parameters(kind: OpenRouterServerToolKind, parameters: serde_json::Value) -> Self {
835 Self {
836 kind,
837 parameters: Some(parameters),
838 }
839 }
840}
841
842#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
845#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
846pub struct OpenRouterRoutingConfig {
847 #[serde(default, skip_serializing_if = "Vec::is_empty")]
849 pub models: Vec<String>,
850 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub route: Option<OpenRouterRoute>,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub provider: Option<OpenRouterProviderRouting>,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub plugins: Option<OpenRouterPluginConfig>,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
865 #[serde(default, skip_serializing_if = "Vec::is_empty")]
869 pub presets: Vec<OpenRouterRoutingPreset>,
870 #[serde(default, skip_serializing_if = "Vec::is_empty")]
873 pub server_tools: Vec<OpenRouterServerTool>,
874}
875
876impl OpenRouterRoutingConfig {
877 pub fn is_empty(&self) -> bool {
878 self.models.is_empty()
879 && self.route.is_none()
880 && self.provider.is_none()
881 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
882 && matches!(
883 self.capacity_strategy,
884 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
885 )
886 && self.presets.is_empty()
887 && self.server_tools.is_empty()
888 }
889
890 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
892 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
893 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
894 Self {
895 models,
896 route,
897 provider: None,
898 plugins: None,
899 capacity_strategy: None,
900 presets: vec![],
901 server_tools: vec![],
902 }
903 }
904
905 pub fn validate_for_primary_model(
906 &self,
907 primary_model: &str,
908 ) -> std::result::Result<(), String> {
909 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
910 return Err(
911 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
912 );
913 }
914
915 if let Some(first_model) = self.models.first()
916 && first_model != primary_model
917 {
918 return Err(format!(
919 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
920 ));
921 }
922
923 Ok(())
924 }
925
926 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
936 match self.capacity_strategy {
937 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
938 Some(OpenRouterCapacityStrategy::ByokFirst) => {
939 let mut result = self.clone();
940 let provider = result.provider.get_or_insert_with(Default::default);
941 if provider.allow_fallbacks.is_none() {
942 provider.allow_fallbacks = Some(true);
943 }
944 Ok(result)
945 }
946 Some(OpenRouterCapacityStrategy::ByokOnly) => {
947 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
948 if only_is_empty {
949 return Err(
950 "OpenRouter BYOK-only strategy requires provider.only to list at least \
951 one upstream provider slug. Configure the provider list to match the \
952 BYOK providers registered in your OpenRouter workspace."
953 .to_string(),
954 );
955 }
956 let mut result = self.clone();
957 let provider = result.provider.get_or_insert_with(Default::default);
958 provider.allow_fallbacks = Some(false);
959 Ok(result)
960 }
961 }
962 }
963
964 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
974 if self.presets.is_empty() {
975 return Ok(self.clone());
976 }
977
978 let mut derived = OpenRouterProviderRouting::default();
979
980 for preset in &self.presets {
981 match preset {
982 OpenRouterRoutingPreset::CheapestWithTools => {
983 derived.require_parameters = Some(true);
984 derived.sort = Some(OpenRouterProviderSort::Simple(
985 OpenRouterProviderSortBy::Price,
986 ));
987 }
988 OpenRouterRoutingPreset::LowestLatencyReview => {
989 derived.sort = Some(OpenRouterProviderSort::Simple(
990 OpenRouterProviderSortBy::Throughput,
991 ));
992 }
993 OpenRouterRoutingPreset::ZdrOnly => {
994 derived.zdr = Some(true);
995 }
996 OpenRouterRoutingPreset::ByokFirst => {
997 if derived.allow_fallbacks.is_none() {
998 derived.allow_fallbacks = Some(true);
999 }
1000 }
1001 OpenRouterRoutingPreset::NoDataCollection => {
1002 derived.data_collection = Some(OpenRouterDataCollection::Deny);
1003 }
1004 OpenRouterRoutingPreset::StrictJson
1005 | OpenRouterRoutingPreset::ReasoningRequired => {
1006 derived.require_parameters = Some(true);
1007 }
1008 OpenRouterRoutingPreset::MaxPrice {
1009 prompt_usd_per_million,
1010 completion_usd_per_million,
1011 } => {
1012 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
1013 || completion_usd_per_million.is_some_and(|v| v < 0.0)
1014 {
1015 return Err(
1016 "MaxPrice preset values must be non-negative USD per million tokens"
1017 .to_string(),
1018 );
1019 }
1020 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
1021 let mp = derived.max_price.get_or_insert_with(Default::default);
1022 if let Some(p) = prompt_usd_per_million {
1023 mp.prompt = Some(p / 1_000_000.0);
1024 }
1025 if let Some(c) = completion_usd_per_million {
1026 mp.completion = Some(c / 1_000_000.0);
1027 }
1028 }
1029 }
1030 }
1031 }
1032
1033 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
1035
1036 let mut result = self.clone();
1037 result.presets = vec![];
1038 result.provider = if merged.is_empty() {
1039 None
1040 } else {
1041 Some(merged)
1042 };
1043 Ok(result)
1044 }
1045}
1046
1047fn merge_provider_routing(
1051 derived: OpenRouterProviderRouting,
1052 explicit: OpenRouterProviderRouting,
1053) -> OpenRouterProviderRouting {
1054 OpenRouterProviderRouting {
1055 order: if !explicit.order.is_empty() {
1056 explicit.order
1057 } else {
1058 derived.order
1059 },
1060 only: if !explicit.only.is_empty() {
1061 explicit.only
1062 } else {
1063 derived.only
1064 },
1065 ignore: if !explicit.ignore.is_empty() {
1066 explicit.ignore
1067 } else {
1068 derived.ignore
1069 },
1070 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
1071 require_parameters: explicit.require_parameters.or(derived.require_parameters),
1072 data_collection: explicit.data_collection.or(derived.data_collection),
1073 zdr: explicit.zdr.or(derived.zdr),
1074 enforce_distillable_text: explicit
1075 .enforce_distillable_text
1076 .or(derived.enforce_distillable_text),
1077 quantizations: if !explicit.quantizations.is_empty() {
1078 explicit.quantizations
1079 } else {
1080 derived.quantizations
1081 },
1082 sort: explicit.sort.or(derived.sort),
1083 max_price: explicit.max_price.or(derived.max_price),
1084 }
1085}
1086
1087#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1089#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1090#[serde(rename_all = "snake_case")]
1091pub enum OpenRouterRoute {
1092 Fallback,
1093}
1094
1095#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1097#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1098pub struct OpenRouterProviderRouting {
1099 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1101 pub order: Vec<String>,
1102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1104 pub only: Vec<String>,
1105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1107 pub ignore: Vec<String>,
1108 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub allow_fallbacks: Option<bool>,
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1113 pub require_parameters: Option<bool>,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub data_collection: Option<OpenRouterDataCollection>,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub zdr: Option<bool>,
1120 #[serde(default, skip_serializing_if = "Option::is_none")]
1122 pub enforce_distillable_text: Option<bool>,
1123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1125 pub quantizations: Vec<String>,
1126 #[serde(default, skip_serializing_if = "Option::is_none")]
1128 pub sort: Option<OpenRouterProviderSort>,
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 pub max_price: Option<OpenRouterMaxPrice>,
1132}
1133
1134impl OpenRouterProviderRouting {
1135 pub fn is_empty(&self) -> bool {
1136 self.order.is_empty()
1137 && self.only.is_empty()
1138 && self.ignore.is_empty()
1139 && self.allow_fallbacks.is_none()
1140 && self.require_parameters.is_none()
1141 && self.data_collection.is_none()
1142 && self.zdr.is_none()
1143 && self.enforce_distillable_text.is_none()
1144 && self.quantizations.is_empty()
1145 && self.sort.is_none()
1146 && self.max_price.is_none()
1147 }
1148}
1149
1150#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1152#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1153#[serde(rename_all = "snake_case")]
1154pub enum OpenRouterDataCollection {
1155 Allow,
1156 Deny,
1157}
1158
1159#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1161#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1162#[serde(untagged)]
1163pub enum OpenRouterProviderSort {
1164 Simple(OpenRouterProviderSortBy),
1165 Advanced(OpenRouterProviderSortOptions),
1166}
1167
1168#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1171#[serde(rename_all = "snake_case")]
1172pub enum OpenRouterProviderSortBy {
1173 Price,
1174 Throughput,
1175 Latency,
1176}
1177
1178#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1180#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1181pub struct OpenRouterProviderSortOptions {
1182 pub by: OpenRouterProviderSortBy,
1183 #[serde(default, skip_serializing_if = "Option::is_none")]
1184 pub partition: Option<OpenRouterSortPartition>,
1185}
1186
1187#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1189#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1190#[serde(rename_all = "snake_case")]
1191pub enum OpenRouterSortPartition {
1192 Model,
1193 None,
1194}
1195
1196#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1200pub struct OpenRouterMaxPrice {
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub prompt: Option<f64>,
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub completion: Option<f64>,
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1206 pub request: Option<f64>,
1207 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub image: Option<f64>,
1209}
1210
1211#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1218pub struct OpenRouterWebSearchPlugin {
1219 #[serde(default, skip_serializing_if = "Option::is_none")]
1221 pub max_results: Option<u32>,
1222 #[serde(default, skip_serializing_if = "Option::is_none")]
1224 pub search_prompt: Option<String>,
1225}
1226
1227#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1233pub struct OpenRouterFilePlugin {}
1234
1235#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1240#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1241pub struct OpenRouterPluginConfig {
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1244 pub web: Option<OpenRouterWebSearchPlugin>,
1245 #[serde(default, skip_serializing_if = "Option::is_none")]
1247 pub file: Option<OpenRouterFilePlugin>,
1248}
1249
1250impl OpenRouterPluginConfig {
1251 pub fn is_empty(&self) -> bool {
1252 self.web.is_none() && self.file.is_none()
1253 }
1254}
1255
1256pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
1258pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
1260
1261#[derive(Debug, Clone)]
1263pub struct LlmCallConfig {
1264 pub model: String,
1265 pub temperature: Option<f32>,
1266 pub max_tokens: Option<u32>,
1267 pub tools: Vec<ToolDefinition>,
1268 pub reasoning_effort: Option<String>,
1270 pub speed: Option<String>,
1274 pub verbosity: Option<String>,
1278 pub metadata: HashMap<String, String>,
1282 pub previous_response_id: Option<String>,
1285 pub tool_search: Option<ToolSearchConfig>,
1287 pub prompt_cache: Option<PromptCacheConfig>,
1289 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1291 pub parallel_tool_calls: Option<bool>,
1298 pub volatile_suffix_len: usize,
1308}
1309
1310impl LlmCallConfig {
1311 pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1321 if supported {
1322 self.parallel_tool_calls
1323 } else {
1324 None
1325 }
1326 }
1327}
1328
1329impl From<&RuntimeAgent> for LlmCallConfig {
1330 fn from(runtime_agent: &RuntimeAgent) -> Self {
1331 Self {
1332 model: runtime_agent.model.clone(),
1333 temperature: runtime_agent.temperature,
1334 max_tokens: runtime_agent.max_tokens,
1335 tools: runtime_agent.tools.clone(),
1336 reasoning_effort: None, speed: None, verbosity: None, metadata: HashMap::new(), previous_response_id: None,
1341 tool_search: runtime_agent.tool_search.clone(),
1342 prompt_cache: runtime_agent.prompt_cache.clone(),
1343 openrouter_routing: runtime_agent.openrouter_routing.clone(),
1344 parallel_tool_calls: runtime_agent.parallel_tool_calls,
1345 volatile_suffix_len: 0,
1346 }
1347 }
1348}
1349
1350#[derive(Debug, Clone)]
1352pub struct LlmResponse {
1353 pub text: String,
1354 pub thinking: Option<String>,
1356 pub thinking_signature: Option<String>,
1358 pub tool_calls: Option<Vec<ToolCall>>,
1359 pub metadata: LlmCompletionMetadata,
1360}
1361
1362pub struct LlmCallConfigBuilder {
1381 config: LlmCallConfig,
1382}
1383
1384impl LlmCallConfigBuilder {
1385 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1387 Self {
1388 config: LlmCallConfig::from(runtime_agent),
1389 }
1390 }
1391
1392 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1394 self.config.reasoning_effort = Some(effort.into());
1395 self
1396 }
1397
1398 pub fn speed(mut self, speed: impl Into<String>) -> Self {
1400 self.config.speed = Some(speed.into());
1401 self
1402 }
1403
1404 pub fn verbosity(mut self, verbosity: impl Into<String>) -> Self {
1406 self.config.verbosity = Some(verbosity.into());
1407 self
1408 }
1409
1410 pub fn model(mut self, model: impl Into<String>) -> Self {
1412 self.config.model = model.into();
1413 self
1414 }
1415
1416 pub fn temperature(mut self, temp: f32) -> Self {
1418 self.config.temperature = Some(temp);
1419 self
1420 }
1421
1422 pub fn max_tokens(mut self, tokens: u32) -> Self {
1424 self.config.max_tokens = Some(tokens);
1425 self
1426 }
1427
1428 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1430 self.config.tools = tools;
1431 self
1432 }
1433
1434 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1439 self.config.metadata = metadata;
1440 self
1441 }
1442
1443 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1445 self.config.metadata.insert(key.into(), value.into());
1446 self
1447 }
1448
1449 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1451 self.config.previous_response_id = id;
1452 self
1453 }
1454
1455 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1457 self.config.tool_search = Some(config);
1458 self
1459 }
1460
1461 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1463 self.config.prompt_cache = Some(config);
1464 self
1465 }
1466
1467 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1469 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1470 self
1471 }
1472
1473 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1475 self.config.parallel_tool_calls = parallel_tool_calls;
1476 self
1477 }
1478
1479 pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1483 self.config.volatile_suffix_len = len;
1484 self
1485 }
1486
1487 pub fn build(self) -> LlmCallConfig {
1489 self.config
1490 }
1491}
1492
1493impl From<&crate::message::Message> for LlmMessage {
1498 fn from(msg: &crate::message::Message) -> Self {
1504 let role = match msg.role {
1505 crate::message::MessageRole::System => LlmMessageRole::System,
1506 crate::message::MessageRole::User => LlmMessageRole::User,
1507 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1508 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1509 };
1510
1511 let tool_calls: Vec<ToolCall> = msg
1513 .tool_calls()
1514 .into_iter()
1515 .map(|tc| ToolCall {
1516 id: tc.id.clone(),
1517 name: tc.name.clone(),
1518 arguments: tc.arguments.clone(),
1519 })
1520 .collect();
1521
1522 LlmMessage {
1523 role,
1524 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1525 tool_calls: if tool_calls.is_empty() {
1526 None
1527 } else {
1528 Some(tool_calls)
1529 },
1530 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1531 phase: msg.phase,
1532 thinking: msg.thinking.clone(),
1533 thinking_signature: msg.thinking_signature.clone(),
1534 }
1535 }
1536}
1537
1538use crate::traits::ResolvedImage;
1543use uuid::Uuid;
1544
1545impl LlmMessage {
1546 pub fn from_message_with_images(
1566 msg: &crate::message::Message,
1567 resolved_images: &HashMap<Uuid, ResolvedImage>,
1568 ) -> Self {
1569 use crate::message::{ContentPart, MessageRole};
1570
1571 let role = match msg.role {
1572 MessageRole::System => LlmMessageRole::System,
1573 MessageRole::User => LlmMessageRole::User,
1574 MessageRole::Agent => LlmMessageRole::Assistant,
1575 MessageRole::ToolResult => LlmMessageRole::Tool,
1576 };
1577
1578 let mut parts: Vec<LlmContentPart> = Vec::new();
1580 let mut tool_calls: Vec<ToolCall> = Vec::new();
1581
1582 for part in &msg.content {
1583 match part {
1584 ContentPart::Text(t) => {
1585 parts.push(LlmContentPart::Text {
1586 text: t.text.clone(),
1587 });
1588 }
1589 ContentPart::Image(img) => {
1590 if let Some(url) = &img.url {
1592 parts.push(LlmContentPart::Image { url: url.clone() });
1593 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1594 {
1595 let data_url = format!("data:{};base64,{}", media_type, base64);
1596 parts.push(LlmContentPart::Image { url: data_url });
1597 }
1598 }
1599 ContentPart::ImageFile(img_file) => {
1600 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1602 parts.push(LlmContentPart::Image {
1603 url: resolved.to_data_url(),
1604 });
1605 } else {
1606 parts.push(LlmContentPart::Text {
1608 text: format!("[Image not found: {}]", img_file.image_id),
1609 });
1610 }
1611 }
1612 ContentPart::ToolCall(tc) => {
1613 tool_calls.push(ToolCall {
1615 id: tc.id.clone(),
1616 name: tc.name.clone(),
1617 arguments: tc.arguments.clone(),
1618 });
1619 }
1620 ContentPart::ToolResult(tr) => {
1621 let text = if let Some(err) = &tr.error {
1623 format!("Tool error: {}", err)
1624 } else if let Some(res) = &tr.result {
1625 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1626 } else {
1627 "{}".to_string()
1628 };
1629 let text = truncate_tool_result(text);
1633 parts.push(LlmContentPart::Text { text });
1634 }
1635 }
1636 }
1637
1638 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1640 if let LlmContentPart::Text { text } = &parts[0] {
1642 LlmMessageContent::Text(text.clone())
1643 } else {
1644 LlmMessageContent::Parts(parts)
1645 }
1646 } else if parts.is_empty() {
1647 LlmMessageContent::Text(String::new())
1649 } else {
1650 LlmMessageContent::Parts(parts)
1652 };
1653
1654 LlmMessage {
1655 role,
1656 content,
1657 tool_calls: if tool_calls.is_empty() {
1658 None
1659 } else {
1660 Some(tool_calls)
1661 },
1662 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1663 phase: msg.phase,
1664 thinking: msg.thinking.clone(),
1665 thinking_signature: msg.thinking_signature.clone(),
1666 }
1667 }
1668
1669 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1671 msg.content.iter().any(|p| p.is_image_file())
1672 }
1673
1674 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1676 msg.content
1677 .iter()
1678 .filter_map(|p| match p {
1679 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1680 _ => None,
1681 })
1682 .collect()
1683 }
1684}
1685
1686pub use crate::provider::DriverId;
1691
1692#[derive(Debug, Clone, Default, PartialEq, Eq)]
1698pub struct ProviderMetadata {
1699 pub refresh_token: Option<String>,
1701 pub account_id: Option<String>,
1703 pub extra: Option<serde_json::Value>,
1705}
1706
1707#[derive(Debug, Clone)]
1709pub struct ProviderConfig {
1710 pub provider_type: DriverId,
1712 pub api_key: Option<String>,
1714 pub base_url: Option<String>,
1716 pub metadata: ProviderMetadata,
1718}
1719
1720impl ProviderConfig {
1721 pub fn new(provider_type: DriverId) -> Self {
1723 Self {
1724 provider_type,
1725 api_key: None,
1726 base_url: None,
1727 metadata: ProviderMetadata::default(),
1728 }
1729 }
1730
1731 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1733 self.api_key = Some(api_key.into());
1734 self
1735 }
1736
1737 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1739 self.base_url = Some(base_url.into());
1740 self
1741 }
1742
1743 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1745 self.metadata = metadata;
1746 self
1747 }
1748}
1749
1750#[derive(Debug, Clone)]
1756pub struct DriverConfig {
1757 pub provider_type: DriverId,
1759 pub api_key: Option<String>,
1765 pub credentials: std::collections::BTreeMap<String, String>,
1771 pub base_url: Option<String>,
1773 pub metadata: ProviderMetadata,
1775}
1776
1777impl DriverConfig {
1778 pub fn from_provider_config(config: &ProviderConfig) -> Self {
1784 Self {
1785 provider_type: config.provider_type.clone(),
1786 credentials: crate::credential_schema::parse_credential_document(
1787 config.api_key.as_deref(),
1788 ),
1789 api_key: config.api_key.clone(),
1790 base_url: config.base_url.clone(),
1791 metadata: config.metadata.clone(),
1792 }
1793 }
1794
1795 pub fn credential(&self, name: &str) -> Option<&str> {
1797 self.credentials
1798 .get(name)
1799 .map(String::as_str)
1800 .filter(|s| !s.is_empty())
1801 }
1802}
1803
1804impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1805 fn from(model: &crate::traits::ResolvedModel) -> Self {
1806 Self {
1807 provider_type: model.provider_type.clone(),
1808 api_key: model.api_key.clone(),
1809 base_url: model.base_url.clone(),
1810 metadata: model.provider_metadata.clone().unwrap_or_default(),
1811 }
1812 }
1813}
1814
1815pub type BoxedChatDriver = Box<dyn ChatDriver>;
1817
1818#[derive(Debug, Clone)]
1824pub struct EmbedRequest {
1825 pub texts: Vec<String>,
1827 pub model: String,
1829}
1830
1831#[derive(Debug, Clone)]
1833pub struct EmbedResponse {
1834 pub embeddings: Vec<Vec<f32>>,
1836 pub usage_tokens: Option<u32>,
1839}
1840
1841#[derive(Debug, thiserror::Error)]
1843pub enum EmbeddingsDriverError {
1844 #[error("embeddings provider returned an error: {0}")]
1845 Provider(String),
1846 #[error("embeddings request failed: {0}")]
1847 Transport(String),
1848}
1849
1850#[async_trait]
1856pub trait EmbeddingsDriver: Send + Sync {
1857 async fn embed(
1859 &self,
1860 request: EmbedRequest,
1861 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1862}
1863
1864pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1866
1867pub type EmbeddingsDriverFactory =
1869 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1870
1871pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1880
1881#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1887#[serde(rename_all = "snake_case")]
1888pub enum ServiceKind {
1889 Chat,
1891 Embeddings,
1893 Realtime,
1895 Images,
1897 Rerank,
1899}
1900
1901impl std::fmt::Display for ServiceKind {
1902 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1903 let s = match self {
1904 ServiceKind::Chat => "chat",
1905 ServiceKind::Embeddings => "embeddings",
1906 ServiceKind::Realtime => "realtime",
1907 ServiceKind::Images => "images",
1908 ServiceKind::Rerank => "rerank",
1909 };
1910 f.write_str(s)
1911 }
1912}
1913
1914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1927pub enum DriverOAuthFlow {
1928 OpenRouterPkce,
1936}
1937
1938#[derive(Debug, Clone)]
1943pub struct DriverOAuthConfig {
1944 pub authorize_url: String,
1946 pub token_url: String,
1948 pub flow: DriverOAuthFlow,
1950}
1951
1952impl DriverOAuthConfig {
1953 pub fn openrouter() -> Self {
1955 Self {
1956 authorize_url: "https://openrouter.ai/auth".to_string(),
1957 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1958 flow: DriverOAuthFlow::OpenRouterPkce,
1959 }
1960 }
1961}
1962
1963#[derive(Clone)]
1970pub struct DriverDescriptor {
1971 pub id: DriverId,
1973 pub display_name: String,
1975 pub services: Vec<ServiceKind>,
1977 pub credential_schema: CredentialFormSchema,
1979 pub oauth: Option<DriverOAuthConfig>,
1982 pub chat: Option<DriverFactory>,
1984 pub embeddings: Option<EmbeddingsDriverFactory>,
1986}
1987
1988impl DriverDescriptor {
1989 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1994 where
1995 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1996 {
1997 let id = id.into();
1998 Self {
1999 display_name: default_display_name(&id),
2000 credential_schema: default_credential_schema(&id),
2001 services: vec![ServiceKind::Chat],
2002 oauth: None,
2003 chat: Some(Arc::new(factory)),
2004 embeddings: None,
2005 id,
2006 }
2007 }
2008
2009 pub fn supports(&self, service: ServiceKind) -> bool {
2011 self.services.contains(&service)
2012 }
2013}
2014
2015impl std::fmt::Debug for DriverDescriptor {
2016 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2017 f.debug_struct("DriverDescriptor")
2018 .field("id", &self.id)
2019 .field("display_name", &self.display_name)
2020 .field("services", &self.services)
2021 .field("oauth", &self.oauth.is_some())
2022 .field("chat", &self.chat.is_some())
2023 .field("embeddings", &self.embeddings.is_some())
2024 .finish()
2025 }
2026}
2027
2028fn default_display_name(id: &DriverId) -> String {
2029 match id {
2030 DriverId::OpenAI => "OpenAI".to_string(),
2031 DriverId::OpenRouter => "OpenRouter".to_string(),
2032 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
2033 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
2034 DriverId::Anthropic => "Anthropic".to_string(),
2035 DriverId::Gemini => "Google Gemini".to_string(),
2036 DriverId::Bedrock => "AWS Bedrock".to_string(),
2037 DriverId::Mai => "Microsoft MAI".to_string(),
2038 DriverId::Fireworks => "Fireworks AI".to_string(),
2039 DriverId::LlmSim => "LLM Simulator".to_string(),
2040 DriverId::External(id) => id.to_string(),
2041 }
2042}
2043
2044fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
2045 match id {
2046 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
2048 _ => CredentialFormSchema::api_key(String::new()),
2049 }
2050}
2051
2052#[derive(Clone, Default)]
2072pub struct DriverRegistry {
2073 descriptors: HashMap<DriverId, DriverDescriptor>,
2074}
2075
2076impl DriverRegistry {
2077 pub fn new() -> Self {
2079 Self {
2080 descriptors: HashMap::new(),
2081 }
2082 }
2083
2084 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
2090 if self.descriptors.contains_key(&descriptor.id) {
2091 panic!(
2092 "driver already registered for provider '{}'; \
2093 use register_descriptor_or_replace to overwrite intentionally",
2094 descriptor.id
2095 );
2096 }
2097 self.descriptors.insert(descriptor.id.clone(), descriptor);
2098 }
2099
2100 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
2102 self.descriptors.insert(descriptor.id.clone(), descriptor);
2103 }
2104
2105 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2111 where
2112 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2113 {
2114 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
2115 }
2116
2117 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2122 where
2123 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2124 {
2125 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
2126 }
2127
2128 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
2133 where
2134 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2135 {
2136 self.register(DriverId::external(id), factory);
2137 }
2138
2139 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
2148 let requires_api_key = !matches!(
2152 config.provider_type,
2153 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
2154 );
2155 if requires_api_key && config.api_key.is_none() {
2156 return Err(AgentLoopError::llm(
2157 "API key is required. Configure the API key in provider settings.",
2158 ));
2159 }
2160
2161 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2163 AgentLoopError::driver_not_registered(config.provider_type.to_string())
2164 })?;
2165 let factory = descriptor.chat.as_ref().ok_or_else(|| {
2166 AgentLoopError::llm(format!(
2167 "Provider driver '{}' does not implement the chat service.",
2168 config.provider_type
2169 ))
2170 })?;
2171
2172 let driver_config = DriverConfig::from_provider_config(config);
2174 Ok(factory(&driver_config))
2175 }
2176
2177 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2179 self.descriptors.contains_key(provider_type)
2180 }
2181
2182 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2184 self.descriptors.get(provider_type)
2185 }
2186
2187 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2189 self.descriptors
2190 .get(provider_type)
2191 .is_some_and(|d| d.supports(service))
2192 }
2193
2194 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2196 self.descriptors
2197 .values()
2198 .filter(|d| d.supports(service))
2199 .map(|d| d.id.clone())
2200 .collect()
2201 }
2202
2203 pub fn registered_providers(&self) -> Vec<DriverId> {
2205 self.descriptors.keys().cloned().collect()
2206 }
2207
2208 pub fn create_embeddings_driver(
2216 &self,
2217 config: &ProviderConfig,
2218 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2219 let requires_api_key = !matches!(
2220 config.provider_type,
2221 DriverId::LlmSim | DriverId::External(_)
2222 );
2223 if requires_api_key && config.api_key.is_none() {
2224 return Err(EmbeddingsDriverError::Provider(
2225 "API key is required. Configure the API key in provider settings.".to_string(),
2226 ));
2227 }
2228 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2229 EmbeddingsDriverError::Provider(format!(
2230 "No driver registered for provider '{}'",
2231 config.provider_type
2232 ))
2233 })?;
2234 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2235 EmbeddingsDriverError::Provider(format!(
2236 "Provider driver '{}' does not implement the embeddings service.",
2237 config.provider_type
2238 ))
2239 })?;
2240 let driver_config = DriverConfig::from_provider_config(config);
2241 Ok(factory(&driver_config))
2242 }
2243}
2244
2245const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2250
2251const TRUNCATION_SUFFIX: &str =
2252 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2253
2254fn truncate_tool_result(text: String) -> String {
2255 if text.len() <= MAX_TOOL_RESULT_BYTES {
2256 return text;
2257 }
2258 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2259 let mut end = content_budget;
2260 while end > 0 && !text.is_char_boundary(end) {
2261 end -= 1;
2262 }
2263 let mut truncated = text[..end].to_string();
2264 truncated.push_str(TRUNCATION_SUFFIX);
2265 truncated
2266}
2267
2268#[cfg(test)]
2273mod tests {
2274 use super::*;
2275
2276 #[test]
2277 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2278 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2281 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2283 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2284 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2286 }
2287
2288 #[test]
2289 fn test_resolved_parallel_tool_calls_gating() {
2290 let mut config = LlmCallConfig::from(&RuntimeAgent::new("p", "gpt-5.2"));
2291
2292 assert_eq!(config.resolved_parallel_tool_calls(true), None);
2294 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2295
2296 config.parallel_tool_calls = Some(true);
2298 assert_eq!(config.resolved_parallel_tool_calls(true), Some(true));
2299 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2300
2301 config.parallel_tool_calls = Some(false);
2302 assert_eq!(config.resolved_parallel_tool_calls(true), Some(false));
2303 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2304 }
2305
2306 #[test]
2307 fn test_chat_driver_default_omits_parallel_tool_calls() {
2308 struct DefaultDriver;
2310 #[async_trait]
2311 impl ChatDriver for DefaultDriver {
2312 async fn chat_completion_stream(
2313 &self,
2314 _messages: Vec<LlmMessage>,
2315 _config: &LlmCallConfig,
2316 ) -> Result<LlmResponseStream> {
2317 unreachable!()
2318 }
2319 }
2320 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2321 }
2322
2323 #[test]
2324 fn test_fold_system_messages_none_when_absent() {
2325 let messages = vec![
2326 LlmMessage::text(LlmMessageRole::User, "hi"),
2327 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2328 ];
2329 assert_eq!(fold_system_messages(&messages), None);
2330 }
2331
2332 #[test]
2333 fn test_fold_system_messages_single() {
2334 let messages = vec![
2335 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2336 LlmMessage::text(LlmMessageRole::User, "hi"),
2337 ];
2338 assert_eq!(
2339 fold_system_messages(&messages),
2340 Some("AGENT-PROMPT".to_string())
2341 );
2342 }
2343
2344 #[test]
2345 fn test_fold_system_messages_accumulates_in_order() {
2346 let messages = vec![
2350 LlmMessage::text(LlmMessageRole::System, "A"),
2351 LlmMessage::text(LlmMessageRole::User, "hi"),
2352 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2353 LlmMessage::text(LlmMessageRole::System, "B"),
2354 ];
2355 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2356 }
2357
2358 #[test]
2359 fn test_fold_system_messages_concatenates_parts() {
2360 let messages = vec![LlmMessage::parts(
2361 LlmMessageRole::System,
2362 vec![
2363 LlmContentPart::text("foo"),
2364 LlmContentPart::image("data:image/png;base64,xxx"),
2365 LlmContentPart::text("bar"),
2366 ],
2367 )];
2368 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2369 }
2370
2371 #[test]
2372 fn test_llm_call_config_builder_from_runtime_agent() {
2373 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2374 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2375
2376 assert_eq!(llm_config.model, "gpt-4o");
2377 assert!(llm_config.reasoning_effort.is_none());
2378 assert!(llm_config.temperature.is_none());
2379 assert!(llm_config.max_tokens.is_none());
2380 assert!(llm_config.tools.is_empty());
2381 assert!(llm_config.metadata.is_empty());
2382 assert!(llm_config.openrouter_routing.is_none());
2384 }
2385
2386 #[test]
2387 fn runtime_agent_openrouter_routing_flows_into_call_config() {
2388 let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2392 runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2393 server_tools: vec![OpenRouterServerTool::new(
2394 OpenRouterServerToolKind::WebSearch,
2395 )],
2396 ..Default::default()
2397 });
2398
2399 let llm_config = LlmCallConfig::from(&runtime_agent);
2400 let routing = llm_config
2401 .openrouter_routing
2402 .expect("server-tool routing survives into the call config");
2403 assert_eq!(routing.server_tools.len(), 1);
2404 assert_eq!(
2405 routing.server_tools[0].kind.wire_type(),
2406 "openrouter:web_search"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_llm_call_config_builder_with_metadata() {
2412 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2413 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2414 .with_metadata("session_id", "session_abc123")
2415 .with_metadata("agent_id", "agent_xyz789")
2416 .build();
2417
2418 assert_eq!(
2419 llm_config.metadata.get("session_id"),
2420 Some(&"session_abc123".to_string())
2421 );
2422 assert_eq!(
2423 llm_config.metadata.get("agent_id"),
2424 Some(&"agent_xyz789".to_string())
2425 );
2426 }
2427
2428 #[test]
2429 fn test_llm_call_config_builder_with_metadata_hashmap() {
2430 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2431 let mut metadata = HashMap::new();
2432 metadata.insert("key1".to_string(), "value1".to_string());
2433 metadata.insert("key2".to_string(), "value2".to_string());
2434
2435 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2436 .metadata(metadata)
2437 .build();
2438
2439 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2440 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2441 }
2442
2443 #[test]
2444 fn test_llm_call_config_builder_with_reasoning_effort() {
2445 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2446 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2447 .reasoning_effort("high")
2448 .build();
2449
2450 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2451 }
2452
2453 #[test]
2454 fn test_llm_call_config_builder_with_all_options() {
2455 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2456 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2457 .model("claude-3-opus")
2458 .reasoning_effort("medium")
2459 .temperature(0.7)
2460 .max_tokens(1000)
2461 .build();
2462
2463 assert_eq!(llm_config.model, "claude-3-opus");
2464 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2465 assert_eq!(llm_config.temperature, Some(0.7));
2466 assert_eq!(llm_config.max_tokens, Some(1000));
2467 }
2468
2469 #[test]
2470 fn test_llm_call_config_builder_with_openrouter_routing() {
2471 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2472 let routing = OpenRouterRoutingConfig::fallback_models([
2473 "openai/gpt-5-mini",
2474 "anthropic/claude-sonnet-4.5",
2475 ]);
2476
2477 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2478 .openrouter_routing(routing.clone())
2479 .build();
2480
2481 assert_eq!(llm_config.openrouter_routing, Some(routing));
2482 }
2483
2484 #[test]
2485 fn test_openrouter_fallback_models_empty_is_empty() {
2486 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2487
2488 assert!(routing.is_empty());
2489 assert_eq!(routing.route, None);
2490 }
2491
2492 #[test]
2493 fn test_openrouter_routing_validates_primary_model() {
2494 let routing = OpenRouterRoutingConfig::fallback_models([
2495 "openai/gpt-5-mini",
2496 "anthropic/claude-sonnet-4.5",
2497 ]);
2498
2499 assert!(
2500 routing
2501 .validate_for_primary_model("openai/gpt-5-mini")
2502 .is_ok()
2503 );
2504 let err = routing
2505 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2506 .unwrap_err();
2507 assert!(err.contains("models[0]"));
2508 }
2509
2510 #[test]
2511 fn test_openrouter_routing_rejects_fallback_without_models() {
2512 let routing = OpenRouterRoutingConfig {
2513 route: Some(OpenRouterRoute::Fallback),
2514 ..Default::default()
2515 };
2516
2517 let err = routing
2518 .validate_for_primary_model("openai/gpt-5-mini")
2519 .unwrap_err();
2520 assert!(err.contains("requires at least one model"));
2521 }
2522
2523 #[test]
2524 fn test_openrouter_routing_serializes_request_fields() {
2525 let routing = OpenRouterRoutingConfig {
2526 models: vec![
2527 "openai/gpt-5-mini".to_string(),
2528 "anthropic/claude-sonnet-4.5".to_string(),
2529 ],
2530 route: Some(OpenRouterRoute::Fallback),
2531 provider: Some(OpenRouterProviderRouting {
2532 order: vec!["anthropic".to_string(), "openai".to_string()],
2533 allow_fallbacks: Some(false),
2534 require_parameters: Some(true),
2535 data_collection: Some(OpenRouterDataCollection::Deny),
2536 zdr: Some(true),
2537 sort: Some(OpenRouterProviderSort::Advanced(
2538 OpenRouterProviderSortOptions {
2539 by: OpenRouterProviderSortBy::Throughput,
2540 partition: Some(OpenRouterSortPartition::None),
2541 },
2542 )),
2543 max_price: Some(OpenRouterMaxPrice {
2544 prompt: Some(1.0),
2545 completion: Some(2.0),
2546 ..Default::default()
2547 }),
2548 ..Default::default()
2549 }),
2550 ..Default::default()
2551 };
2552
2553 let json = serde_json::to_value(routing).unwrap();
2554
2555 assert_eq!(
2556 json,
2557 serde_json::json!({
2558 "models": [
2559 "openai/gpt-5-mini",
2560 "anthropic/claude-sonnet-4.5"
2561 ],
2562 "route": "fallback",
2563 "provider": {
2564 "order": ["anthropic", "openai"],
2565 "allow_fallbacks": false,
2566 "require_parameters": true,
2567 "data_collection": "deny",
2568 "zdr": true,
2569 "sort": {
2570 "by": "throughput",
2571 "partition": "none"
2572 },
2573 "max_price": {
2574 "prompt": 1.0,
2575 "completion": 2.0
2576 }
2577 }
2578 })
2579 );
2580 }
2581
2582 #[test]
2583 fn test_provider_type_parsing() {
2584 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2585 assert_eq!(
2586 "openrouter".parse::<DriverId>().unwrap(),
2587 DriverId::OpenRouter
2588 );
2589 assert_eq!(
2590 "openai_completions".parse::<DriverId>().unwrap(),
2591 DriverId::OpenAICompletions
2592 );
2593 assert_eq!(
2594 "azure_openai".parse::<DriverId>().unwrap(),
2595 DriverId::AzureOpenAI
2596 );
2597 assert_eq!(
2598 "anthropic".parse::<DriverId>().unwrap(),
2599 DriverId::Anthropic
2600 );
2601 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2602 assert_eq!(
2604 "ollama".parse::<DriverId>().unwrap(),
2605 DriverId::external("ollama")
2606 );
2607 assert_eq!(
2608 "custom".parse::<DriverId>().unwrap(),
2609 DriverId::external("custom")
2610 );
2611 }
2612
2613 #[test]
2614 fn test_external_provider_id_is_case_insensitive() {
2615 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2618 assert_eq!(
2619 "Ollama".parse::<DriverId>().unwrap(),
2620 "ollama".parse::<DriverId>().unwrap()
2621 );
2622 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2623 assert_eq!(
2625 DriverId::external("MyProvider"),
2626 "myprovider".parse::<DriverId>().unwrap()
2627 );
2628 }
2629
2630 #[test]
2631 fn test_provider_type_display() {
2632 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2633 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2634 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2635 assert_eq!(
2636 DriverId::OpenAICompletions.to_string(),
2637 "openai_completions"
2638 );
2639 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2640 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2641 }
2642
2643 #[test]
2644 fn test_provider_config_builder() {
2645 let config = ProviderConfig::new(DriverId::Anthropic)
2646 .with_api_key("test-key")
2647 .with_base_url("https://custom.api.com");
2648
2649 assert_eq!(config.provider_type, DriverId::Anthropic);
2650 assert_eq!(config.api_key, Some("test-key".to_string()));
2651 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2652 }
2653
2654 #[test]
2655 fn test_driver_registry_requires_api_key() {
2656 let mut registry = DriverRegistry::new();
2658 registry.register(DriverId::OpenAI, |_config| {
2659 struct MockDriver;
2661 #[async_trait]
2662 impl ChatDriver for MockDriver {
2663 async fn chat_completion_stream(
2664 &self,
2665 _messages: Vec<LlmMessage>,
2666 _config: &LlmCallConfig,
2667 ) -> Result<LlmResponseStream> {
2668 unimplemented!()
2669 }
2670 }
2671 Box::new(MockDriver)
2672 });
2673
2674 let config = ProviderConfig::new(DriverId::OpenAI);
2676 let result = registry.create_chat_driver(&config);
2677 assert!(result.is_err());
2678
2679 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2681 let result = registry.create_chat_driver(&config_with_key);
2682 assert!(result.is_ok());
2683 }
2684
2685 #[test]
2686 fn test_driver_registry_returns_error_for_unregistered_provider() {
2687 let registry = DriverRegistry::new();
2688 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2689
2690 let result = registry.create_chat_driver(&config);
2691
2692 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2694 assert_eq!(provider, "anthropic");
2695 } else {
2696 panic!("Expected DriverNotRegistered error");
2697 }
2698 }
2699
2700 #[test]
2701 fn test_driver_registry_registration() {
2702 let mut registry = DriverRegistry::new();
2703
2704 assert!(!registry.has_driver(&DriverId::OpenAI));
2705 assert!(!registry.has_driver(&DriverId::Anthropic));
2706
2707 registry.register(DriverId::OpenAI, |_config| {
2708 struct MockDriver;
2709 #[async_trait]
2710 impl ChatDriver for MockDriver {
2711 async fn chat_completion_stream(
2712 &self,
2713 _messages: Vec<LlmMessage>,
2714 _config: &LlmCallConfig,
2715 ) -> Result<LlmResponseStream> {
2716 unimplemented!()
2717 }
2718 }
2719 Box::new(MockDriver)
2720 });
2721
2722 assert!(registry.has_driver(&DriverId::OpenAI));
2723 assert!(!registry.has_driver(&DriverId::Anthropic));
2724 }
2725
2726 #[test]
2727 fn test_register_external_and_create_driver_without_api_key() {
2728 struct MockDriver;
2729 #[async_trait]
2730 impl ChatDriver for MockDriver {
2731 async fn chat_completion_stream(
2732 &self,
2733 _messages: Vec<LlmMessage>,
2734 _config: &LlmCallConfig,
2735 ) -> Result<LlmResponseStream> {
2736 unimplemented!()
2737 }
2738 }
2739
2740 let mut registry = DriverRegistry::new();
2741 registry.register_external("openai-codex", |config| {
2742 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2744 Box::new(MockDriver)
2745 });
2746
2747 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2748
2749 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2751 ProviderMetadata {
2752 refresh_token: Some("rt".into()),
2753 ..Default::default()
2754 },
2755 );
2756 assert!(registry.create_chat_driver(&config).is_ok());
2757 }
2758
2759 #[test]
2760 fn test_register_defaults_to_chat_only_descriptor() {
2761 struct MockDriver;
2762 #[async_trait]
2763 impl ChatDriver for MockDriver {
2764 async fn chat_completion_stream(
2765 &self,
2766 _messages: Vec<LlmMessage>,
2767 _config: &LlmCallConfig,
2768 ) -> Result<LlmResponseStream> {
2769 unimplemented!()
2770 }
2771 }
2772
2773 let mut registry = DriverRegistry::new();
2774 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2775
2776 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2777 assert_eq!(descriptor.display_name, "Anthropic");
2778 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2779 assert!(descriptor.chat.is_some());
2780 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2782 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2783 assert!(descriptor.credential_schema.fields[0].required);
2784
2785 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2787 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2788 assert!(sim.credential_schema.fields.is_empty());
2789 }
2790
2791 #[test]
2792 fn test_descriptor_services_and_lookup() {
2793 struct MockDriver;
2794 #[async_trait]
2795 impl ChatDriver for MockDriver {
2796 async fn chat_completion_stream(
2797 &self,
2798 _messages: Vec<LlmMessage>,
2799 _config: &LlmCallConfig,
2800 ) -> Result<LlmResponseStream> {
2801 unimplemented!()
2802 }
2803 }
2804
2805 let mut registry = DriverRegistry::new();
2806 registry.register_descriptor(DriverDescriptor {
2807 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2808 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2809 });
2810 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2811
2812 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2813 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2814 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2815 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2816
2817 let realtime = registry.providers_for(ServiceKind::Realtime);
2818 assert_eq!(realtime, vec![DriverId::OpenAI]);
2819 let mut chat = registry.providers_for(ServiceKind::Chat);
2820 chat.sort_by_key(|p| p.to_string());
2821 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2822 }
2823
2824 #[test]
2825 fn test_create_chat_driver_fails_without_chat_factory() {
2826 let mut registry = DriverRegistry::new();
2827 registry.register_descriptor(DriverDescriptor {
2828 id: DriverId::external("embeddings-only"),
2829 display_name: "Embeddings Only".to_string(),
2830 services: vec![ServiceKind::Embeddings],
2831 credential_schema: CredentialFormSchema::empty(),
2832 oauth: None,
2833 chat: None,
2834 embeddings: None,
2835 });
2836
2837 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2838 let err = match registry.create_chat_driver(&config) {
2839 Ok(_) => panic!("expected error for missing chat factory"),
2840 Err(err) => err,
2841 };
2842 assert!(
2843 err.to_string()
2844 .contains("does not implement the chat service"),
2845 "unexpected error: {err}"
2846 );
2847 }
2848
2849 #[test]
2850 #[should_panic(expected = "already registered")]
2851 fn test_register_duplicate_panics() {
2852 struct MockDriver;
2853 #[async_trait]
2854 impl ChatDriver for MockDriver {
2855 async fn chat_completion_stream(
2856 &self,
2857 _messages: Vec<LlmMessage>,
2858 _config: &LlmCallConfig,
2859 ) -> Result<LlmResponseStream> {
2860 unimplemented!()
2861 }
2862 }
2863
2864 let mut registry = DriverRegistry::new();
2865 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2866 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2868 }
2869
2870 #[test]
2871 fn test_register_or_replace_overwrites() {
2872 struct MockDriver;
2873 #[async_trait]
2874 impl ChatDriver for MockDriver {
2875 async fn chat_completion_stream(
2876 &self,
2877 _messages: Vec<LlmMessage>,
2878 _config: &LlmCallConfig,
2879 ) -> Result<LlmResponseStream> {
2880 unimplemented!()
2881 }
2882 }
2883
2884 let mut registry = DriverRegistry::new();
2885 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2886 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2888 assert!(registry.has_driver(&DriverId::LlmSim));
2889 }
2890
2891 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2896
2897 #[test]
2898 fn test_message_has_image_files_with_image_file() {
2899 let message = Message {
2900 id: uuid::Uuid::new_v4().into(),
2901 role: MessageRole::User,
2902 content: vec![
2903 ContentPart::Text(TextContentPart {
2904 text: "Look at this image".to_string(),
2905 }),
2906 ContentPart::ImageFile(ImageFileContentPart {
2907 image_id: uuid::Uuid::new_v4().into(),
2908 filename: Some("test.png".to_string()),
2909 }),
2910 ],
2911 phase: None,
2912 thinking: None,
2913 thinking_signature: None,
2914 controls: None,
2915 metadata: None,
2916 external_actor: None,
2917 created_at: chrono::Utc::now(),
2918 };
2919
2920 assert!(LlmMessage::message_has_image_files(&message));
2921 }
2922
2923 #[test]
2924 fn test_message_has_image_files_without_image_file() {
2925 let message = Message {
2926 id: uuid::Uuid::new_v4().into(),
2927 role: MessageRole::User,
2928 content: vec![ContentPart::Text(TextContentPart {
2929 text: "Just text".to_string(),
2930 })],
2931 phase: None,
2932 thinking: None,
2933 thinking_signature: None,
2934 controls: None,
2935 metadata: None,
2936 external_actor: None,
2937 created_at: chrono::Utc::now(),
2938 };
2939
2940 assert!(!LlmMessage::message_has_image_files(&message));
2941 }
2942
2943 #[test]
2944 fn test_extract_image_file_ids() {
2945 let id1 = uuid::Uuid::new_v4();
2946 let id2 = uuid::Uuid::new_v4();
2947
2948 let message = Message {
2949 id: uuid::Uuid::new_v4().into(),
2950 role: MessageRole::User,
2951 content: vec![
2952 ContentPart::Text(TextContentPart {
2953 text: "Look at these images".to_string(),
2954 }),
2955 ContentPart::ImageFile(ImageFileContentPart {
2956 image_id: id1.into(),
2957 filename: Some("test1.png".to_string()),
2958 }),
2959 ContentPart::ImageFile(ImageFileContentPart {
2960 image_id: id2.into(),
2961 filename: Some("test2.png".to_string()),
2962 }),
2963 ],
2964 phase: None,
2965 thinking: None,
2966 thinking_signature: None,
2967 controls: None,
2968 metadata: None,
2969 external_actor: None,
2970 created_at: chrono::Utc::now(),
2971 };
2972
2973 let ids = LlmMessage::extract_image_file_ids(&message);
2974 assert_eq!(ids.len(), 2);
2975 assert!(ids.contains(&id1));
2976 assert!(ids.contains(&id2));
2977 }
2978
2979 #[test]
2980 fn test_from_message_with_images_text_only() {
2981 let message = Message {
2982 id: uuid::Uuid::new_v4().into(),
2983 role: MessageRole::User,
2984 content: vec![ContentPart::Text(TextContentPart {
2985 text: "Hello".to_string(),
2986 })],
2987 phase: None,
2988 thinking: None,
2989 thinking_signature: None,
2990 controls: None,
2991 metadata: None,
2992 external_actor: None,
2993 created_at: chrono::Utc::now(),
2994 };
2995
2996 let resolved = std::collections::HashMap::new();
2997 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2998
2999 assert_eq!(llm_message.role, LlmMessageRole::User);
3000 match llm_message.content {
3001 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
3002 _ => panic!("Expected text content"),
3003 }
3004 }
3005
3006 #[test]
3007 fn test_from_message_with_images_resolved_image() {
3008 let image_id = uuid::Uuid::new_v4();
3009 let message = Message {
3010 id: uuid::Uuid::new_v4().into(),
3011 role: MessageRole::User,
3012 content: vec![
3013 ContentPart::Text(TextContentPart {
3014 text: "Look at this".to_string(),
3015 }),
3016 ContentPart::ImageFile(ImageFileContentPart {
3017 image_id: image_id.into(),
3018 filename: Some("test.png".to_string()),
3019 }),
3020 ],
3021 phase: None,
3022 thinking: None,
3023 thinking_signature: None,
3024 controls: None,
3025 metadata: None,
3026 external_actor: None,
3027 created_at: chrono::Utc::now(),
3028 };
3029
3030 let mut resolved = std::collections::HashMap::new();
3031 resolved.insert(
3032 image_id,
3033 crate::ResolvedImage::new("base64data", "image/png"),
3034 );
3035
3036 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
3037
3038 match &llm_message.content {
3039 LlmMessageContent::Parts(parts) => {
3040 assert_eq!(parts.len(), 2);
3041 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
3043 if let LlmContentPart::Image { url } = &parts[1] {
3045 assert!(url.starts_with("data:image/png;base64,"));
3046 } else {
3047 panic!("Expected image content part");
3048 }
3049 }
3050 _ => panic!("Expected parts content"),
3051 }
3052 }
3053
3054 #[test]
3055 fn test_from_message_with_images_unresolved_image() {
3056 let image_id = uuid::Uuid::new_v4();
3057 let message = Message {
3058 id: uuid::Uuid::new_v4().into(),
3059 role: MessageRole::User,
3060 content: vec![ContentPart::ImageFile(ImageFileContentPart {
3061 image_id: image_id.into(),
3062 filename: Some("missing.png".to_string()),
3063 })],
3064 phase: None,
3065 thinking: None,
3066 thinking_signature: None,
3067 controls: None,
3068 metadata: None,
3069 external_actor: None,
3070 created_at: chrono::Utc::now(),
3071 };
3072
3073 let resolved = std::collections::HashMap::new();
3075 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
3076
3077 match &llm_message.content {
3080 LlmMessageContent::Text(text) => {
3081 assert!(text.contains("Image not found"));
3082 }
3083 LlmMessageContent::Parts(parts) => {
3084 assert_eq!(parts.len(), 1);
3085 if let LlmContentPart::Text { text } = &parts[0] {
3086 assert!(text.contains("Image not found"));
3087 } else {
3088 panic!("Expected text placeholder for missing image");
3089 }
3090 }
3091 }
3092 }
3093
3094 #[test]
3095 fn test_prepend_text_prefix_simple_text() {
3096 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
3097 msg.prepend_text_prefix("[Alice] ");
3098 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
3099 }
3100
3101 #[test]
3102 fn test_prepend_text_prefix_parts() {
3103 let mut msg = LlmMessage::parts(
3104 LlmMessageRole::User,
3105 vec![
3106 LlmContentPart::Text {
3107 text: "Hello".to_string(),
3108 },
3109 LlmContentPart::Image {
3110 url: "data:image/png;base64,abc".to_string(),
3111 },
3112 ],
3113 );
3114 msg.prepend_text_prefix("[Bob] ");
3115 match &msg.content {
3116 LlmMessageContent::Parts(parts) => {
3117 if let LlmContentPart::Text { text } = &parts[0] {
3118 assert_eq!(text, "[Bob] Hello");
3119 } else {
3120 panic!("Expected text part");
3121 }
3122 }
3123 _ => panic!("Expected parts content"),
3124 }
3125 }
3126
3127 #[test]
3128 fn test_prepend_text_prefix_parts_no_text() {
3129 let mut msg = LlmMessage::parts(
3130 LlmMessageRole::User,
3131 vec![LlmContentPart::Image {
3132 url: "data:image/png;base64,abc".to_string(),
3133 }],
3134 );
3135 msg.prepend_text_prefix("[Eve] ");
3136 match &msg.content {
3137 LlmMessageContent::Parts(parts) => {
3138 assert_eq!(parts.len(), 2);
3139 if let LlmContentPart::Text { text } = &parts[0] {
3140 assert_eq!(text, "[Eve] ");
3141 } else {
3142 panic!("Expected prepended text part");
3143 }
3144 }
3145 _ => panic!("Expected parts content"),
3146 }
3147 }
3148
3149 #[test]
3150 fn test_openrouter_plugin_config_is_empty() {
3151 assert!(OpenRouterPluginConfig::default().is_empty());
3152 assert!(
3153 !OpenRouterPluginConfig {
3154 web: Some(OpenRouterWebSearchPlugin::default()),
3155 file: None,
3156 }
3157 .is_empty()
3158 );
3159 assert!(
3160 !OpenRouterPluginConfig {
3161 web: None,
3162 file: Some(OpenRouterFilePlugin {}),
3163 }
3164 .is_empty()
3165 );
3166 }
3167
3168 #[test]
3169 fn test_openrouter_routing_is_empty_with_plugins() {
3170 let with_plugins = OpenRouterRoutingConfig {
3171 plugins: Some(OpenRouterPluginConfig {
3172 web: Some(OpenRouterWebSearchPlugin::default()),
3173 file: None,
3174 }),
3175 ..Default::default()
3176 };
3177 assert!(!with_plugins.is_empty());
3178
3179 let empty_plugins = OpenRouterRoutingConfig {
3180 plugins: Some(OpenRouterPluginConfig::default()),
3181 ..Default::default()
3182 };
3183 assert!(empty_plugins.is_empty());
3184 }
3185
3186 #[test]
3187 fn test_openrouter_web_search_plugin_serialization() {
3188 let plugin = OpenRouterWebSearchPlugin {
3189 max_results: Some(10),
3190 search_prompt: Some("search for Rust crates".to_string()),
3191 };
3192 let json = serde_json::to_value(&plugin).unwrap();
3193 assert_eq!(json["max_results"], 10);
3194 assert_eq!(json["search_prompt"], "search for Rust crates");
3195 }
3196
3197 #[test]
3198 fn test_openrouter_web_search_plugin_omits_none_fields() {
3199 let plugin = OpenRouterWebSearchPlugin::default();
3200 let json = serde_json::to_value(&plugin).unwrap();
3201 assert!(json.get("max_results").is_none());
3202 assert!(json.get("search_prompt").is_none());
3203 }
3204
3205 #[test]
3206 fn test_capacity_strategy_shared_capacity_is_noop() {
3207 let base = OpenRouterRoutingConfig {
3208 models: vec!["openai/gpt-5-mini".to_string()],
3209 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3210 ..Default::default()
3211 };
3212 let result = base.apply_capacity_strategy().unwrap();
3213 assert_eq!(
3214 result.capacity_strategy,
3215 Some(OpenRouterCapacityStrategy::SharedCapacity)
3216 );
3217 assert!(result.provider.is_none());
3218 }
3219
3220 #[test]
3221 fn test_capacity_strategy_none_is_noop() {
3222 let base = OpenRouterRoutingConfig {
3223 models: vec!["openai/gpt-5-mini".to_string()],
3224 capacity_strategy: None,
3225 ..Default::default()
3226 };
3227 let result = base.apply_capacity_strategy().unwrap();
3228 assert!(result.provider.is_none());
3229 }
3230
3231 #[test]
3232 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
3233 let base = OpenRouterRoutingConfig {
3234 models: vec!["openai/gpt-5-mini".to_string()],
3235 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3236 ..Default::default()
3237 };
3238 let result = base.apply_capacity_strategy().unwrap();
3239 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
3240 assert_eq!(provider.allow_fallbacks, Some(true));
3241 }
3242
3243 #[test]
3244 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
3245 let base = OpenRouterRoutingConfig {
3247 models: vec!["openai/gpt-5-mini".to_string()],
3248 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3249 provider: Some(OpenRouterProviderRouting {
3250 allow_fallbacks: Some(false),
3251 ..Default::default()
3252 }),
3253 ..Default::default()
3254 };
3255 let result = base.apply_capacity_strategy().unwrap();
3256 let provider = result.provider.as_ref().unwrap();
3257 assert_eq!(provider.allow_fallbacks, Some(false));
3258 }
3259
3260 #[test]
3261 fn test_capacity_strategy_byok_only_requires_provider_only() {
3262 let base = OpenRouterRoutingConfig {
3263 models: vec!["openai/gpt-5-mini".to_string()],
3264 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3265 ..Default::default()
3266 };
3267 let err = base.apply_capacity_strategy().unwrap_err();
3268 assert!(
3269 err.contains("provider.only"),
3270 "error should mention provider.only: {err}"
3271 );
3272 }
3273
3274 #[test]
3275 fn test_capacity_strategy_byok_only_disables_fallbacks() {
3276 let base = OpenRouterRoutingConfig {
3277 models: vec!["openai/gpt-5-mini".to_string()],
3278 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3279 provider: Some(OpenRouterProviderRouting {
3280 only: vec!["my-byok-provider".to_string()],
3281 ..Default::default()
3282 }),
3283 ..Default::default()
3284 };
3285 let result = base.apply_capacity_strategy().unwrap();
3286 let provider = result.provider.as_ref().unwrap();
3287 assert_eq!(provider.allow_fallbacks, Some(false));
3288 assert_eq!(provider.only, vec!["my-byok-provider"]);
3289 }
3290
3291 #[test]
3292 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3293 let with_strategy = OpenRouterRoutingConfig {
3294 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3295 ..Default::default()
3296 };
3297 assert!(!with_strategy.is_empty());
3298
3299 let byok_first = OpenRouterRoutingConfig {
3300 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3301 ..Default::default()
3302 };
3303 assert!(!byok_first.is_empty());
3304
3305 let shared = OpenRouterRoutingConfig {
3306 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3307 ..Default::default()
3308 };
3309 assert!(shared.is_empty());
3310 }
3311
3312 #[test]
3317 fn test_preset_no_presets_is_noop() {
3318 let base = OpenRouterRoutingConfig {
3319 models: vec!["openai/gpt-5-mini".to_string()],
3320 ..Default::default()
3321 };
3322 let result = base.apply_presets().unwrap();
3323 assert_eq!(result, base);
3324 }
3325
3326 #[test]
3327 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3328 let base = OpenRouterRoutingConfig {
3329 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3330 ..Default::default()
3331 };
3332 let result = base.apply_presets().unwrap();
3333 assert!(result.presets.is_empty(), "presets cleared after apply");
3334 let provider = result.provider.expect("provider set by preset");
3335 assert_eq!(provider.require_parameters, Some(true));
3336 assert_eq!(
3337 provider.sort,
3338 Some(OpenRouterProviderSort::Simple(
3339 OpenRouterProviderSortBy::Price
3340 ))
3341 );
3342 }
3343
3344 #[test]
3345 fn test_preset_lowest_latency_review_sets_sort_throughput() {
3346 let base = OpenRouterRoutingConfig {
3347 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3348 ..Default::default()
3349 };
3350 let result = base.apply_presets().unwrap();
3351 let provider = result.provider.expect("provider set by preset");
3352 assert_eq!(
3353 provider.sort,
3354 Some(OpenRouterProviderSort::Simple(
3355 OpenRouterProviderSortBy::Throughput
3356 ))
3357 );
3358 }
3359
3360 #[test]
3361 fn test_preset_zdr_only_sets_zdr() {
3362 let base = OpenRouterRoutingConfig {
3363 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3364 ..Default::default()
3365 };
3366 let result = base.apply_presets().unwrap();
3367 let provider = result.provider.expect("provider set");
3368 assert_eq!(provider.zdr, Some(true));
3369 }
3370
3371 #[test]
3372 fn test_preset_byok_first_sets_allow_fallbacks() {
3373 let base = OpenRouterRoutingConfig {
3374 presets: vec![OpenRouterRoutingPreset::ByokFirst],
3375 ..Default::default()
3376 };
3377 let result = base.apply_presets().unwrap();
3378 let provider = result.provider.expect("provider set");
3379 assert_eq!(provider.allow_fallbacks, Some(true));
3380 }
3381
3382 #[test]
3383 fn test_preset_no_data_collection_sets_data_collection_deny() {
3384 let base = OpenRouterRoutingConfig {
3385 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3386 ..Default::default()
3387 };
3388 let result = base.apply_presets().unwrap();
3389 let provider = result.provider.expect("provider set");
3390 assert_eq!(
3391 provider.data_collection,
3392 Some(OpenRouterDataCollection::Deny)
3393 );
3394 }
3395
3396 #[test]
3397 fn test_preset_strict_json_sets_require_parameters() {
3398 let base = OpenRouterRoutingConfig {
3399 presets: vec![OpenRouterRoutingPreset::StrictJson],
3400 ..Default::default()
3401 };
3402 let result = base.apply_presets().unwrap();
3403 let provider = result.provider.expect("provider set");
3404 assert_eq!(provider.require_parameters, Some(true));
3405 }
3406
3407 #[test]
3408 fn test_preset_reasoning_required_sets_require_parameters() {
3409 let base = OpenRouterRoutingConfig {
3410 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3411 ..Default::default()
3412 };
3413 let result = base.apply_presets().unwrap();
3414 let provider = result.provider.expect("provider set");
3415 assert_eq!(provider.require_parameters, Some(true));
3416 }
3417
3418 #[test]
3419 fn test_preset_max_price_converts_usd_per_million() {
3420 let base = OpenRouterRoutingConfig {
3421 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3422 prompt_usd_per_million: Some(5.0),
3423 completion_usd_per_million: Some(15.0),
3424 }],
3425 ..Default::default()
3426 };
3427 let result = base.apply_presets().unwrap();
3428 let provider = result.provider.expect("provider set");
3429 let max_price = provider.max_price.expect("max_price set");
3430 let prompt = max_price.prompt.expect("prompt set");
3432 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3433 let completion = max_price.completion.expect("completion set");
3434 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3435 }
3436
3437 #[test]
3438 fn test_preset_max_price_rejects_negative_values() {
3439 let base = OpenRouterRoutingConfig {
3440 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3441 prompt_usd_per_million: Some(-1.0),
3442 completion_usd_per_million: None,
3443 }],
3444 ..Default::default()
3445 };
3446 let err = base.apply_presets().unwrap_err();
3447 assert!(
3448 err.contains("non-negative"),
3449 "error should mention non-negative: {err}"
3450 );
3451 }
3452
3453 #[test]
3454 fn test_preset_max_price_both_none_no_provider_field() {
3455 let base = OpenRouterRoutingConfig {
3456 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3457 prompt_usd_per_million: None,
3458 completion_usd_per_million: None,
3459 }],
3460 ..Default::default()
3461 };
3462 let result = base.apply_presets().unwrap();
3463 assert!(
3464 result.provider.is_none(),
3465 "MaxPrice with no dimensions should not produce a provider field"
3466 );
3467 }
3468
3469 #[test]
3470 fn test_preset_explicit_provider_overrides_preset() {
3471 let base = OpenRouterRoutingConfig {
3472 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3473 provider: Some(OpenRouterProviderRouting {
3474 sort: Some(OpenRouterProviderSort::Simple(
3476 OpenRouterProviderSortBy::Throughput,
3477 )),
3478 ..Default::default()
3479 }),
3480 ..Default::default()
3481 };
3482 let result = base.apply_presets().unwrap();
3483 let provider = result.provider.expect("provider set");
3484 assert_eq!(
3486 provider.sort,
3487 Some(OpenRouterProviderSort::Simple(
3488 OpenRouterProviderSortBy::Throughput
3489 ))
3490 );
3491 assert_eq!(provider.require_parameters, Some(true));
3493 }
3494
3495 #[test]
3496 fn test_preset_multiple_presets_combined() {
3497 let base = OpenRouterRoutingConfig {
3498 presets: vec![
3499 OpenRouterRoutingPreset::ZdrOnly,
3500 OpenRouterRoutingPreset::NoDataCollection,
3501 OpenRouterRoutingPreset::LowestLatencyReview,
3502 ],
3503 ..Default::default()
3504 };
3505 let result = base.apply_presets().unwrap();
3506 let provider = result.provider.expect("provider set");
3507 assert_eq!(provider.zdr, Some(true));
3508 assert_eq!(
3509 provider.data_collection,
3510 Some(OpenRouterDataCollection::Deny)
3511 );
3512 assert_eq!(
3513 provider.sort,
3514 Some(OpenRouterProviderSort::Simple(
3515 OpenRouterProviderSortBy::Throughput
3516 ))
3517 );
3518 }
3519
3520 #[test]
3521 fn test_preset_later_preset_overrides_sort() {
3522 let base = OpenRouterRoutingConfig {
3523 presets: vec![
3524 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3527 ..Default::default()
3528 };
3529 let result = base.apply_presets().unwrap();
3530 let provider = result.provider.expect("provider set");
3531 assert_eq!(
3533 provider.sort,
3534 Some(OpenRouterProviderSort::Simple(
3535 OpenRouterProviderSortBy::Throughput
3536 ))
3537 );
3538 assert_eq!(provider.require_parameters, Some(true));
3540 }
3541
3542 #[test]
3543 fn test_preset_non_empty_in_is_empty() {
3544 let with_preset = OpenRouterRoutingConfig {
3545 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3546 ..Default::default()
3547 };
3548 assert!(!with_preset.is_empty());
3549
3550 let without = OpenRouterRoutingConfig::default();
3551 assert!(without.is_empty());
3552 }
3553}