1use crate::credential_schema::CredentialFormSchema;
18use crate::error::{AgentLoopError, 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)]
39pub enum LlmStreamEvent {
40 TextDelta(String),
42 ThinkingDelta(String),
44 ThinkingSignature(String),
47 ReasonItem {
53 provider: String,
55 model: Option<String>,
57 item_id: String,
59 encrypted_content: Option<String>,
61 summary: Vec<String>,
63 token_count: Option<u32>,
65 },
66 ToolCalls(Vec<ToolCall>),
68 Done(Box<LlmCompletionMetadata>),
70 Error(String),
72}
73
74#[derive(Debug, Clone)]
85pub struct DiscoveredModel {
86 pub model_id: String,
88 pub display_name: Option<String>,
90 pub created_at: Option<DateTime<Utc>>,
92 pub owned_by: Option<String>,
94 pub discovered_profile: Option<crate::model::ModelProfile>,
97}
98
99#[derive(Debug, Clone, Default)]
113pub struct LlmCompletionMetadata {
114 pub total_tokens: Option<u32>,
116 pub prompt_tokens: Option<u32>,
118 pub completion_tokens: Option<u32>,
120 pub cache_read_tokens: Option<u32>,
122 pub cache_creation_tokens: Option<u32>,
124 pub provider_cost_usd: Option<f64>,
128 pub model: Option<String>,
130 pub finish_reason: Option<String>,
132 pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
134 pub response_id: Option<String>,
137 pub phase: Option<String>,
141}
142
143pub fn disjoint_prompt_tokens(reported_input: u32, cache_read: Option<u32>) -> u32 {
154 reported_input.saturating_sub(cache_read.unwrap_or(0))
155}
156
157#[async_trait]
179pub trait ChatDriver: Send + Sync {
180 async fn chat_completion_stream(
182 &self,
183 messages: Vec<LlmMessage>,
184 config: &LlmCallConfig,
185 ) -> Result<LlmResponseStream>;
186
187 async fn chat_completion(
189 &self,
190 messages: Vec<LlmMessage>,
191 config: &LlmCallConfig,
192 ) -> Result<LlmResponse> {
193 use futures::StreamExt;
194
195 let mut stream = self.chat_completion_stream(messages, config).await?;
196 let mut text = String::new();
197 let mut thinking = String::new();
198 let mut thinking_signature: Option<String> = None;
199 let mut tool_calls = Vec::new();
200 let mut metadata = LlmCompletionMetadata::default();
201
202 while let Some(event) = stream.next().await {
203 match event? {
204 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
205 LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
206 LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
207 LlmStreamEvent::ReasonItem {
208 encrypted_content, ..
209 } => {
210 if let Some(sig) = encrypted_content {
211 thinking_signature = Some(sig);
212 }
213 }
214 LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
215 LlmStreamEvent::Done(meta) => metadata = *meta,
216 LlmStreamEvent::Error(err) => return Err(crate::error::AgentLoopError::llm(err)),
217 }
218 }
219
220 Ok(LlmResponse {
221 text,
222 thinking: if thinking.is_empty() {
223 None
224 } else {
225 Some(thinking)
226 },
227 thinking_signature,
228 tool_calls: if tool_calls.is_empty() {
229 None
230 } else {
231 Some(tool_calls)
232 },
233 metadata,
234 })
235 }
236
237 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
245 Ok(None)
247 }
248
249 fn supports_compact(&self) -> bool {
258 false
260 }
261
262 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
273 false
274 }
275
276 async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
296 Ok(None)
298 }
299}
300
301#[async_trait]
303impl ChatDriver for Box<dyn ChatDriver> {
304 async fn chat_completion_stream(
305 &self,
306 messages: Vec<LlmMessage>,
307 config: &LlmCallConfig,
308 ) -> Result<LlmResponseStream> {
309 (**self).chat_completion_stream(messages, config).await
310 }
311
312 async fn chat_completion(
313 &self,
314 messages: Vec<LlmMessage>,
315 config: &LlmCallConfig,
316 ) -> Result<LlmResponse> {
317 (**self).chat_completion(messages, config).await
318 }
319
320 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
321 (**self).list_models().await
322 }
323
324 fn supports_compact(&self) -> bool {
325 (**self).supports_compact()
326 }
327
328 fn supports_parallel_tool_calls(&self, model: &str) -> bool {
329 (**self).supports_parallel_tool_calls(model)
330 }
331
332 async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
333 (**self).compact(request).await
334 }
335}
336
337#[derive(Debug, Clone)]
343pub struct LlmMessage {
344 pub role: LlmMessageRole,
345 pub content: LlmMessageContent,
346 pub tool_calls: Option<Vec<ToolCall>>,
347 pub tool_call_id: Option<String>,
348 pub phase: Option<crate::message::ExecutionPhase>,
353 pub thinking: Option<String>,
356 pub thinking_signature: Option<String>,
359}
360
361impl LlmMessage {
362 pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
364 Self {
365 role,
366 content: LlmMessageContent::Text(content.into()),
367 tool_calls: None,
368 tool_call_id: None,
369 phase: None,
370 thinking: None,
371 thinking_signature: None,
372 }
373 }
374
375 pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
377 Self {
378 role,
379 content: LlmMessageContent::Parts(parts),
380 tool_calls: None,
381 tool_call_id: None,
382 phase: None,
383 thinking: None,
384 thinking_signature: None,
385 }
386 }
387
388 pub fn content_as_text(&self) -> String {
390 self.content.to_text()
391 }
392
393 pub fn prepend_text_prefix(&mut self, prefix: &str) {
398 match &mut self.content {
399 LlmMessageContent::Text(text) => {
400 *text = format!("{}{}", prefix, text);
401 }
402 LlmMessageContent::Parts(parts) => {
403 for part in parts.iter_mut() {
404 if let LlmContentPart::Text { text } = part {
405 *text = format!("{}{}", prefix, text);
406 return;
407 }
408 }
409 parts.insert(
411 0,
412 LlmContentPart::Text {
413 text: prefix.to_string(),
414 },
415 );
416 }
417 }
418 }
419}
420
421pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
432 let mut system: Option<String> = None;
433 for msg in messages {
434 if msg.role == LlmMessageRole::System {
435 let text = msg.content.to_text();
436 system = Some(match system.take() {
437 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
438 _ => text,
439 });
440 }
441 }
442 system
443}
444
445#[derive(Debug, Clone)]
447pub enum LlmMessageContent {
448 Text(String),
450 Parts(Vec<LlmContentPart>),
452}
453
454impl LlmMessageContent {
455 pub fn to_text(&self) -> String {
457 match self {
458 LlmMessageContent::Text(s) => s.clone(),
459 LlmMessageContent::Parts(parts) => parts
460 .iter()
461 .filter_map(|p| match p {
462 LlmContentPart::Text { text } => Some(text.clone()),
463 _ => None,
464 })
465 .collect::<Vec<_>>()
466 .join(""),
467 }
468 }
469
470 pub fn is_text(&self) -> bool {
472 matches!(self, LlmMessageContent::Text(_))
473 }
474
475 pub fn is_parts(&self) -> bool {
477 matches!(self, LlmMessageContent::Parts(_))
478 }
479}
480
481impl From<String> for LlmMessageContent {
482 fn from(s: String) -> Self {
483 LlmMessageContent::Text(s)
484 }
485}
486
487impl From<&str> for LlmMessageContent {
488 fn from(s: &str) -> Self {
489 LlmMessageContent::Text(s.to_string())
490 }
491}
492
493#[derive(Debug, Clone)]
495pub enum LlmContentPart {
496 Text { text: String },
498 Image { url: String },
500 Audio { url: String },
502}
503
504impl LlmContentPart {
505 pub fn text(text: impl Into<String>) -> Self {
507 LlmContentPart::Text { text: text.into() }
508 }
509
510 pub fn image(url: impl Into<String>) -> Self {
512 LlmContentPart::Image { url: url.into() }
513 }
514
515 pub fn audio(url: impl Into<String>) -> Self {
517 LlmContentPart::Audio { url: url.into() }
518 }
519}
520
521#[derive(Debug, Clone, PartialEq, Eq)]
523pub enum LlmMessageRole {
524 System,
525 User,
526 Assistant,
527 Tool,
528}
529
530#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
540pub struct ToolSearchConfig {
541 pub enabled: bool,
543 pub threshold: usize,
546}
547
548#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
550#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
551#[serde(rename_all = "snake_case")]
552pub enum PromptCacheStrategy {
553 #[default]
555 Auto,
556}
557
558#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
563#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
564pub struct PromptCacheConfig {
565 pub enabled: bool,
567 #[serde(default)]
569 pub strategy: PromptCacheStrategy,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub gemini_cached_content: Option<String>,
578}
579
580#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
590#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
591#[serde(tag = "kind", rename_all = "snake_case")]
592pub enum OpenRouterRoutingPreset {
593 CheapestWithTools,
595 LowestLatencyReview,
597 ZdrOnly,
599 ByokFirst,
601 NoDataCollection,
603 StrictJson,
605 ReasoningRequired,
607 MaxPrice {
610 #[serde(default, skip_serializing_if = "Option::is_none")]
612 prompt_usd_per_million: Option<f64>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
615 completion_usd_per_million: Option<f64>,
616 },
617}
618
619#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
627#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
628#[serde(rename_all = "snake_case")]
629pub enum OpenRouterCapacityStrategy {
630 #[default]
632 SharedCapacity,
633 ByokFirst,
637 ByokOnly,
642}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
652#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
653#[serde(rename_all = "snake_case")]
654pub enum OpenRouterServerToolKind {
655 WebSearch,
656 WebFetch,
657 Datetime,
658 ImageGeneration,
659 ApplyPatch,
660 Fusion,
661 Advisor,
662 Subagent,
663}
664
665impl OpenRouterServerToolKind {
666 pub const ALL: [OpenRouterServerToolKind; 8] = [
668 Self::WebSearch,
669 Self::WebFetch,
670 Self::Datetime,
671 Self::ImageGeneration,
672 Self::ApplyPatch,
673 Self::Fusion,
674 Self::Advisor,
675 Self::Subagent,
676 ];
677
678 pub fn name(&self) -> &'static str {
680 match self {
681 Self::WebSearch => "web_search",
682 Self::WebFetch => "web_fetch",
683 Self::Datetime => "datetime",
684 Self::ImageGeneration => "image_generation",
685 Self::ApplyPatch => "apply_patch",
686 Self::Fusion => "fusion",
687 Self::Advisor => "advisor",
688 Self::Subagent => "subagent",
689 }
690 }
691
692 pub fn display_name(&self) -> &'static str {
694 match self {
695 Self::WebSearch => "Web Search",
696 Self::WebFetch => "Web Fetch",
697 Self::Datetime => "Date & Time",
698 Self::ImageGeneration => "Image Generation",
699 Self::ApplyPatch => "Apply Patch",
700 Self::Fusion => "Fusion",
701 Self::Advisor => "Advisor",
702 Self::Subagent => "Subagent",
703 }
704 }
705
706 pub fn wire_type(&self) -> String {
709 format!("openrouter:{}", self.name())
710 }
711
712 pub fn from_name(name: &str) -> Option<Self> {
714 Self::ALL.into_iter().find(|kind| kind.name() == name)
715 }
716}
717
718#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
722#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
723pub struct OpenRouterServerTool {
724 pub kind: OpenRouterServerToolKind,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
727 pub parameters: Option<serde_json::Value>,
728}
729
730impl OpenRouterServerTool {
731 pub fn new(kind: OpenRouterServerToolKind) -> Self {
733 Self {
734 kind,
735 parameters: None,
736 }
737 }
738
739 pub fn with_parameters(kind: OpenRouterServerToolKind, parameters: serde_json::Value) -> Self {
741 Self {
742 kind,
743 parameters: Some(parameters),
744 }
745 }
746}
747
748#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
751#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
752pub struct OpenRouterRoutingConfig {
753 #[serde(default, skip_serializing_if = "Vec::is_empty")]
755 pub models: Vec<String>,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub route: Option<OpenRouterRoute>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub provider: Option<OpenRouterProviderRouting>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub plugins: Option<OpenRouterPluginConfig>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
771 #[serde(default, skip_serializing_if = "Vec::is_empty")]
775 pub presets: Vec<OpenRouterRoutingPreset>,
776 #[serde(default, skip_serializing_if = "Vec::is_empty")]
779 pub server_tools: Vec<OpenRouterServerTool>,
780}
781
782impl OpenRouterRoutingConfig {
783 pub fn is_empty(&self) -> bool {
784 self.models.is_empty()
785 && self.route.is_none()
786 && self.provider.is_none()
787 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
788 && matches!(
789 self.capacity_strategy,
790 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
791 )
792 && self.presets.is_empty()
793 && self.server_tools.is_empty()
794 }
795
796 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
798 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
799 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
800 Self {
801 models,
802 route,
803 provider: None,
804 plugins: None,
805 capacity_strategy: None,
806 presets: vec![],
807 server_tools: vec![],
808 }
809 }
810
811 pub fn validate_for_primary_model(
812 &self,
813 primary_model: &str,
814 ) -> std::result::Result<(), String> {
815 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
816 return Err(
817 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
818 );
819 }
820
821 if let Some(first_model) = self.models.first()
822 && first_model != primary_model
823 {
824 return Err(format!(
825 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
826 ));
827 }
828
829 Ok(())
830 }
831
832 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
842 match self.capacity_strategy {
843 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
844 Some(OpenRouterCapacityStrategy::ByokFirst) => {
845 let mut result = self.clone();
846 let provider = result.provider.get_or_insert_with(Default::default);
847 if provider.allow_fallbacks.is_none() {
848 provider.allow_fallbacks = Some(true);
849 }
850 Ok(result)
851 }
852 Some(OpenRouterCapacityStrategy::ByokOnly) => {
853 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
854 if only_is_empty {
855 return Err(
856 "OpenRouter BYOK-only strategy requires provider.only to list at least \
857 one upstream provider slug. Configure the provider list to match the \
858 BYOK providers registered in your OpenRouter workspace."
859 .to_string(),
860 );
861 }
862 let mut result = self.clone();
863 let provider = result.provider.get_or_insert_with(Default::default);
864 provider.allow_fallbacks = Some(false);
865 Ok(result)
866 }
867 }
868 }
869
870 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
880 if self.presets.is_empty() {
881 return Ok(self.clone());
882 }
883
884 let mut derived = OpenRouterProviderRouting::default();
885
886 for preset in &self.presets {
887 match preset {
888 OpenRouterRoutingPreset::CheapestWithTools => {
889 derived.require_parameters = Some(true);
890 derived.sort = Some(OpenRouterProviderSort::Simple(
891 OpenRouterProviderSortBy::Price,
892 ));
893 }
894 OpenRouterRoutingPreset::LowestLatencyReview => {
895 derived.sort = Some(OpenRouterProviderSort::Simple(
896 OpenRouterProviderSortBy::Throughput,
897 ));
898 }
899 OpenRouterRoutingPreset::ZdrOnly => {
900 derived.zdr = Some(true);
901 }
902 OpenRouterRoutingPreset::ByokFirst => {
903 if derived.allow_fallbacks.is_none() {
904 derived.allow_fallbacks = Some(true);
905 }
906 }
907 OpenRouterRoutingPreset::NoDataCollection => {
908 derived.data_collection = Some(OpenRouterDataCollection::Deny);
909 }
910 OpenRouterRoutingPreset::StrictJson
911 | OpenRouterRoutingPreset::ReasoningRequired => {
912 derived.require_parameters = Some(true);
913 }
914 OpenRouterRoutingPreset::MaxPrice {
915 prompt_usd_per_million,
916 completion_usd_per_million,
917 } => {
918 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
919 || completion_usd_per_million.is_some_and(|v| v < 0.0)
920 {
921 return Err(
922 "MaxPrice preset values must be non-negative USD per million tokens"
923 .to_string(),
924 );
925 }
926 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
927 let mp = derived.max_price.get_or_insert_with(Default::default);
928 if let Some(p) = prompt_usd_per_million {
929 mp.prompt = Some(p / 1_000_000.0);
930 }
931 if let Some(c) = completion_usd_per_million {
932 mp.completion = Some(c / 1_000_000.0);
933 }
934 }
935 }
936 }
937 }
938
939 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
941
942 let mut result = self.clone();
943 result.presets = vec![];
944 result.provider = if merged.is_empty() {
945 None
946 } else {
947 Some(merged)
948 };
949 Ok(result)
950 }
951}
952
953fn merge_provider_routing(
957 derived: OpenRouterProviderRouting,
958 explicit: OpenRouterProviderRouting,
959) -> OpenRouterProviderRouting {
960 OpenRouterProviderRouting {
961 order: if !explicit.order.is_empty() {
962 explicit.order
963 } else {
964 derived.order
965 },
966 only: if !explicit.only.is_empty() {
967 explicit.only
968 } else {
969 derived.only
970 },
971 ignore: if !explicit.ignore.is_empty() {
972 explicit.ignore
973 } else {
974 derived.ignore
975 },
976 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
977 require_parameters: explicit.require_parameters.or(derived.require_parameters),
978 data_collection: explicit.data_collection.or(derived.data_collection),
979 zdr: explicit.zdr.or(derived.zdr),
980 enforce_distillable_text: explicit
981 .enforce_distillable_text
982 .or(derived.enforce_distillable_text),
983 quantizations: if !explicit.quantizations.is_empty() {
984 explicit.quantizations
985 } else {
986 derived.quantizations
987 },
988 sort: explicit.sort.or(derived.sort),
989 max_price: explicit.max_price.or(derived.max_price),
990 }
991}
992
993#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
995#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
996#[serde(rename_all = "snake_case")]
997pub enum OpenRouterRoute {
998 Fallback,
999}
1000
1001#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1003#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1004pub struct OpenRouterProviderRouting {
1005 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1007 pub order: Vec<String>,
1008 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1010 pub only: Vec<String>,
1011 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1013 pub ignore: Vec<String>,
1014 #[serde(default, skip_serializing_if = "Option::is_none")]
1016 pub allow_fallbacks: Option<bool>,
1017 #[serde(default, skip_serializing_if = "Option::is_none")]
1019 pub require_parameters: Option<bool>,
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1022 pub data_collection: Option<OpenRouterDataCollection>,
1023 #[serde(default, skip_serializing_if = "Option::is_none")]
1025 pub zdr: Option<bool>,
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub enforce_distillable_text: Option<bool>,
1029 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1031 pub quantizations: Vec<String>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub sort: Option<OpenRouterProviderSort>,
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1037 pub max_price: Option<OpenRouterMaxPrice>,
1038}
1039
1040impl OpenRouterProviderRouting {
1041 pub fn is_empty(&self) -> bool {
1042 self.order.is_empty()
1043 && self.only.is_empty()
1044 && self.ignore.is_empty()
1045 && self.allow_fallbacks.is_none()
1046 && self.require_parameters.is_none()
1047 && self.data_collection.is_none()
1048 && self.zdr.is_none()
1049 && self.enforce_distillable_text.is_none()
1050 && self.quantizations.is_empty()
1051 && self.sort.is_none()
1052 && self.max_price.is_none()
1053 }
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1058#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1059#[serde(rename_all = "snake_case")]
1060pub enum OpenRouterDataCollection {
1061 Allow,
1062 Deny,
1063}
1064
1065#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1067#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1068#[serde(untagged)]
1069pub enum OpenRouterProviderSort {
1070 Simple(OpenRouterProviderSortBy),
1071 Advanced(OpenRouterProviderSortOptions),
1072}
1073
1074#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1076#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1077#[serde(rename_all = "snake_case")]
1078pub enum OpenRouterProviderSortBy {
1079 Price,
1080 Throughput,
1081 Latency,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1086#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1087pub struct OpenRouterProviderSortOptions {
1088 pub by: OpenRouterProviderSortBy,
1089 #[serde(default, skip_serializing_if = "Option::is_none")]
1090 pub partition: Option<OpenRouterSortPartition>,
1091}
1092
1093#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1095#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1096#[serde(rename_all = "snake_case")]
1097pub enum OpenRouterSortPartition {
1098 Model,
1099 None,
1100}
1101
1102#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1105#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1106pub struct OpenRouterMaxPrice {
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub prompt: Option<f64>,
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub completion: Option<f64>,
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub request: Option<f64>,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub image: Option<f64>,
1115}
1116
1117#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1123#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1124pub struct OpenRouterWebSearchPlugin {
1125 #[serde(default, skip_serializing_if = "Option::is_none")]
1127 pub max_results: Option<u32>,
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub search_prompt: Option<String>,
1131}
1132
1133#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1138#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1139pub struct OpenRouterFilePlugin {}
1140
1141#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1147pub struct OpenRouterPluginConfig {
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub web: Option<OpenRouterWebSearchPlugin>,
1151 #[serde(default, skip_serializing_if = "Option::is_none")]
1153 pub file: Option<OpenRouterFilePlugin>,
1154}
1155
1156impl OpenRouterPluginConfig {
1157 pub fn is_empty(&self) -> bool {
1158 self.web.is_none() && self.file.is_none()
1159 }
1160}
1161
1162pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
1164pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
1166
1167#[derive(Debug, Clone)]
1169pub struct LlmCallConfig {
1170 pub model: String,
1171 pub temperature: Option<f32>,
1172 pub max_tokens: Option<u32>,
1173 pub tools: Vec<ToolDefinition>,
1174 pub reasoning_effort: Option<String>,
1176 pub speed: Option<String>,
1180 pub metadata: HashMap<String, String>,
1184 pub previous_response_id: Option<String>,
1187 pub tool_search: Option<ToolSearchConfig>,
1189 pub prompt_cache: Option<PromptCacheConfig>,
1191 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1193 pub parallel_tool_calls: Option<bool>,
1200 pub volatile_suffix_len: usize,
1210}
1211
1212impl LlmCallConfig {
1213 pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1223 if supported {
1224 self.parallel_tool_calls
1225 } else {
1226 None
1227 }
1228 }
1229}
1230
1231impl From<&RuntimeAgent> for LlmCallConfig {
1232 fn from(runtime_agent: &RuntimeAgent) -> Self {
1233 Self {
1234 model: runtime_agent.model.clone(),
1235 temperature: runtime_agent.temperature,
1236 max_tokens: runtime_agent.max_tokens,
1237 tools: runtime_agent.tools.clone(),
1238 reasoning_effort: None, speed: None, metadata: HashMap::new(), previous_response_id: None,
1242 tool_search: runtime_agent.tool_search.clone(),
1243 prompt_cache: runtime_agent.prompt_cache.clone(),
1244 openrouter_routing: runtime_agent.openrouter_routing.clone(),
1245 parallel_tool_calls: runtime_agent.parallel_tool_calls,
1246 volatile_suffix_len: 0,
1247 }
1248 }
1249}
1250
1251#[derive(Debug, Clone)]
1253pub struct LlmResponse {
1254 pub text: String,
1255 pub thinking: Option<String>,
1257 pub thinking_signature: Option<String>,
1259 pub tool_calls: Option<Vec<ToolCall>>,
1260 pub metadata: LlmCompletionMetadata,
1261}
1262
1263pub struct LlmCallConfigBuilder {
1282 config: LlmCallConfig,
1283}
1284
1285impl LlmCallConfigBuilder {
1286 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1288 Self {
1289 config: LlmCallConfig::from(runtime_agent),
1290 }
1291 }
1292
1293 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1295 self.config.reasoning_effort = Some(effort.into());
1296 self
1297 }
1298
1299 pub fn speed(mut self, speed: impl Into<String>) -> Self {
1301 self.config.speed = Some(speed.into());
1302 self
1303 }
1304
1305 pub fn model(mut self, model: impl Into<String>) -> Self {
1307 self.config.model = model.into();
1308 self
1309 }
1310
1311 pub fn temperature(mut self, temp: f32) -> Self {
1313 self.config.temperature = Some(temp);
1314 self
1315 }
1316
1317 pub fn max_tokens(mut self, tokens: u32) -> Self {
1319 self.config.max_tokens = Some(tokens);
1320 self
1321 }
1322
1323 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1325 self.config.tools = tools;
1326 self
1327 }
1328
1329 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1334 self.config.metadata = metadata;
1335 self
1336 }
1337
1338 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1340 self.config.metadata.insert(key.into(), value.into());
1341 self
1342 }
1343
1344 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1346 self.config.previous_response_id = id;
1347 self
1348 }
1349
1350 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1352 self.config.tool_search = Some(config);
1353 self
1354 }
1355
1356 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1358 self.config.prompt_cache = Some(config);
1359 self
1360 }
1361
1362 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1364 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1365 self
1366 }
1367
1368 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1370 self.config.parallel_tool_calls = parallel_tool_calls;
1371 self
1372 }
1373
1374 pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1378 self.config.volatile_suffix_len = len;
1379 self
1380 }
1381
1382 pub fn build(self) -> LlmCallConfig {
1384 self.config
1385 }
1386}
1387
1388impl From<&crate::message::Message> for LlmMessage {
1393 fn from(msg: &crate::message::Message) -> Self {
1399 let role = match msg.role {
1400 crate::message::MessageRole::System => LlmMessageRole::System,
1401 crate::message::MessageRole::User => LlmMessageRole::User,
1402 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1403 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1404 };
1405
1406 let tool_calls: Vec<ToolCall> = msg
1408 .tool_calls()
1409 .into_iter()
1410 .map(|tc| ToolCall {
1411 id: tc.id.clone(),
1412 name: tc.name.clone(),
1413 arguments: tc.arguments.clone(),
1414 })
1415 .collect();
1416
1417 LlmMessage {
1418 role,
1419 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1420 tool_calls: if tool_calls.is_empty() {
1421 None
1422 } else {
1423 Some(tool_calls)
1424 },
1425 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1426 phase: msg.phase,
1427 thinking: msg.thinking.clone(),
1428 thinking_signature: msg.thinking_signature.clone(),
1429 }
1430 }
1431}
1432
1433use crate::traits::ResolvedImage;
1438use uuid::Uuid;
1439
1440impl LlmMessage {
1441 pub fn from_message_with_images(
1461 msg: &crate::message::Message,
1462 resolved_images: &HashMap<Uuid, ResolvedImage>,
1463 ) -> Self {
1464 use crate::message::{ContentPart, MessageRole};
1465
1466 let role = match msg.role {
1467 MessageRole::System => LlmMessageRole::System,
1468 MessageRole::User => LlmMessageRole::User,
1469 MessageRole::Agent => LlmMessageRole::Assistant,
1470 MessageRole::ToolResult => LlmMessageRole::Tool,
1471 };
1472
1473 let mut parts: Vec<LlmContentPart> = Vec::new();
1475 let mut tool_calls: Vec<ToolCall> = Vec::new();
1476
1477 for part in &msg.content {
1478 match part {
1479 ContentPart::Text(t) => {
1480 parts.push(LlmContentPart::Text {
1481 text: t.text.clone(),
1482 });
1483 }
1484 ContentPart::Image(img) => {
1485 if let Some(url) = &img.url {
1487 parts.push(LlmContentPart::Image { url: url.clone() });
1488 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1489 {
1490 let data_url = format!("data:{};base64,{}", media_type, base64);
1491 parts.push(LlmContentPart::Image { url: data_url });
1492 }
1493 }
1494 ContentPart::ImageFile(img_file) => {
1495 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1497 parts.push(LlmContentPart::Image {
1498 url: resolved.to_data_url(),
1499 });
1500 } else {
1501 parts.push(LlmContentPart::Text {
1503 text: format!("[Image not found: {}]", img_file.image_id),
1504 });
1505 }
1506 }
1507 ContentPart::ToolCall(tc) => {
1508 tool_calls.push(ToolCall {
1510 id: tc.id.clone(),
1511 name: tc.name.clone(),
1512 arguments: tc.arguments.clone(),
1513 });
1514 }
1515 ContentPart::ToolResult(tr) => {
1516 let text = if let Some(err) = &tr.error {
1518 format!("Tool error: {}", err)
1519 } else if let Some(res) = &tr.result {
1520 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1521 } else {
1522 "{}".to_string()
1523 };
1524 let text = truncate_tool_result(text);
1528 parts.push(LlmContentPart::Text { text });
1529 }
1530 }
1531 }
1532
1533 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1535 if let LlmContentPart::Text { text } = &parts[0] {
1537 LlmMessageContent::Text(text.clone())
1538 } else {
1539 LlmMessageContent::Parts(parts)
1540 }
1541 } else if parts.is_empty() {
1542 LlmMessageContent::Text(String::new())
1544 } else {
1545 LlmMessageContent::Parts(parts)
1547 };
1548
1549 LlmMessage {
1550 role,
1551 content,
1552 tool_calls: if tool_calls.is_empty() {
1553 None
1554 } else {
1555 Some(tool_calls)
1556 },
1557 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1558 phase: msg.phase,
1559 thinking: msg.thinking.clone(),
1560 thinking_signature: msg.thinking_signature.clone(),
1561 }
1562 }
1563
1564 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1566 msg.content.iter().any(|p| p.is_image_file())
1567 }
1568
1569 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1571 msg.content
1572 .iter()
1573 .filter_map(|p| match p {
1574 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1575 _ => None,
1576 })
1577 .collect()
1578 }
1579}
1580
1581pub use crate::provider::DriverId;
1586
1587#[derive(Debug, Clone, Default, PartialEq, Eq)]
1593pub struct ProviderMetadata {
1594 pub refresh_token: Option<String>,
1596 pub account_id: Option<String>,
1598 pub extra: Option<serde_json::Value>,
1600}
1601
1602#[derive(Debug, Clone)]
1604pub struct ProviderConfig {
1605 pub provider_type: DriverId,
1607 pub api_key: Option<String>,
1609 pub base_url: Option<String>,
1611 pub metadata: ProviderMetadata,
1613}
1614
1615impl ProviderConfig {
1616 pub fn new(provider_type: DriverId) -> Self {
1618 Self {
1619 provider_type,
1620 api_key: None,
1621 base_url: None,
1622 metadata: ProviderMetadata::default(),
1623 }
1624 }
1625
1626 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1628 self.api_key = Some(api_key.into());
1629 self
1630 }
1631
1632 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1634 self.base_url = Some(base_url.into());
1635 self
1636 }
1637
1638 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1640 self.metadata = metadata;
1641 self
1642 }
1643}
1644
1645#[derive(Debug, Clone)]
1651pub struct DriverConfig {
1652 pub provider_type: DriverId,
1654 pub api_key: Option<String>,
1660 pub credentials: std::collections::BTreeMap<String, String>,
1666 pub base_url: Option<String>,
1668 pub metadata: ProviderMetadata,
1670}
1671
1672impl DriverConfig {
1673 pub fn from_provider_config(config: &ProviderConfig) -> Self {
1679 Self {
1680 provider_type: config.provider_type.clone(),
1681 credentials: crate::credential_schema::parse_credential_document(
1682 config.api_key.as_deref(),
1683 ),
1684 api_key: config.api_key.clone(),
1685 base_url: config.base_url.clone(),
1686 metadata: config.metadata.clone(),
1687 }
1688 }
1689
1690 pub fn credential(&self, name: &str) -> Option<&str> {
1692 self.credentials
1693 .get(name)
1694 .map(String::as_str)
1695 .filter(|s| !s.is_empty())
1696 }
1697}
1698
1699impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1700 fn from(model: &crate::traits::ResolvedModel) -> Self {
1701 Self {
1702 provider_type: model.provider_type.clone(),
1703 api_key: model.api_key.clone(),
1704 base_url: model.base_url.clone(),
1705 metadata: model.provider_metadata.clone().unwrap_or_default(),
1706 }
1707 }
1708}
1709
1710pub type BoxedChatDriver = Box<dyn ChatDriver>;
1712
1713#[derive(Debug, Clone)]
1719pub struct EmbedRequest {
1720 pub texts: Vec<String>,
1722 pub model: String,
1724}
1725
1726#[derive(Debug, Clone)]
1728pub struct EmbedResponse {
1729 pub embeddings: Vec<Vec<f32>>,
1731 pub usage_tokens: Option<u32>,
1734}
1735
1736#[derive(Debug, thiserror::Error)]
1738pub enum EmbeddingsDriverError {
1739 #[error("embeddings provider returned an error: {0}")]
1740 Provider(String),
1741 #[error("embeddings request failed: {0}")]
1742 Transport(String),
1743}
1744
1745#[async_trait]
1751pub trait EmbeddingsDriver: Send + Sync {
1752 async fn embed(
1754 &self,
1755 request: EmbedRequest,
1756 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1757}
1758
1759pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1761
1762pub type EmbeddingsDriverFactory =
1764 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1765
1766pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1775
1776#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1782#[serde(rename_all = "snake_case")]
1783pub enum ServiceKind {
1784 Chat,
1786 Embeddings,
1788 Realtime,
1790 Images,
1792 Rerank,
1794}
1795
1796impl std::fmt::Display for ServiceKind {
1797 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1798 let s = match self {
1799 ServiceKind::Chat => "chat",
1800 ServiceKind::Embeddings => "embeddings",
1801 ServiceKind::Realtime => "realtime",
1802 ServiceKind::Images => "images",
1803 ServiceKind::Rerank => "rerank",
1804 };
1805 f.write_str(s)
1806 }
1807}
1808
1809#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1822pub enum DriverOAuthFlow {
1823 OpenRouterPkce,
1831}
1832
1833#[derive(Debug, Clone)]
1838pub struct DriverOAuthConfig {
1839 pub authorize_url: String,
1841 pub token_url: String,
1843 pub flow: DriverOAuthFlow,
1845}
1846
1847impl DriverOAuthConfig {
1848 pub fn openrouter() -> Self {
1850 Self {
1851 authorize_url: "https://openrouter.ai/auth".to_string(),
1852 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1853 flow: DriverOAuthFlow::OpenRouterPkce,
1854 }
1855 }
1856}
1857
1858#[derive(Clone)]
1865pub struct DriverDescriptor {
1866 pub id: DriverId,
1868 pub display_name: String,
1870 pub services: Vec<ServiceKind>,
1872 pub credential_schema: CredentialFormSchema,
1874 pub oauth: Option<DriverOAuthConfig>,
1877 pub chat: Option<DriverFactory>,
1879 pub embeddings: Option<EmbeddingsDriverFactory>,
1881}
1882
1883impl DriverDescriptor {
1884 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1889 where
1890 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1891 {
1892 let id = id.into();
1893 Self {
1894 display_name: default_display_name(&id),
1895 credential_schema: default_credential_schema(&id),
1896 services: vec![ServiceKind::Chat],
1897 oauth: None,
1898 chat: Some(Arc::new(factory)),
1899 embeddings: None,
1900 id,
1901 }
1902 }
1903
1904 pub fn supports(&self, service: ServiceKind) -> bool {
1906 self.services.contains(&service)
1907 }
1908}
1909
1910impl std::fmt::Debug for DriverDescriptor {
1911 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1912 f.debug_struct("DriverDescriptor")
1913 .field("id", &self.id)
1914 .field("display_name", &self.display_name)
1915 .field("services", &self.services)
1916 .field("oauth", &self.oauth.is_some())
1917 .field("chat", &self.chat.is_some())
1918 .field("embeddings", &self.embeddings.is_some())
1919 .finish()
1920 }
1921}
1922
1923fn default_display_name(id: &DriverId) -> String {
1924 match id {
1925 DriverId::OpenAI => "OpenAI".to_string(),
1926 DriverId::OpenRouter => "OpenRouter".to_string(),
1927 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1928 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1929 DriverId::Anthropic => "Anthropic".to_string(),
1930 DriverId::Gemini => "Google Gemini".to_string(),
1931 DriverId::Bedrock => "AWS Bedrock".to_string(),
1932 DriverId::Mai => "Microsoft MAI".to_string(),
1933 DriverId::Fireworks => "Fireworks AI".to_string(),
1934 DriverId::LlmSim => "LLM Simulator".to_string(),
1935 DriverId::External(id) => id.to_string(),
1936 }
1937}
1938
1939fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1940 match id {
1941 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1943 _ => CredentialFormSchema::api_key(String::new()),
1944 }
1945}
1946
1947#[derive(Clone, Default)]
1967pub struct DriverRegistry {
1968 descriptors: HashMap<DriverId, DriverDescriptor>,
1969}
1970
1971impl DriverRegistry {
1972 pub fn new() -> Self {
1974 Self {
1975 descriptors: HashMap::new(),
1976 }
1977 }
1978
1979 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1985 if self.descriptors.contains_key(&descriptor.id) {
1986 panic!(
1987 "driver already registered for provider '{}'; \
1988 use register_descriptor_or_replace to overwrite intentionally",
1989 descriptor.id
1990 );
1991 }
1992 self.descriptors.insert(descriptor.id.clone(), descriptor);
1993 }
1994
1995 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1997 self.descriptors.insert(descriptor.id.clone(), descriptor);
1998 }
1999
2000 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2006 where
2007 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2008 {
2009 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
2010 }
2011
2012 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2017 where
2018 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2019 {
2020 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
2021 }
2022
2023 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
2028 where
2029 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2030 {
2031 self.register(DriverId::external(id), factory);
2032 }
2033
2034 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
2043 let requires_api_key = !matches!(
2047 config.provider_type,
2048 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
2049 );
2050 if requires_api_key && config.api_key.is_none() {
2051 return Err(AgentLoopError::llm(
2052 "API key is required. Configure the API key in provider settings.",
2053 ));
2054 }
2055
2056 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2058 AgentLoopError::driver_not_registered(config.provider_type.to_string())
2059 })?;
2060 let factory = descriptor.chat.as_ref().ok_or_else(|| {
2061 AgentLoopError::llm(format!(
2062 "Provider driver '{}' does not implement the chat service.",
2063 config.provider_type
2064 ))
2065 })?;
2066
2067 let driver_config = DriverConfig::from_provider_config(config);
2069 Ok(factory(&driver_config))
2070 }
2071
2072 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2074 self.descriptors.contains_key(provider_type)
2075 }
2076
2077 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2079 self.descriptors.get(provider_type)
2080 }
2081
2082 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2084 self.descriptors
2085 .get(provider_type)
2086 .is_some_and(|d| d.supports(service))
2087 }
2088
2089 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2091 self.descriptors
2092 .values()
2093 .filter(|d| d.supports(service))
2094 .map(|d| d.id.clone())
2095 .collect()
2096 }
2097
2098 pub fn registered_providers(&self) -> Vec<DriverId> {
2100 self.descriptors.keys().cloned().collect()
2101 }
2102
2103 pub fn create_embeddings_driver(
2111 &self,
2112 config: &ProviderConfig,
2113 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2114 let requires_api_key = !matches!(
2115 config.provider_type,
2116 DriverId::LlmSim | DriverId::External(_)
2117 );
2118 if requires_api_key && config.api_key.is_none() {
2119 return Err(EmbeddingsDriverError::Provider(
2120 "API key is required. Configure the API key in provider settings.".to_string(),
2121 ));
2122 }
2123 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2124 EmbeddingsDriverError::Provider(format!(
2125 "No driver registered for provider '{}'",
2126 config.provider_type
2127 ))
2128 })?;
2129 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2130 EmbeddingsDriverError::Provider(format!(
2131 "Provider driver '{}' does not implement the embeddings service.",
2132 config.provider_type
2133 ))
2134 })?;
2135 let driver_config = DriverConfig::from_provider_config(config);
2136 Ok(factory(&driver_config))
2137 }
2138}
2139
2140const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2145
2146const TRUNCATION_SUFFIX: &str =
2147 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2148
2149fn truncate_tool_result(text: String) -> String {
2150 if text.len() <= MAX_TOOL_RESULT_BYTES {
2151 return text;
2152 }
2153 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2154 let mut end = content_budget;
2155 while end > 0 && !text.is_char_boundary(end) {
2156 end -= 1;
2157 }
2158 let mut truncated = text[..end].to_string();
2159 truncated.push_str(TRUNCATION_SUFFIX);
2160 truncated
2161}
2162
2163#[cfg(test)]
2168mod tests {
2169 use super::*;
2170
2171 #[test]
2172 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2173 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2176 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2178 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2179 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2181 }
2182
2183 #[test]
2184 fn test_resolved_parallel_tool_calls_gating() {
2185 let mut config = LlmCallConfig::from(&RuntimeAgent::new("p", "gpt-5.2"));
2186
2187 assert_eq!(config.resolved_parallel_tool_calls(true), None);
2189 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2190
2191 config.parallel_tool_calls = Some(true);
2193 assert_eq!(config.resolved_parallel_tool_calls(true), Some(true));
2194 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2195
2196 config.parallel_tool_calls = Some(false);
2197 assert_eq!(config.resolved_parallel_tool_calls(true), Some(false));
2198 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2199 }
2200
2201 #[test]
2202 fn test_chat_driver_default_omits_parallel_tool_calls() {
2203 struct DefaultDriver;
2205 #[async_trait]
2206 impl ChatDriver for DefaultDriver {
2207 async fn chat_completion_stream(
2208 &self,
2209 _messages: Vec<LlmMessage>,
2210 _config: &LlmCallConfig,
2211 ) -> Result<LlmResponseStream> {
2212 unreachable!()
2213 }
2214 }
2215 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2216 }
2217
2218 #[test]
2219 fn test_fold_system_messages_none_when_absent() {
2220 let messages = vec![
2221 LlmMessage::text(LlmMessageRole::User, "hi"),
2222 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2223 ];
2224 assert_eq!(fold_system_messages(&messages), None);
2225 }
2226
2227 #[test]
2228 fn test_fold_system_messages_single() {
2229 let messages = vec![
2230 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2231 LlmMessage::text(LlmMessageRole::User, "hi"),
2232 ];
2233 assert_eq!(
2234 fold_system_messages(&messages),
2235 Some("AGENT-PROMPT".to_string())
2236 );
2237 }
2238
2239 #[test]
2240 fn test_fold_system_messages_accumulates_in_order() {
2241 let messages = vec![
2245 LlmMessage::text(LlmMessageRole::System, "A"),
2246 LlmMessage::text(LlmMessageRole::User, "hi"),
2247 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2248 LlmMessage::text(LlmMessageRole::System, "B"),
2249 ];
2250 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2251 }
2252
2253 #[test]
2254 fn test_fold_system_messages_concatenates_parts() {
2255 let messages = vec![LlmMessage::parts(
2256 LlmMessageRole::System,
2257 vec![
2258 LlmContentPart::text("foo"),
2259 LlmContentPart::image("data:image/png;base64,xxx"),
2260 LlmContentPart::text("bar"),
2261 ],
2262 )];
2263 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2264 }
2265
2266 #[test]
2267 fn test_llm_call_config_builder_from_runtime_agent() {
2268 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2269 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2270
2271 assert_eq!(llm_config.model, "gpt-4o");
2272 assert!(llm_config.reasoning_effort.is_none());
2273 assert!(llm_config.temperature.is_none());
2274 assert!(llm_config.max_tokens.is_none());
2275 assert!(llm_config.tools.is_empty());
2276 assert!(llm_config.metadata.is_empty());
2277 assert!(llm_config.openrouter_routing.is_none());
2279 }
2280
2281 #[test]
2282 fn runtime_agent_openrouter_routing_flows_into_call_config() {
2283 let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2287 runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2288 server_tools: vec![OpenRouterServerTool::new(
2289 OpenRouterServerToolKind::WebSearch,
2290 )],
2291 ..Default::default()
2292 });
2293
2294 let llm_config = LlmCallConfig::from(&runtime_agent);
2295 let routing = llm_config
2296 .openrouter_routing
2297 .expect("server-tool routing survives into the call config");
2298 assert_eq!(routing.server_tools.len(), 1);
2299 assert_eq!(
2300 routing.server_tools[0].kind.wire_type(),
2301 "openrouter:web_search"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_llm_call_config_builder_with_metadata() {
2307 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2308 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2309 .with_metadata("session_id", "session_abc123")
2310 .with_metadata("agent_id", "agent_xyz789")
2311 .build();
2312
2313 assert_eq!(
2314 llm_config.metadata.get("session_id"),
2315 Some(&"session_abc123".to_string())
2316 );
2317 assert_eq!(
2318 llm_config.metadata.get("agent_id"),
2319 Some(&"agent_xyz789".to_string())
2320 );
2321 }
2322
2323 #[test]
2324 fn test_llm_call_config_builder_with_metadata_hashmap() {
2325 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2326 let mut metadata = HashMap::new();
2327 metadata.insert("key1".to_string(), "value1".to_string());
2328 metadata.insert("key2".to_string(), "value2".to_string());
2329
2330 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2331 .metadata(metadata)
2332 .build();
2333
2334 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2335 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2336 }
2337
2338 #[test]
2339 fn test_llm_call_config_builder_with_reasoning_effort() {
2340 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2341 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2342 .reasoning_effort("high")
2343 .build();
2344
2345 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2346 }
2347
2348 #[test]
2349 fn test_llm_call_config_builder_with_all_options() {
2350 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2351 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2352 .model("claude-3-opus")
2353 .reasoning_effort("medium")
2354 .temperature(0.7)
2355 .max_tokens(1000)
2356 .build();
2357
2358 assert_eq!(llm_config.model, "claude-3-opus");
2359 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2360 assert_eq!(llm_config.temperature, Some(0.7));
2361 assert_eq!(llm_config.max_tokens, Some(1000));
2362 }
2363
2364 #[test]
2365 fn test_llm_call_config_builder_with_openrouter_routing() {
2366 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2367 let routing = OpenRouterRoutingConfig::fallback_models([
2368 "openai/gpt-5-mini",
2369 "anthropic/claude-sonnet-4.5",
2370 ]);
2371
2372 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2373 .openrouter_routing(routing.clone())
2374 .build();
2375
2376 assert_eq!(llm_config.openrouter_routing, Some(routing));
2377 }
2378
2379 #[test]
2380 fn test_openrouter_fallback_models_empty_is_empty() {
2381 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2382
2383 assert!(routing.is_empty());
2384 assert_eq!(routing.route, None);
2385 }
2386
2387 #[test]
2388 fn test_openrouter_routing_validates_primary_model() {
2389 let routing = OpenRouterRoutingConfig::fallback_models([
2390 "openai/gpt-5-mini",
2391 "anthropic/claude-sonnet-4.5",
2392 ]);
2393
2394 assert!(
2395 routing
2396 .validate_for_primary_model("openai/gpt-5-mini")
2397 .is_ok()
2398 );
2399 let err = routing
2400 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2401 .unwrap_err();
2402 assert!(err.contains("models[0]"));
2403 }
2404
2405 #[test]
2406 fn test_openrouter_routing_rejects_fallback_without_models() {
2407 let routing = OpenRouterRoutingConfig {
2408 route: Some(OpenRouterRoute::Fallback),
2409 ..Default::default()
2410 };
2411
2412 let err = routing
2413 .validate_for_primary_model("openai/gpt-5-mini")
2414 .unwrap_err();
2415 assert!(err.contains("requires at least one model"));
2416 }
2417
2418 #[test]
2419 fn test_openrouter_routing_serializes_request_fields() {
2420 let routing = OpenRouterRoutingConfig {
2421 models: vec![
2422 "openai/gpt-5-mini".to_string(),
2423 "anthropic/claude-sonnet-4.5".to_string(),
2424 ],
2425 route: Some(OpenRouterRoute::Fallback),
2426 provider: Some(OpenRouterProviderRouting {
2427 order: vec!["anthropic".to_string(), "openai".to_string()],
2428 allow_fallbacks: Some(false),
2429 require_parameters: Some(true),
2430 data_collection: Some(OpenRouterDataCollection::Deny),
2431 zdr: Some(true),
2432 sort: Some(OpenRouterProviderSort::Advanced(
2433 OpenRouterProviderSortOptions {
2434 by: OpenRouterProviderSortBy::Throughput,
2435 partition: Some(OpenRouterSortPartition::None),
2436 },
2437 )),
2438 max_price: Some(OpenRouterMaxPrice {
2439 prompt: Some(1.0),
2440 completion: Some(2.0),
2441 ..Default::default()
2442 }),
2443 ..Default::default()
2444 }),
2445 ..Default::default()
2446 };
2447
2448 let json = serde_json::to_value(routing).unwrap();
2449
2450 assert_eq!(
2451 json,
2452 serde_json::json!({
2453 "models": [
2454 "openai/gpt-5-mini",
2455 "anthropic/claude-sonnet-4.5"
2456 ],
2457 "route": "fallback",
2458 "provider": {
2459 "order": ["anthropic", "openai"],
2460 "allow_fallbacks": false,
2461 "require_parameters": true,
2462 "data_collection": "deny",
2463 "zdr": true,
2464 "sort": {
2465 "by": "throughput",
2466 "partition": "none"
2467 },
2468 "max_price": {
2469 "prompt": 1.0,
2470 "completion": 2.0
2471 }
2472 }
2473 })
2474 );
2475 }
2476
2477 #[test]
2478 fn test_provider_type_parsing() {
2479 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2480 assert_eq!(
2481 "openrouter".parse::<DriverId>().unwrap(),
2482 DriverId::OpenRouter
2483 );
2484 assert_eq!(
2485 "openai_completions".parse::<DriverId>().unwrap(),
2486 DriverId::OpenAICompletions
2487 );
2488 assert_eq!(
2489 "azure_openai".parse::<DriverId>().unwrap(),
2490 DriverId::AzureOpenAI
2491 );
2492 assert_eq!(
2493 "anthropic".parse::<DriverId>().unwrap(),
2494 DriverId::Anthropic
2495 );
2496 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2497 assert_eq!(
2499 "ollama".parse::<DriverId>().unwrap(),
2500 DriverId::external("ollama")
2501 );
2502 assert_eq!(
2503 "custom".parse::<DriverId>().unwrap(),
2504 DriverId::external("custom")
2505 );
2506 }
2507
2508 #[test]
2509 fn test_external_provider_id_is_case_insensitive() {
2510 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2513 assert_eq!(
2514 "Ollama".parse::<DriverId>().unwrap(),
2515 "ollama".parse::<DriverId>().unwrap()
2516 );
2517 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2518 assert_eq!(
2520 DriverId::external("MyProvider"),
2521 "myprovider".parse::<DriverId>().unwrap()
2522 );
2523 }
2524
2525 #[test]
2526 fn test_provider_type_display() {
2527 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2528 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2529 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2530 assert_eq!(
2531 DriverId::OpenAICompletions.to_string(),
2532 "openai_completions"
2533 );
2534 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2535 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2536 }
2537
2538 #[test]
2539 fn test_provider_config_builder() {
2540 let config = ProviderConfig::new(DriverId::Anthropic)
2541 .with_api_key("test-key")
2542 .with_base_url("https://custom.api.com");
2543
2544 assert_eq!(config.provider_type, DriverId::Anthropic);
2545 assert_eq!(config.api_key, Some("test-key".to_string()));
2546 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2547 }
2548
2549 #[test]
2550 fn test_driver_registry_requires_api_key() {
2551 let mut registry = DriverRegistry::new();
2553 registry.register(DriverId::OpenAI, |_config| {
2554 struct MockDriver;
2556 #[async_trait]
2557 impl ChatDriver for MockDriver {
2558 async fn chat_completion_stream(
2559 &self,
2560 _messages: Vec<LlmMessage>,
2561 _config: &LlmCallConfig,
2562 ) -> Result<LlmResponseStream> {
2563 unimplemented!()
2564 }
2565 }
2566 Box::new(MockDriver)
2567 });
2568
2569 let config = ProviderConfig::new(DriverId::OpenAI);
2571 let result = registry.create_chat_driver(&config);
2572 assert!(result.is_err());
2573
2574 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2576 let result = registry.create_chat_driver(&config_with_key);
2577 assert!(result.is_ok());
2578 }
2579
2580 #[test]
2581 fn test_driver_registry_returns_error_for_unregistered_provider() {
2582 let registry = DriverRegistry::new();
2583 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2584
2585 let result = registry.create_chat_driver(&config);
2586
2587 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2589 assert_eq!(provider, "anthropic");
2590 } else {
2591 panic!("Expected DriverNotRegistered error");
2592 }
2593 }
2594
2595 #[test]
2596 fn test_driver_registry_registration() {
2597 let mut registry = DriverRegistry::new();
2598
2599 assert!(!registry.has_driver(&DriverId::OpenAI));
2600 assert!(!registry.has_driver(&DriverId::Anthropic));
2601
2602 registry.register(DriverId::OpenAI, |_config| {
2603 struct MockDriver;
2604 #[async_trait]
2605 impl ChatDriver for MockDriver {
2606 async fn chat_completion_stream(
2607 &self,
2608 _messages: Vec<LlmMessage>,
2609 _config: &LlmCallConfig,
2610 ) -> Result<LlmResponseStream> {
2611 unimplemented!()
2612 }
2613 }
2614 Box::new(MockDriver)
2615 });
2616
2617 assert!(registry.has_driver(&DriverId::OpenAI));
2618 assert!(!registry.has_driver(&DriverId::Anthropic));
2619 }
2620
2621 #[test]
2622 fn test_register_external_and_create_driver_without_api_key() {
2623 struct MockDriver;
2624 #[async_trait]
2625 impl ChatDriver for MockDriver {
2626 async fn chat_completion_stream(
2627 &self,
2628 _messages: Vec<LlmMessage>,
2629 _config: &LlmCallConfig,
2630 ) -> Result<LlmResponseStream> {
2631 unimplemented!()
2632 }
2633 }
2634
2635 let mut registry = DriverRegistry::new();
2636 registry.register_external("openai-codex", |config| {
2637 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2639 Box::new(MockDriver)
2640 });
2641
2642 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2643
2644 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2646 ProviderMetadata {
2647 refresh_token: Some("rt".into()),
2648 ..Default::default()
2649 },
2650 );
2651 assert!(registry.create_chat_driver(&config).is_ok());
2652 }
2653
2654 #[test]
2655 fn test_register_defaults_to_chat_only_descriptor() {
2656 struct MockDriver;
2657 #[async_trait]
2658 impl ChatDriver for MockDriver {
2659 async fn chat_completion_stream(
2660 &self,
2661 _messages: Vec<LlmMessage>,
2662 _config: &LlmCallConfig,
2663 ) -> Result<LlmResponseStream> {
2664 unimplemented!()
2665 }
2666 }
2667
2668 let mut registry = DriverRegistry::new();
2669 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2670
2671 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2672 assert_eq!(descriptor.display_name, "Anthropic");
2673 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2674 assert!(descriptor.chat.is_some());
2675 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2677 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2678 assert!(descriptor.credential_schema.fields[0].required);
2679
2680 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2682 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2683 assert!(sim.credential_schema.fields.is_empty());
2684 }
2685
2686 #[test]
2687 fn test_descriptor_services_and_lookup() {
2688 struct MockDriver;
2689 #[async_trait]
2690 impl ChatDriver for MockDriver {
2691 async fn chat_completion_stream(
2692 &self,
2693 _messages: Vec<LlmMessage>,
2694 _config: &LlmCallConfig,
2695 ) -> Result<LlmResponseStream> {
2696 unimplemented!()
2697 }
2698 }
2699
2700 let mut registry = DriverRegistry::new();
2701 registry.register_descriptor(DriverDescriptor {
2702 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2703 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2704 });
2705 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2706
2707 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2708 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2709 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2710 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2711
2712 let realtime = registry.providers_for(ServiceKind::Realtime);
2713 assert_eq!(realtime, vec![DriverId::OpenAI]);
2714 let mut chat = registry.providers_for(ServiceKind::Chat);
2715 chat.sort_by_key(|p| p.to_string());
2716 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2717 }
2718
2719 #[test]
2720 fn test_create_chat_driver_fails_without_chat_factory() {
2721 let mut registry = DriverRegistry::new();
2722 registry.register_descriptor(DriverDescriptor {
2723 id: DriverId::external("embeddings-only"),
2724 display_name: "Embeddings Only".to_string(),
2725 services: vec![ServiceKind::Embeddings],
2726 credential_schema: CredentialFormSchema::empty(),
2727 oauth: None,
2728 chat: None,
2729 embeddings: None,
2730 });
2731
2732 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2733 let err = match registry.create_chat_driver(&config) {
2734 Ok(_) => panic!("expected error for missing chat factory"),
2735 Err(err) => err,
2736 };
2737 assert!(
2738 err.to_string()
2739 .contains("does not implement the chat service"),
2740 "unexpected error: {err}"
2741 );
2742 }
2743
2744 #[test]
2745 #[should_panic(expected = "already registered")]
2746 fn test_register_duplicate_panics() {
2747 struct MockDriver;
2748 #[async_trait]
2749 impl ChatDriver for MockDriver {
2750 async fn chat_completion_stream(
2751 &self,
2752 _messages: Vec<LlmMessage>,
2753 _config: &LlmCallConfig,
2754 ) -> Result<LlmResponseStream> {
2755 unimplemented!()
2756 }
2757 }
2758
2759 let mut registry = DriverRegistry::new();
2760 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2761 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2763 }
2764
2765 #[test]
2766 fn test_register_or_replace_overwrites() {
2767 struct MockDriver;
2768 #[async_trait]
2769 impl ChatDriver for MockDriver {
2770 async fn chat_completion_stream(
2771 &self,
2772 _messages: Vec<LlmMessage>,
2773 _config: &LlmCallConfig,
2774 ) -> Result<LlmResponseStream> {
2775 unimplemented!()
2776 }
2777 }
2778
2779 let mut registry = DriverRegistry::new();
2780 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2781 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2783 assert!(registry.has_driver(&DriverId::LlmSim));
2784 }
2785
2786 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2791
2792 #[test]
2793 fn test_message_has_image_files_with_image_file() {
2794 let message = Message {
2795 id: uuid::Uuid::new_v4().into(),
2796 role: MessageRole::User,
2797 content: vec![
2798 ContentPart::Text(TextContentPart {
2799 text: "Look at this image".to_string(),
2800 }),
2801 ContentPart::ImageFile(ImageFileContentPart {
2802 image_id: uuid::Uuid::new_v4().into(),
2803 filename: Some("test.png".to_string()),
2804 }),
2805 ],
2806 phase: None,
2807 thinking: None,
2808 thinking_signature: None,
2809 controls: None,
2810 metadata: None,
2811 external_actor: None,
2812 created_at: chrono::Utc::now(),
2813 };
2814
2815 assert!(LlmMessage::message_has_image_files(&message));
2816 }
2817
2818 #[test]
2819 fn test_message_has_image_files_without_image_file() {
2820 let message = Message {
2821 id: uuid::Uuid::new_v4().into(),
2822 role: MessageRole::User,
2823 content: vec![ContentPart::Text(TextContentPart {
2824 text: "Just text".to_string(),
2825 })],
2826 phase: None,
2827 thinking: None,
2828 thinking_signature: None,
2829 controls: None,
2830 metadata: None,
2831 external_actor: None,
2832 created_at: chrono::Utc::now(),
2833 };
2834
2835 assert!(!LlmMessage::message_has_image_files(&message));
2836 }
2837
2838 #[test]
2839 fn test_extract_image_file_ids() {
2840 let id1 = uuid::Uuid::new_v4();
2841 let id2 = uuid::Uuid::new_v4();
2842
2843 let message = Message {
2844 id: uuid::Uuid::new_v4().into(),
2845 role: MessageRole::User,
2846 content: vec![
2847 ContentPart::Text(TextContentPart {
2848 text: "Look at these images".to_string(),
2849 }),
2850 ContentPart::ImageFile(ImageFileContentPart {
2851 image_id: id1.into(),
2852 filename: Some("test1.png".to_string()),
2853 }),
2854 ContentPart::ImageFile(ImageFileContentPart {
2855 image_id: id2.into(),
2856 filename: Some("test2.png".to_string()),
2857 }),
2858 ],
2859 phase: None,
2860 thinking: None,
2861 thinking_signature: None,
2862 controls: None,
2863 metadata: None,
2864 external_actor: None,
2865 created_at: chrono::Utc::now(),
2866 };
2867
2868 let ids = LlmMessage::extract_image_file_ids(&message);
2869 assert_eq!(ids.len(), 2);
2870 assert!(ids.contains(&id1));
2871 assert!(ids.contains(&id2));
2872 }
2873
2874 #[test]
2875 fn test_from_message_with_images_text_only() {
2876 let message = Message {
2877 id: uuid::Uuid::new_v4().into(),
2878 role: MessageRole::User,
2879 content: vec![ContentPart::Text(TextContentPart {
2880 text: "Hello".to_string(),
2881 })],
2882 phase: None,
2883 thinking: None,
2884 thinking_signature: None,
2885 controls: None,
2886 metadata: None,
2887 external_actor: None,
2888 created_at: chrono::Utc::now(),
2889 };
2890
2891 let resolved = std::collections::HashMap::new();
2892 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2893
2894 assert_eq!(llm_message.role, LlmMessageRole::User);
2895 match llm_message.content {
2896 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2897 _ => panic!("Expected text content"),
2898 }
2899 }
2900
2901 #[test]
2902 fn test_from_message_with_images_resolved_image() {
2903 let image_id = uuid::Uuid::new_v4();
2904 let message = Message {
2905 id: uuid::Uuid::new_v4().into(),
2906 role: MessageRole::User,
2907 content: vec![
2908 ContentPart::Text(TextContentPart {
2909 text: "Look at this".to_string(),
2910 }),
2911 ContentPart::ImageFile(ImageFileContentPart {
2912 image_id: image_id.into(),
2913 filename: Some("test.png".to_string()),
2914 }),
2915 ],
2916 phase: None,
2917 thinking: None,
2918 thinking_signature: None,
2919 controls: None,
2920 metadata: None,
2921 external_actor: None,
2922 created_at: chrono::Utc::now(),
2923 };
2924
2925 let mut resolved = std::collections::HashMap::new();
2926 resolved.insert(
2927 image_id,
2928 crate::ResolvedImage::new("base64data", "image/png"),
2929 );
2930
2931 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2932
2933 match &llm_message.content {
2934 LlmMessageContent::Parts(parts) => {
2935 assert_eq!(parts.len(), 2);
2936 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2938 if let LlmContentPart::Image { url } = &parts[1] {
2940 assert!(url.starts_with("data:image/png;base64,"));
2941 } else {
2942 panic!("Expected image content part");
2943 }
2944 }
2945 _ => panic!("Expected parts content"),
2946 }
2947 }
2948
2949 #[test]
2950 fn test_from_message_with_images_unresolved_image() {
2951 let image_id = uuid::Uuid::new_v4();
2952 let message = Message {
2953 id: uuid::Uuid::new_v4().into(),
2954 role: MessageRole::User,
2955 content: vec![ContentPart::ImageFile(ImageFileContentPart {
2956 image_id: image_id.into(),
2957 filename: Some("missing.png".to_string()),
2958 })],
2959 phase: None,
2960 thinking: None,
2961 thinking_signature: None,
2962 controls: None,
2963 metadata: None,
2964 external_actor: None,
2965 created_at: chrono::Utc::now(),
2966 };
2967
2968 let resolved = std::collections::HashMap::new();
2970 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2971
2972 match &llm_message.content {
2975 LlmMessageContent::Text(text) => {
2976 assert!(text.contains("Image not found"));
2977 }
2978 LlmMessageContent::Parts(parts) => {
2979 assert_eq!(parts.len(), 1);
2980 if let LlmContentPart::Text { text } = &parts[0] {
2981 assert!(text.contains("Image not found"));
2982 } else {
2983 panic!("Expected text placeholder for missing image");
2984 }
2985 }
2986 }
2987 }
2988
2989 #[test]
2990 fn test_prepend_text_prefix_simple_text() {
2991 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2992 msg.prepend_text_prefix("[Alice] ");
2993 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2994 }
2995
2996 #[test]
2997 fn test_prepend_text_prefix_parts() {
2998 let mut msg = LlmMessage::parts(
2999 LlmMessageRole::User,
3000 vec![
3001 LlmContentPart::Text {
3002 text: "Hello".to_string(),
3003 },
3004 LlmContentPart::Image {
3005 url: "data:image/png;base64,abc".to_string(),
3006 },
3007 ],
3008 );
3009 msg.prepend_text_prefix("[Bob] ");
3010 match &msg.content {
3011 LlmMessageContent::Parts(parts) => {
3012 if let LlmContentPart::Text { text } = &parts[0] {
3013 assert_eq!(text, "[Bob] Hello");
3014 } else {
3015 panic!("Expected text part");
3016 }
3017 }
3018 _ => panic!("Expected parts content"),
3019 }
3020 }
3021
3022 #[test]
3023 fn test_prepend_text_prefix_parts_no_text() {
3024 let mut msg = LlmMessage::parts(
3025 LlmMessageRole::User,
3026 vec![LlmContentPart::Image {
3027 url: "data:image/png;base64,abc".to_string(),
3028 }],
3029 );
3030 msg.prepend_text_prefix("[Eve] ");
3031 match &msg.content {
3032 LlmMessageContent::Parts(parts) => {
3033 assert_eq!(parts.len(), 2);
3034 if let LlmContentPart::Text { text } = &parts[0] {
3035 assert_eq!(text, "[Eve] ");
3036 } else {
3037 panic!("Expected prepended text part");
3038 }
3039 }
3040 _ => panic!("Expected parts content"),
3041 }
3042 }
3043
3044 #[test]
3045 fn test_openrouter_plugin_config_is_empty() {
3046 assert!(OpenRouterPluginConfig::default().is_empty());
3047 assert!(
3048 !OpenRouterPluginConfig {
3049 web: Some(OpenRouterWebSearchPlugin::default()),
3050 file: None,
3051 }
3052 .is_empty()
3053 );
3054 assert!(
3055 !OpenRouterPluginConfig {
3056 web: None,
3057 file: Some(OpenRouterFilePlugin {}),
3058 }
3059 .is_empty()
3060 );
3061 }
3062
3063 #[test]
3064 fn test_openrouter_routing_is_empty_with_plugins() {
3065 let with_plugins = OpenRouterRoutingConfig {
3066 plugins: Some(OpenRouterPluginConfig {
3067 web: Some(OpenRouterWebSearchPlugin::default()),
3068 file: None,
3069 }),
3070 ..Default::default()
3071 };
3072 assert!(!with_plugins.is_empty());
3073
3074 let empty_plugins = OpenRouterRoutingConfig {
3075 plugins: Some(OpenRouterPluginConfig::default()),
3076 ..Default::default()
3077 };
3078 assert!(empty_plugins.is_empty());
3079 }
3080
3081 #[test]
3082 fn test_openrouter_web_search_plugin_serialization() {
3083 let plugin = OpenRouterWebSearchPlugin {
3084 max_results: Some(10),
3085 search_prompt: Some("search for Rust crates".to_string()),
3086 };
3087 let json = serde_json::to_value(&plugin).unwrap();
3088 assert_eq!(json["max_results"], 10);
3089 assert_eq!(json["search_prompt"], "search for Rust crates");
3090 }
3091
3092 #[test]
3093 fn test_openrouter_web_search_plugin_omits_none_fields() {
3094 let plugin = OpenRouterWebSearchPlugin::default();
3095 let json = serde_json::to_value(&plugin).unwrap();
3096 assert!(json.get("max_results").is_none());
3097 assert!(json.get("search_prompt").is_none());
3098 }
3099
3100 #[test]
3101 fn test_capacity_strategy_shared_capacity_is_noop() {
3102 let base = OpenRouterRoutingConfig {
3103 models: vec!["openai/gpt-5-mini".to_string()],
3104 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3105 ..Default::default()
3106 };
3107 let result = base.apply_capacity_strategy().unwrap();
3108 assert_eq!(
3109 result.capacity_strategy,
3110 Some(OpenRouterCapacityStrategy::SharedCapacity)
3111 );
3112 assert!(result.provider.is_none());
3113 }
3114
3115 #[test]
3116 fn test_capacity_strategy_none_is_noop() {
3117 let base = OpenRouterRoutingConfig {
3118 models: vec!["openai/gpt-5-mini".to_string()],
3119 capacity_strategy: None,
3120 ..Default::default()
3121 };
3122 let result = base.apply_capacity_strategy().unwrap();
3123 assert!(result.provider.is_none());
3124 }
3125
3126 #[test]
3127 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
3128 let base = OpenRouterRoutingConfig {
3129 models: vec!["openai/gpt-5-mini".to_string()],
3130 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3131 ..Default::default()
3132 };
3133 let result = base.apply_capacity_strategy().unwrap();
3134 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
3135 assert_eq!(provider.allow_fallbacks, Some(true));
3136 }
3137
3138 #[test]
3139 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
3140 let base = OpenRouterRoutingConfig {
3142 models: vec!["openai/gpt-5-mini".to_string()],
3143 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3144 provider: Some(OpenRouterProviderRouting {
3145 allow_fallbacks: Some(false),
3146 ..Default::default()
3147 }),
3148 ..Default::default()
3149 };
3150 let result = base.apply_capacity_strategy().unwrap();
3151 let provider = result.provider.as_ref().unwrap();
3152 assert_eq!(provider.allow_fallbacks, Some(false));
3153 }
3154
3155 #[test]
3156 fn test_capacity_strategy_byok_only_requires_provider_only() {
3157 let base = OpenRouterRoutingConfig {
3158 models: vec!["openai/gpt-5-mini".to_string()],
3159 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3160 ..Default::default()
3161 };
3162 let err = base.apply_capacity_strategy().unwrap_err();
3163 assert!(
3164 err.contains("provider.only"),
3165 "error should mention provider.only: {err}"
3166 );
3167 }
3168
3169 #[test]
3170 fn test_capacity_strategy_byok_only_disables_fallbacks() {
3171 let base = OpenRouterRoutingConfig {
3172 models: vec!["openai/gpt-5-mini".to_string()],
3173 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3174 provider: Some(OpenRouterProviderRouting {
3175 only: vec!["my-byok-provider".to_string()],
3176 ..Default::default()
3177 }),
3178 ..Default::default()
3179 };
3180 let result = base.apply_capacity_strategy().unwrap();
3181 let provider = result.provider.as_ref().unwrap();
3182 assert_eq!(provider.allow_fallbacks, Some(false));
3183 assert_eq!(provider.only, vec!["my-byok-provider"]);
3184 }
3185
3186 #[test]
3187 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3188 let with_strategy = OpenRouterRoutingConfig {
3189 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3190 ..Default::default()
3191 };
3192 assert!(!with_strategy.is_empty());
3193
3194 let byok_first = OpenRouterRoutingConfig {
3195 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3196 ..Default::default()
3197 };
3198 assert!(!byok_first.is_empty());
3199
3200 let shared = OpenRouterRoutingConfig {
3201 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3202 ..Default::default()
3203 };
3204 assert!(shared.is_empty());
3205 }
3206
3207 #[test]
3212 fn test_preset_no_presets_is_noop() {
3213 let base = OpenRouterRoutingConfig {
3214 models: vec!["openai/gpt-5-mini".to_string()],
3215 ..Default::default()
3216 };
3217 let result = base.apply_presets().unwrap();
3218 assert_eq!(result, base);
3219 }
3220
3221 #[test]
3222 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3223 let base = OpenRouterRoutingConfig {
3224 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3225 ..Default::default()
3226 };
3227 let result = base.apply_presets().unwrap();
3228 assert!(result.presets.is_empty(), "presets cleared after apply");
3229 let provider = result.provider.expect("provider set by preset");
3230 assert_eq!(provider.require_parameters, Some(true));
3231 assert_eq!(
3232 provider.sort,
3233 Some(OpenRouterProviderSort::Simple(
3234 OpenRouterProviderSortBy::Price
3235 ))
3236 );
3237 }
3238
3239 #[test]
3240 fn test_preset_lowest_latency_review_sets_sort_throughput() {
3241 let base = OpenRouterRoutingConfig {
3242 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3243 ..Default::default()
3244 };
3245 let result = base.apply_presets().unwrap();
3246 let provider = result.provider.expect("provider set by preset");
3247 assert_eq!(
3248 provider.sort,
3249 Some(OpenRouterProviderSort::Simple(
3250 OpenRouterProviderSortBy::Throughput
3251 ))
3252 );
3253 }
3254
3255 #[test]
3256 fn test_preset_zdr_only_sets_zdr() {
3257 let base = OpenRouterRoutingConfig {
3258 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3259 ..Default::default()
3260 };
3261 let result = base.apply_presets().unwrap();
3262 let provider = result.provider.expect("provider set");
3263 assert_eq!(provider.zdr, Some(true));
3264 }
3265
3266 #[test]
3267 fn test_preset_byok_first_sets_allow_fallbacks() {
3268 let base = OpenRouterRoutingConfig {
3269 presets: vec![OpenRouterRoutingPreset::ByokFirst],
3270 ..Default::default()
3271 };
3272 let result = base.apply_presets().unwrap();
3273 let provider = result.provider.expect("provider set");
3274 assert_eq!(provider.allow_fallbacks, Some(true));
3275 }
3276
3277 #[test]
3278 fn test_preset_no_data_collection_sets_data_collection_deny() {
3279 let base = OpenRouterRoutingConfig {
3280 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3281 ..Default::default()
3282 };
3283 let result = base.apply_presets().unwrap();
3284 let provider = result.provider.expect("provider set");
3285 assert_eq!(
3286 provider.data_collection,
3287 Some(OpenRouterDataCollection::Deny)
3288 );
3289 }
3290
3291 #[test]
3292 fn test_preset_strict_json_sets_require_parameters() {
3293 let base = OpenRouterRoutingConfig {
3294 presets: vec![OpenRouterRoutingPreset::StrictJson],
3295 ..Default::default()
3296 };
3297 let result = base.apply_presets().unwrap();
3298 let provider = result.provider.expect("provider set");
3299 assert_eq!(provider.require_parameters, Some(true));
3300 }
3301
3302 #[test]
3303 fn test_preset_reasoning_required_sets_require_parameters() {
3304 let base = OpenRouterRoutingConfig {
3305 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3306 ..Default::default()
3307 };
3308 let result = base.apply_presets().unwrap();
3309 let provider = result.provider.expect("provider set");
3310 assert_eq!(provider.require_parameters, Some(true));
3311 }
3312
3313 #[test]
3314 fn test_preset_max_price_converts_usd_per_million() {
3315 let base = OpenRouterRoutingConfig {
3316 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3317 prompt_usd_per_million: Some(5.0),
3318 completion_usd_per_million: Some(15.0),
3319 }],
3320 ..Default::default()
3321 };
3322 let result = base.apply_presets().unwrap();
3323 let provider = result.provider.expect("provider set");
3324 let max_price = provider.max_price.expect("max_price set");
3325 let prompt = max_price.prompt.expect("prompt set");
3327 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3328 let completion = max_price.completion.expect("completion set");
3329 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3330 }
3331
3332 #[test]
3333 fn test_preset_max_price_rejects_negative_values() {
3334 let base = OpenRouterRoutingConfig {
3335 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3336 prompt_usd_per_million: Some(-1.0),
3337 completion_usd_per_million: None,
3338 }],
3339 ..Default::default()
3340 };
3341 let err = base.apply_presets().unwrap_err();
3342 assert!(
3343 err.contains("non-negative"),
3344 "error should mention non-negative: {err}"
3345 );
3346 }
3347
3348 #[test]
3349 fn test_preset_max_price_both_none_no_provider_field() {
3350 let base = OpenRouterRoutingConfig {
3351 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3352 prompt_usd_per_million: None,
3353 completion_usd_per_million: None,
3354 }],
3355 ..Default::default()
3356 };
3357 let result = base.apply_presets().unwrap();
3358 assert!(
3359 result.provider.is_none(),
3360 "MaxPrice with no dimensions should not produce a provider field"
3361 );
3362 }
3363
3364 #[test]
3365 fn test_preset_explicit_provider_overrides_preset() {
3366 let base = OpenRouterRoutingConfig {
3367 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3368 provider: Some(OpenRouterProviderRouting {
3369 sort: Some(OpenRouterProviderSort::Simple(
3371 OpenRouterProviderSortBy::Throughput,
3372 )),
3373 ..Default::default()
3374 }),
3375 ..Default::default()
3376 };
3377 let result = base.apply_presets().unwrap();
3378 let provider = result.provider.expect("provider set");
3379 assert_eq!(
3381 provider.sort,
3382 Some(OpenRouterProviderSort::Simple(
3383 OpenRouterProviderSortBy::Throughput
3384 ))
3385 );
3386 assert_eq!(provider.require_parameters, Some(true));
3388 }
3389
3390 #[test]
3391 fn test_preset_multiple_presets_combined() {
3392 let base = OpenRouterRoutingConfig {
3393 presets: vec![
3394 OpenRouterRoutingPreset::ZdrOnly,
3395 OpenRouterRoutingPreset::NoDataCollection,
3396 OpenRouterRoutingPreset::LowestLatencyReview,
3397 ],
3398 ..Default::default()
3399 };
3400 let result = base.apply_presets().unwrap();
3401 let provider = result.provider.expect("provider set");
3402 assert_eq!(provider.zdr, Some(true));
3403 assert_eq!(
3404 provider.data_collection,
3405 Some(OpenRouterDataCollection::Deny)
3406 );
3407 assert_eq!(
3408 provider.sort,
3409 Some(OpenRouterProviderSort::Simple(
3410 OpenRouterProviderSortBy::Throughput
3411 ))
3412 );
3413 }
3414
3415 #[test]
3416 fn test_preset_later_preset_overrides_sort() {
3417 let base = OpenRouterRoutingConfig {
3418 presets: vec![
3419 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3422 ..Default::default()
3423 };
3424 let result = base.apply_presets().unwrap();
3425 let provider = result.provider.expect("provider set");
3426 assert_eq!(
3428 provider.sort,
3429 Some(OpenRouterProviderSort::Simple(
3430 OpenRouterProviderSortBy::Throughput
3431 ))
3432 );
3433 assert_eq!(provider.require_parameters, Some(true));
3435 }
3436
3437 #[test]
3438 fn test_preset_non_empty_in_is_empty() {
3439 let with_preset = OpenRouterRoutingConfig {
3440 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3441 ..Default::default()
3442 };
3443 assert!(!with_preset.is_empty());
3444
3445 let without = OpenRouterRoutingConfig::default();
3446 assert!(without.is_empty());
3447 }
3448}