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 metadata: HashMap<String, String>,
1180 pub previous_response_id: Option<String>,
1183 pub tool_search: Option<ToolSearchConfig>,
1185 pub prompt_cache: Option<PromptCacheConfig>,
1187 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1189 pub parallel_tool_calls: Option<bool>,
1196 pub volatile_suffix_len: usize,
1206}
1207
1208impl LlmCallConfig {
1209 pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1219 if supported {
1220 self.parallel_tool_calls
1221 } else {
1222 None
1223 }
1224 }
1225}
1226
1227impl From<&RuntimeAgent> for LlmCallConfig {
1228 fn from(runtime_agent: &RuntimeAgent) -> Self {
1229 Self {
1230 model: runtime_agent.model.clone(),
1231 temperature: runtime_agent.temperature,
1232 max_tokens: runtime_agent.max_tokens,
1233 tools: runtime_agent.tools.clone(),
1234 reasoning_effort: None, metadata: HashMap::new(), previous_response_id: None,
1237 tool_search: runtime_agent.tool_search.clone(),
1238 prompt_cache: runtime_agent.prompt_cache.clone(),
1239 openrouter_routing: runtime_agent.openrouter_routing.clone(),
1240 parallel_tool_calls: runtime_agent.parallel_tool_calls,
1241 volatile_suffix_len: 0,
1242 }
1243 }
1244}
1245
1246#[derive(Debug, Clone)]
1248pub struct LlmResponse {
1249 pub text: String,
1250 pub thinking: Option<String>,
1252 pub thinking_signature: Option<String>,
1254 pub tool_calls: Option<Vec<ToolCall>>,
1255 pub metadata: LlmCompletionMetadata,
1256}
1257
1258pub struct LlmCallConfigBuilder {
1277 config: LlmCallConfig,
1278}
1279
1280impl LlmCallConfigBuilder {
1281 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1283 Self {
1284 config: LlmCallConfig::from(runtime_agent),
1285 }
1286 }
1287
1288 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1290 self.config.reasoning_effort = Some(effort.into());
1291 self
1292 }
1293
1294 pub fn model(mut self, model: impl Into<String>) -> Self {
1296 self.config.model = model.into();
1297 self
1298 }
1299
1300 pub fn temperature(mut self, temp: f32) -> Self {
1302 self.config.temperature = Some(temp);
1303 self
1304 }
1305
1306 pub fn max_tokens(mut self, tokens: u32) -> Self {
1308 self.config.max_tokens = Some(tokens);
1309 self
1310 }
1311
1312 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1314 self.config.tools = tools;
1315 self
1316 }
1317
1318 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1323 self.config.metadata = metadata;
1324 self
1325 }
1326
1327 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1329 self.config.metadata.insert(key.into(), value.into());
1330 self
1331 }
1332
1333 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1335 self.config.previous_response_id = id;
1336 self
1337 }
1338
1339 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1341 self.config.tool_search = Some(config);
1342 self
1343 }
1344
1345 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1347 self.config.prompt_cache = Some(config);
1348 self
1349 }
1350
1351 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1353 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1354 self
1355 }
1356
1357 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1359 self.config.parallel_tool_calls = parallel_tool_calls;
1360 self
1361 }
1362
1363 pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1367 self.config.volatile_suffix_len = len;
1368 self
1369 }
1370
1371 pub fn build(self) -> LlmCallConfig {
1373 self.config
1374 }
1375}
1376
1377impl From<&crate::message::Message> for LlmMessage {
1382 fn from(msg: &crate::message::Message) -> Self {
1388 let role = match msg.role {
1389 crate::message::MessageRole::System => LlmMessageRole::System,
1390 crate::message::MessageRole::User => LlmMessageRole::User,
1391 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1392 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1393 };
1394
1395 let tool_calls: Vec<ToolCall> = msg
1397 .tool_calls()
1398 .into_iter()
1399 .map(|tc| ToolCall {
1400 id: tc.id.clone(),
1401 name: tc.name.clone(),
1402 arguments: tc.arguments.clone(),
1403 })
1404 .collect();
1405
1406 LlmMessage {
1407 role,
1408 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1409 tool_calls: if tool_calls.is_empty() {
1410 None
1411 } else {
1412 Some(tool_calls)
1413 },
1414 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1415 phase: msg.phase,
1416 thinking: msg.thinking.clone(),
1417 thinking_signature: msg.thinking_signature.clone(),
1418 }
1419 }
1420}
1421
1422use crate::traits::ResolvedImage;
1427use uuid::Uuid;
1428
1429impl LlmMessage {
1430 pub fn from_message_with_images(
1450 msg: &crate::message::Message,
1451 resolved_images: &HashMap<Uuid, ResolvedImage>,
1452 ) -> Self {
1453 use crate::message::{ContentPart, MessageRole};
1454
1455 let role = match msg.role {
1456 MessageRole::System => LlmMessageRole::System,
1457 MessageRole::User => LlmMessageRole::User,
1458 MessageRole::Agent => LlmMessageRole::Assistant,
1459 MessageRole::ToolResult => LlmMessageRole::Tool,
1460 };
1461
1462 let mut parts: Vec<LlmContentPart> = Vec::new();
1464 let mut tool_calls: Vec<ToolCall> = Vec::new();
1465
1466 for part in &msg.content {
1467 match part {
1468 ContentPart::Text(t) => {
1469 parts.push(LlmContentPart::Text {
1470 text: t.text.clone(),
1471 });
1472 }
1473 ContentPart::Image(img) => {
1474 if let Some(url) = &img.url {
1476 parts.push(LlmContentPart::Image { url: url.clone() });
1477 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1478 {
1479 let data_url = format!("data:{};base64,{}", media_type, base64);
1480 parts.push(LlmContentPart::Image { url: data_url });
1481 }
1482 }
1483 ContentPart::ImageFile(img_file) => {
1484 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1486 parts.push(LlmContentPart::Image {
1487 url: resolved.to_data_url(),
1488 });
1489 } else {
1490 parts.push(LlmContentPart::Text {
1492 text: format!("[Image not found: {}]", img_file.image_id),
1493 });
1494 }
1495 }
1496 ContentPart::ToolCall(tc) => {
1497 tool_calls.push(ToolCall {
1499 id: tc.id.clone(),
1500 name: tc.name.clone(),
1501 arguments: tc.arguments.clone(),
1502 });
1503 }
1504 ContentPart::ToolResult(tr) => {
1505 let text = if let Some(err) = &tr.error {
1507 format!("Tool error: {}", err)
1508 } else if let Some(res) = &tr.result {
1509 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1510 } else {
1511 "{}".to_string()
1512 };
1513 let text = truncate_tool_result(text);
1517 parts.push(LlmContentPart::Text { text });
1518 }
1519 }
1520 }
1521
1522 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1524 if let LlmContentPart::Text { text } = &parts[0] {
1526 LlmMessageContent::Text(text.clone())
1527 } else {
1528 LlmMessageContent::Parts(parts)
1529 }
1530 } else if parts.is_empty() {
1531 LlmMessageContent::Text(String::new())
1533 } else {
1534 LlmMessageContent::Parts(parts)
1536 };
1537
1538 LlmMessage {
1539 role,
1540 content,
1541 tool_calls: if tool_calls.is_empty() {
1542 None
1543 } else {
1544 Some(tool_calls)
1545 },
1546 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1547 phase: msg.phase,
1548 thinking: msg.thinking.clone(),
1549 thinking_signature: msg.thinking_signature.clone(),
1550 }
1551 }
1552
1553 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1555 msg.content.iter().any(|p| p.is_image_file())
1556 }
1557
1558 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1560 msg.content
1561 .iter()
1562 .filter_map(|p| match p {
1563 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1564 _ => None,
1565 })
1566 .collect()
1567 }
1568}
1569
1570pub use crate::provider::DriverId;
1575
1576#[derive(Debug, Clone, Default, PartialEq, Eq)]
1582pub struct ProviderMetadata {
1583 pub refresh_token: Option<String>,
1585 pub account_id: Option<String>,
1587 pub extra: Option<serde_json::Value>,
1589}
1590
1591#[derive(Debug, Clone)]
1593pub struct ProviderConfig {
1594 pub provider_type: DriverId,
1596 pub api_key: Option<String>,
1598 pub base_url: Option<String>,
1600 pub metadata: ProviderMetadata,
1602}
1603
1604impl ProviderConfig {
1605 pub fn new(provider_type: DriverId) -> Self {
1607 Self {
1608 provider_type,
1609 api_key: None,
1610 base_url: None,
1611 metadata: ProviderMetadata::default(),
1612 }
1613 }
1614
1615 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1617 self.api_key = Some(api_key.into());
1618 self
1619 }
1620
1621 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1623 self.base_url = Some(base_url.into());
1624 self
1625 }
1626
1627 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1629 self.metadata = metadata;
1630 self
1631 }
1632}
1633
1634#[derive(Debug, Clone)]
1640pub struct DriverConfig {
1641 pub provider_type: DriverId,
1643 pub api_key: Option<String>,
1649 pub credentials: std::collections::BTreeMap<String, String>,
1655 pub base_url: Option<String>,
1657 pub metadata: ProviderMetadata,
1659}
1660
1661impl DriverConfig {
1662 pub fn from_provider_config(config: &ProviderConfig) -> Self {
1668 Self {
1669 provider_type: config.provider_type.clone(),
1670 credentials: crate::credential_schema::parse_credential_document(
1671 config.api_key.as_deref(),
1672 ),
1673 api_key: config.api_key.clone(),
1674 base_url: config.base_url.clone(),
1675 metadata: config.metadata.clone(),
1676 }
1677 }
1678
1679 pub fn credential(&self, name: &str) -> Option<&str> {
1681 self.credentials
1682 .get(name)
1683 .map(String::as_str)
1684 .filter(|s| !s.is_empty())
1685 }
1686}
1687
1688impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1689 fn from(model: &crate::traits::ResolvedModel) -> Self {
1690 Self {
1691 provider_type: model.provider_type.clone(),
1692 api_key: model.api_key.clone(),
1693 base_url: model.base_url.clone(),
1694 metadata: model.provider_metadata.clone().unwrap_or_default(),
1695 }
1696 }
1697}
1698
1699pub type BoxedChatDriver = Box<dyn ChatDriver>;
1701
1702#[derive(Debug, Clone)]
1708pub struct EmbedRequest {
1709 pub texts: Vec<String>,
1711 pub model: String,
1713}
1714
1715#[derive(Debug, Clone)]
1717pub struct EmbedResponse {
1718 pub embeddings: Vec<Vec<f32>>,
1720 pub usage_tokens: Option<u32>,
1723}
1724
1725#[derive(Debug, thiserror::Error)]
1727pub enum EmbeddingsDriverError {
1728 #[error("embeddings provider returned an error: {0}")]
1729 Provider(String),
1730 #[error("embeddings request failed: {0}")]
1731 Transport(String),
1732}
1733
1734#[async_trait]
1740pub trait EmbeddingsDriver: Send + Sync {
1741 async fn embed(
1743 &self,
1744 request: EmbedRequest,
1745 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1746}
1747
1748pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1750
1751pub type EmbeddingsDriverFactory =
1753 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1754
1755pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1764
1765#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1771#[serde(rename_all = "snake_case")]
1772pub enum ServiceKind {
1773 Chat,
1775 Embeddings,
1777 Realtime,
1779 Images,
1781 Rerank,
1783}
1784
1785impl std::fmt::Display for ServiceKind {
1786 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1787 let s = match self {
1788 ServiceKind::Chat => "chat",
1789 ServiceKind::Embeddings => "embeddings",
1790 ServiceKind::Realtime => "realtime",
1791 ServiceKind::Images => "images",
1792 ServiceKind::Rerank => "rerank",
1793 };
1794 f.write_str(s)
1795 }
1796}
1797
1798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1811pub enum DriverOAuthFlow {
1812 OpenRouterPkce,
1820}
1821
1822#[derive(Debug, Clone)]
1827pub struct DriverOAuthConfig {
1828 pub authorize_url: String,
1830 pub token_url: String,
1832 pub flow: DriverOAuthFlow,
1834}
1835
1836impl DriverOAuthConfig {
1837 pub fn openrouter() -> Self {
1839 Self {
1840 authorize_url: "https://openrouter.ai/auth".to_string(),
1841 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1842 flow: DriverOAuthFlow::OpenRouterPkce,
1843 }
1844 }
1845}
1846
1847#[derive(Clone)]
1854pub struct DriverDescriptor {
1855 pub id: DriverId,
1857 pub display_name: String,
1859 pub services: Vec<ServiceKind>,
1861 pub credential_schema: CredentialFormSchema,
1863 pub oauth: Option<DriverOAuthConfig>,
1866 pub chat: Option<DriverFactory>,
1868 pub embeddings: Option<EmbeddingsDriverFactory>,
1870}
1871
1872impl DriverDescriptor {
1873 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1878 where
1879 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1880 {
1881 let id = id.into();
1882 Self {
1883 display_name: default_display_name(&id),
1884 credential_schema: default_credential_schema(&id),
1885 services: vec![ServiceKind::Chat],
1886 oauth: None,
1887 chat: Some(Arc::new(factory)),
1888 embeddings: None,
1889 id,
1890 }
1891 }
1892
1893 pub fn supports(&self, service: ServiceKind) -> bool {
1895 self.services.contains(&service)
1896 }
1897}
1898
1899impl std::fmt::Debug for DriverDescriptor {
1900 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1901 f.debug_struct("DriverDescriptor")
1902 .field("id", &self.id)
1903 .field("display_name", &self.display_name)
1904 .field("services", &self.services)
1905 .field("oauth", &self.oauth.is_some())
1906 .field("chat", &self.chat.is_some())
1907 .field("embeddings", &self.embeddings.is_some())
1908 .finish()
1909 }
1910}
1911
1912fn default_display_name(id: &DriverId) -> String {
1913 match id {
1914 DriverId::OpenAI => "OpenAI".to_string(),
1915 DriverId::OpenRouter => "OpenRouter".to_string(),
1916 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1917 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1918 DriverId::Anthropic => "Anthropic".to_string(),
1919 DriverId::Gemini => "Google Gemini".to_string(),
1920 DriverId::Bedrock => "AWS Bedrock".to_string(),
1921 DriverId::Mai => "Microsoft MAI".to_string(),
1922 DriverId::Fireworks => "Fireworks AI".to_string(),
1923 DriverId::LlmSim => "LLM Simulator".to_string(),
1924 DriverId::External(id) => id.to_string(),
1925 }
1926}
1927
1928fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1929 match id {
1930 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1932 _ => CredentialFormSchema::api_key(String::new()),
1933 }
1934}
1935
1936#[derive(Clone, Default)]
1956pub struct DriverRegistry {
1957 descriptors: HashMap<DriverId, DriverDescriptor>,
1958}
1959
1960impl DriverRegistry {
1961 pub fn new() -> Self {
1963 Self {
1964 descriptors: HashMap::new(),
1965 }
1966 }
1967
1968 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1974 if self.descriptors.contains_key(&descriptor.id) {
1975 panic!(
1976 "driver already registered for provider '{}'; \
1977 use register_descriptor_or_replace to overwrite intentionally",
1978 descriptor.id
1979 );
1980 }
1981 self.descriptors.insert(descriptor.id.clone(), descriptor);
1982 }
1983
1984 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1986 self.descriptors.insert(descriptor.id.clone(), descriptor);
1987 }
1988
1989 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1995 where
1996 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1997 {
1998 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1999 }
2000
2001 pub fn register_or_replace<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_or_replace(DriverDescriptor::chat_only(provider_type, factory));
2010 }
2011
2012 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
2017 where
2018 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2019 {
2020 self.register(DriverId::external(id), factory);
2021 }
2022
2023 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
2032 let requires_api_key = !matches!(
2036 config.provider_type,
2037 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
2038 );
2039 if requires_api_key && config.api_key.is_none() {
2040 return Err(AgentLoopError::llm(
2041 "API key is required. Configure the API key in provider settings.",
2042 ));
2043 }
2044
2045 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2047 AgentLoopError::driver_not_registered(config.provider_type.to_string())
2048 })?;
2049 let factory = descriptor.chat.as_ref().ok_or_else(|| {
2050 AgentLoopError::llm(format!(
2051 "Provider driver '{}' does not implement the chat service.",
2052 config.provider_type
2053 ))
2054 })?;
2055
2056 let driver_config = DriverConfig::from_provider_config(config);
2058 Ok(factory(&driver_config))
2059 }
2060
2061 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2063 self.descriptors.contains_key(provider_type)
2064 }
2065
2066 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2068 self.descriptors.get(provider_type)
2069 }
2070
2071 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2073 self.descriptors
2074 .get(provider_type)
2075 .is_some_and(|d| d.supports(service))
2076 }
2077
2078 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2080 self.descriptors
2081 .values()
2082 .filter(|d| d.supports(service))
2083 .map(|d| d.id.clone())
2084 .collect()
2085 }
2086
2087 pub fn registered_providers(&self) -> Vec<DriverId> {
2089 self.descriptors.keys().cloned().collect()
2090 }
2091
2092 pub fn create_embeddings_driver(
2100 &self,
2101 config: &ProviderConfig,
2102 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2103 let requires_api_key = !matches!(
2104 config.provider_type,
2105 DriverId::LlmSim | DriverId::External(_)
2106 );
2107 if requires_api_key && config.api_key.is_none() {
2108 return Err(EmbeddingsDriverError::Provider(
2109 "API key is required. Configure the API key in provider settings.".to_string(),
2110 ));
2111 }
2112 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2113 EmbeddingsDriverError::Provider(format!(
2114 "No driver registered for provider '{}'",
2115 config.provider_type
2116 ))
2117 })?;
2118 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2119 EmbeddingsDriverError::Provider(format!(
2120 "Provider driver '{}' does not implement the embeddings service.",
2121 config.provider_type
2122 ))
2123 })?;
2124 let driver_config = DriverConfig::from_provider_config(config);
2125 Ok(factory(&driver_config))
2126 }
2127}
2128
2129const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2134
2135const TRUNCATION_SUFFIX: &str =
2136 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2137
2138fn truncate_tool_result(text: String) -> String {
2139 if text.len() <= MAX_TOOL_RESULT_BYTES {
2140 return text;
2141 }
2142 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2143 let mut end = content_budget;
2144 while end > 0 && !text.is_char_boundary(end) {
2145 end -= 1;
2146 }
2147 let mut truncated = text[..end].to_string();
2148 truncated.push_str(TRUNCATION_SUFFIX);
2149 truncated
2150}
2151
2152#[cfg(test)]
2157mod tests {
2158 use super::*;
2159
2160 #[test]
2161 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2162 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2165 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2167 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2168 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2170 }
2171
2172 #[test]
2173 fn test_resolved_parallel_tool_calls_gating() {
2174 let mut config = LlmCallConfig::from(&RuntimeAgent::new("p", "gpt-5.2"));
2175
2176 assert_eq!(config.resolved_parallel_tool_calls(true), None);
2178 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2179
2180 config.parallel_tool_calls = Some(true);
2182 assert_eq!(config.resolved_parallel_tool_calls(true), Some(true));
2183 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2184
2185 config.parallel_tool_calls = Some(false);
2186 assert_eq!(config.resolved_parallel_tool_calls(true), Some(false));
2187 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2188 }
2189
2190 #[test]
2191 fn test_chat_driver_default_omits_parallel_tool_calls() {
2192 struct DefaultDriver;
2194 #[async_trait]
2195 impl ChatDriver for DefaultDriver {
2196 async fn chat_completion_stream(
2197 &self,
2198 _messages: Vec<LlmMessage>,
2199 _config: &LlmCallConfig,
2200 ) -> Result<LlmResponseStream> {
2201 unreachable!()
2202 }
2203 }
2204 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2205 }
2206
2207 #[test]
2208 fn test_fold_system_messages_none_when_absent() {
2209 let messages = vec![
2210 LlmMessage::text(LlmMessageRole::User, "hi"),
2211 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2212 ];
2213 assert_eq!(fold_system_messages(&messages), None);
2214 }
2215
2216 #[test]
2217 fn test_fold_system_messages_single() {
2218 let messages = vec![
2219 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2220 LlmMessage::text(LlmMessageRole::User, "hi"),
2221 ];
2222 assert_eq!(
2223 fold_system_messages(&messages),
2224 Some("AGENT-PROMPT".to_string())
2225 );
2226 }
2227
2228 #[test]
2229 fn test_fold_system_messages_accumulates_in_order() {
2230 let messages = vec![
2234 LlmMessage::text(LlmMessageRole::System, "A"),
2235 LlmMessage::text(LlmMessageRole::User, "hi"),
2236 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2237 LlmMessage::text(LlmMessageRole::System, "B"),
2238 ];
2239 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2240 }
2241
2242 #[test]
2243 fn test_fold_system_messages_concatenates_parts() {
2244 let messages = vec![LlmMessage::parts(
2245 LlmMessageRole::System,
2246 vec![
2247 LlmContentPart::text("foo"),
2248 LlmContentPart::image("data:image/png;base64,xxx"),
2249 LlmContentPart::text("bar"),
2250 ],
2251 )];
2252 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2253 }
2254
2255 #[test]
2256 fn test_llm_call_config_builder_from_runtime_agent() {
2257 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2258 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2259
2260 assert_eq!(llm_config.model, "gpt-4o");
2261 assert!(llm_config.reasoning_effort.is_none());
2262 assert!(llm_config.temperature.is_none());
2263 assert!(llm_config.max_tokens.is_none());
2264 assert!(llm_config.tools.is_empty());
2265 assert!(llm_config.metadata.is_empty());
2266 assert!(llm_config.openrouter_routing.is_none());
2268 }
2269
2270 #[test]
2271 fn runtime_agent_openrouter_routing_flows_into_call_config() {
2272 let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2276 runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2277 server_tools: vec![OpenRouterServerTool::new(
2278 OpenRouterServerToolKind::WebSearch,
2279 )],
2280 ..Default::default()
2281 });
2282
2283 let llm_config = LlmCallConfig::from(&runtime_agent);
2284 let routing = llm_config
2285 .openrouter_routing
2286 .expect("server-tool routing survives into the call config");
2287 assert_eq!(routing.server_tools.len(), 1);
2288 assert_eq!(
2289 routing.server_tools[0].kind.wire_type(),
2290 "openrouter:web_search"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_llm_call_config_builder_with_metadata() {
2296 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2297 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2298 .with_metadata("session_id", "session_abc123")
2299 .with_metadata("agent_id", "agent_xyz789")
2300 .build();
2301
2302 assert_eq!(
2303 llm_config.metadata.get("session_id"),
2304 Some(&"session_abc123".to_string())
2305 );
2306 assert_eq!(
2307 llm_config.metadata.get("agent_id"),
2308 Some(&"agent_xyz789".to_string())
2309 );
2310 }
2311
2312 #[test]
2313 fn test_llm_call_config_builder_with_metadata_hashmap() {
2314 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2315 let mut metadata = HashMap::new();
2316 metadata.insert("key1".to_string(), "value1".to_string());
2317 metadata.insert("key2".to_string(), "value2".to_string());
2318
2319 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2320 .metadata(metadata)
2321 .build();
2322
2323 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2324 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2325 }
2326
2327 #[test]
2328 fn test_llm_call_config_builder_with_reasoning_effort() {
2329 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2330 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2331 .reasoning_effort("high")
2332 .build();
2333
2334 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2335 }
2336
2337 #[test]
2338 fn test_llm_call_config_builder_with_all_options() {
2339 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2340 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2341 .model("claude-3-opus")
2342 .reasoning_effort("medium")
2343 .temperature(0.7)
2344 .max_tokens(1000)
2345 .build();
2346
2347 assert_eq!(llm_config.model, "claude-3-opus");
2348 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2349 assert_eq!(llm_config.temperature, Some(0.7));
2350 assert_eq!(llm_config.max_tokens, Some(1000));
2351 }
2352
2353 #[test]
2354 fn test_llm_call_config_builder_with_openrouter_routing() {
2355 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2356 let routing = OpenRouterRoutingConfig::fallback_models([
2357 "openai/gpt-5-mini",
2358 "anthropic/claude-sonnet-4.5",
2359 ]);
2360
2361 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2362 .openrouter_routing(routing.clone())
2363 .build();
2364
2365 assert_eq!(llm_config.openrouter_routing, Some(routing));
2366 }
2367
2368 #[test]
2369 fn test_openrouter_fallback_models_empty_is_empty() {
2370 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2371
2372 assert!(routing.is_empty());
2373 assert_eq!(routing.route, None);
2374 }
2375
2376 #[test]
2377 fn test_openrouter_routing_validates_primary_model() {
2378 let routing = OpenRouterRoutingConfig::fallback_models([
2379 "openai/gpt-5-mini",
2380 "anthropic/claude-sonnet-4.5",
2381 ]);
2382
2383 assert!(
2384 routing
2385 .validate_for_primary_model("openai/gpt-5-mini")
2386 .is_ok()
2387 );
2388 let err = routing
2389 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2390 .unwrap_err();
2391 assert!(err.contains("models[0]"));
2392 }
2393
2394 #[test]
2395 fn test_openrouter_routing_rejects_fallback_without_models() {
2396 let routing = OpenRouterRoutingConfig {
2397 route: Some(OpenRouterRoute::Fallback),
2398 ..Default::default()
2399 };
2400
2401 let err = routing
2402 .validate_for_primary_model("openai/gpt-5-mini")
2403 .unwrap_err();
2404 assert!(err.contains("requires at least one model"));
2405 }
2406
2407 #[test]
2408 fn test_openrouter_routing_serializes_request_fields() {
2409 let routing = OpenRouterRoutingConfig {
2410 models: vec![
2411 "openai/gpt-5-mini".to_string(),
2412 "anthropic/claude-sonnet-4.5".to_string(),
2413 ],
2414 route: Some(OpenRouterRoute::Fallback),
2415 provider: Some(OpenRouterProviderRouting {
2416 order: vec!["anthropic".to_string(), "openai".to_string()],
2417 allow_fallbacks: Some(false),
2418 require_parameters: Some(true),
2419 data_collection: Some(OpenRouterDataCollection::Deny),
2420 zdr: Some(true),
2421 sort: Some(OpenRouterProviderSort::Advanced(
2422 OpenRouterProviderSortOptions {
2423 by: OpenRouterProviderSortBy::Throughput,
2424 partition: Some(OpenRouterSortPartition::None),
2425 },
2426 )),
2427 max_price: Some(OpenRouterMaxPrice {
2428 prompt: Some(1.0),
2429 completion: Some(2.0),
2430 ..Default::default()
2431 }),
2432 ..Default::default()
2433 }),
2434 ..Default::default()
2435 };
2436
2437 let json = serde_json::to_value(routing).unwrap();
2438
2439 assert_eq!(
2440 json,
2441 serde_json::json!({
2442 "models": [
2443 "openai/gpt-5-mini",
2444 "anthropic/claude-sonnet-4.5"
2445 ],
2446 "route": "fallback",
2447 "provider": {
2448 "order": ["anthropic", "openai"],
2449 "allow_fallbacks": false,
2450 "require_parameters": true,
2451 "data_collection": "deny",
2452 "zdr": true,
2453 "sort": {
2454 "by": "throughput",
2455 "partition": "none"
2456 },
2457 "max_price": {
2458 "prompt": 1.0,
2459 "completion": 2.0
2460 }
2461 }
2462 })
2463 );
2464 }
2465
2466 #[test]
2467 fn test_provider_type_parsing() {
2468 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2469 assert_eq!(
2470 "openrouter".parse::<DriverId>().unwrap(),
2471 DriverId::OpenRouter
2472 );
2473 assert_eq!(
2474 "openai_completions".parse::<DriverId>().unwrap(),
2475 DriverId::OpenAICompletions
2476 );
2477 assert_eq!(
2478 "azure_openai".parse::<DriverId>().unwrap(),
2479 DriverId::AzureOpenAI
2480 );
2481 assert_eq!(
2482 "anthropic".parse::<DriverId>().unwrap(),
2483 DriverId::Anthropic
2484 );
2485 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2486 assert_eq!(
2488 "ollama".parse::<DriverId>().unwrap(),
2489 DriverId::external("ollama")
2490 );
2491 assert_eq!(
2492 "custom".parse::<DriverId>().unwrap(),
2493 DriverId::external("custom")
2494 );
2495 }
2496
2497 #[test]
2498 fn test_external_provider_id_is_case_insensitive() {
2499 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2502 assert_eq!(
2503 "Ollama".parse::<DriverId>().unwrap(),
2504 "ollama".parse::<DriverId>().unwrap()
2505 );
2506 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2507 assert_eq!(
2509 DriverId::external("MyProvider"),
2510 "myprovider".parse::<DriverId>().unwrap()
2511 );
2512 }
2513
2514 #[test]
2515 fn test_provider_type_display() {
2516 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2517 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2518 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2519 assert_eq!(
2520 DriverId::OpenAICompletions.to_string(),
2521 "openai_completions"
2522 );
2523 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2524 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2525 }
2526
2527 #[test]
2528 fn test_provider_config_builder() {
2529 let config = ProviderConfig::new(DriverId::Anthropic)
2530 .with_api_key("test-key")
2531 .with_base_url("https://custom.api.com");
2532
2533 assert_eq!(config.provider_type, DriverId::Anthropic);
2534 assert_eq!(config.api_key, Some("test-key".to_string()));
2535 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2536 }
2537
2538 #[test]
2539 fn test_driver_registry_requires_api_key() {
2540 let mut registry = DriverRegistry::new();
2542 registry.register(DriverId::OpenAI, |_config| {
2543 struct MockDriver;
2545 #[async_trait]
2546 impl ChatDriver for MockDriver {
2547 async fn chat_completion_stream(
2548 &self,
2549 _messages: Vec<LlmMessage>,
2550 _config: &LlmCallConfig,
2551 ) -> Result<LlmResponseStream> {
2552 unimplemented!()
2553 }
2554 }
2555 Box::new(MockDriver)
2556 });
2557
2558 let config = ProviderConfig::new(DriverId::OpenAI);
2560 let result = registry.create_chat_driver(&config);
2561 assert!(result.is_err());
2562
2563 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2565 let result = registry.create_chat_driver(&config_with_key);
2566 assert!(result.is_ok());
2567 }
2568
2569 #[test]
2570 fn test_driver_registry_returns_error_for_unregistered_provider() {
2571 let registry = DriverRegistry::new();
2572 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2573
2574 let result = registry.create_chat_driver(&config);
2575
2576 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2578 assert_eq!(provider, "anthropic");
2579 } else {
2580 panic!("Expected DriverNotRegistered error");
2581 }
2582 }
2583
2584 #[test]
2585 fn test_driver_registry_registration() {
2586 let mut registry = DriverRegistry::new();
2587
2588 assert!(!registry.has_driver(&DriverId::OpenAI));
2589 assert!(!registry.has_driver(&DriverId::Anthropic));
2590
2591 registry.register(DriverId::OpenAI, |_config| {
2592 struct MockDriver;
2593 #[async_trait]
2594 impl ChatDriver for MockDriver {
2595 async fn chat_completion_stream(
2596 &self,
2597 _messages: Vec<LlmMessage>,
2598 _config: &LlmCallConfig,
2599 ) -> Result<LlmResponseStream> {
2600 unimplemented!()
2601 }
2602 }
2603 Box::new(MockDriver)
2604 });
2605
2606 assert!(registry.has_driver(&DriverId::OpenAI));
2607 assert!(!registry.has_driver(&DriverId::Anthropic));
2608 }
2609
2610 #[test]
2611 fn test_register_external_and_create_driver_without_api_key() {
2612 struct MockDriver;
2613 #[async_trait]
2614 impl ChatDriver for MockDriver {
2615 async fn chat_completion_stream(
2616 &self,
2617 _messages: Vec<LlmMessage>,
2618 _config: &LlmCallConfig,
2619 ) -> Result<LlmResponseStream> {
2620 unimplemented!()
2621 }
2622 }
2623
2624 let mut registry = DriverRegistry::new();
2625 registry.register_external("openai-codex", |config| {
2626 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2628 Box::new(MockDriver)
2629 });
2630
2631 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2632
2633 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2635 ProviderMetadata {
2636 refresh_token: Some("rt".into()),
2637 ..Default::default()
2638 },
2639 );
2640 assert!(registry.create_chat_driver(&config).is_ok());
2641 }
2642
2643 #[test]
2644 fn test_register_defaults_to_chat_only_descriptor() {
2645 struct MockDriver;
2646 #[async_trait]
2647 impl ChatDriver for MockDriver {
2648 async fn chat_completion_stream(
2649 &self,
2650 _messages: Vec<LlmMessage>,
2651 _config: &LlmCallConfig,
2652 ) -> Result<LlmResponseStream> {
2653 unimplemented!()
2654 }
2655 }
2656
2657 let mut registry = DriverRegistry::new();
2658 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2659
2660 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2661 assert_eq!(descriptor.display_name, "Anthropic");
2662 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2663 assert!(descriptor.chat.is_some());
2664 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2666 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2667 assert!(descriptor.credential_schema.fields[0].required);
2668
2669 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2671 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2672 assert!(sim.credential_schema.fields.is_empty());
2673 }
2674
2675 #[test]
2676 fn test_descriptor_services_and_lookup() {
2677 struct MockDriver;
2678 #[async_trait]
2679 impl ChatDriver for MockDriver {
2680 async fn chat_completion_stream(
2681 &self,
2682 _messages: Vec<LlmMessage>,
2683 _config: &LlmCallConfig,
2684 ) -> Result<LlmResponseStream> {
2685 unimplemented!()
2686 }
2687 }
2688
2689 let mut registry = DriverRegistry::new();
2690 registry.register_descriptor(DriverDescriptor {
2691 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2692 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2693 });
2694 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2695
2696 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2697 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2698 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2699 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2700
2701 let realtime = registry.providers_for(ServiceKind::Realtime);
2702 assert_eq!(realtime, vec![DriverId::OpenAI]);
2703 let mut chat = registry.providers_for(ServiceKind::Chat);
2704 chat.sort_by_key(|p| p.to_string());
2705 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2706 }
2707
2708 #[test]
2709 fn test_create_chat_driver_fails_without_chat_factory() {
2710 let mut registry = DriverRegistry::new();
2711 registry.register_descriptor(DriverDescriptor {
2712 id: DriverId::external("embeddings-only"),
2713 display_name: "Embeddings Only".to_string(),
2714 services: vec![ServiceKind::Embeddings],
2715 credential_schema: CredentialFormSchema::empty(),
2716 oauth: None,
2717 chat: None,
2718 embeddings: None,
2719 });
2720
2721 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2722 let err = match registry.create_chat_driver(&config) {
2723 Ok(_) => panic!("expected error for missing chat factory"),
2724 Err(err) => err,
2725 };
2726 assert!(
2727 err.to_string()
2728 .contains("does not implement the chat service"),
2729 "unexpected error: {err}"
2730 );
2731 }
2732
2733 #[test]
2734 #[should_panic(expected = "already registered")]
2735 fn test_register_duplicate_panics() {
2736 struct MockDriver;
2737 #[async_trait]
2738 impl ChatDriver for MockDriver {
2739 async fn chat_completion_stream(
2740 &self,
2741 _messages: Vec<LlmMessage>,
2742 _config: &LlmCallConfig,
2743 ) -> Result<LlmResponseStream> {
2744 unimplemented!()
2745 }
2746 }
2747
2748 let mut registry = DriverRegistry::new();
2749 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2750 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2752 }
2753
2754 #[test]
2755 fn test_register_or_replace_overwrites() {
2756 struct MockDriver;
2757 #[async_trait]
2758 impl ChatDriver for MockDriver {
2759 async fn chat_completion_stream(
2760 &self,
2761 _messages: Vec<LlmMessage>,
2762 _config: &LlmCallConfig,
2763 ) -> Result<LlmResponseStream> {
2764 unimplemented!()
2765 }
2766 }
2767
2768 let mut registry = DriverRegistry::new();
2769 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2770 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2772 assert!(registry.has_driver(&DriverId::LlmSim));
2773 }
2774
2775 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2780
2781 #[test]
2782 fn test_message_has_image_files_with_image_file() {
2783 let message = Message {
2784 id: uuid::Uuid::new_v4().into(),
2785 role: MessageRole::User,
2786 content: vec![
2787 ContentPart::Text(TextContentPart {
2788 text: "Look at this image".to_string(),
2789 }),
2790 ContentPart::ImageFile(ImageFileContentPart {
2791 image_id: uuid::Uuid::new_v4().into(),
2792 filename: Some("test.png".to_string()),
2793 }),
2794 ],
2795 phase: None,
2796 thinking: None,
2797 thinking_signature: None,
2798 controls: None,
2799 metadata: None,
2800 external_actor: None,
2801 created_at: chrono::Utc::now(),
2802 };
2803
2804 assert!(LlmMessage::message_has_image_files(&message));
2805 }
2806
2807 #[test]
2808 fn test_message_has_image_files_without_image_file() {
2809 let message = Message {
2810 id: uuid::Uuid::new_v4().into(),
2811 role: MessageRole::User,
2812 content: vec![ContentPart::Text(TextContentPart {
2813 text: "Just text".to_string(),
2814 })],
2815 phase: None,
2816 thinking: None,
2817 thinking_signature: None,
2818 controls: None,
2819 metadata: None,
2820 external_actor: None,
2821 created_at: chrono::Utc::now(),
2822 };
2823
2824 assert!(!LlmMessage::message_has_image_files(&message));
2825 }
2826
2827 #[test]
2828 fn test_extract_image_file_ids() {
2829 let id1 = uuid::Uuid::new_v4();
2830 let id2 = uuid::Uuid::new_v4();
2831
2832 let message = Message {
2833 id: uuid::Uuid::new_v4().into(),
2834 role: MessageRole::User,
2835 content: vec![
2836 ContentPart::Text(TextContentPart {
2837 text: "Look at these images".to_string(),
2838 }),
2839 ContentPart::ImageFile(ImageFileContentPart {
2840 image_id: id1.into(),
2841 filename: Some("test1.png".to_string()),
2842 }),
2843 ContentPart::ImageFile(ImageFileContentPart {
2844 image_id: id2.into(),
2845 filename: Some("test2.png".to_string()),
2846 }),
2847 ],
2848 phase: None,
2849 thinking: None,
2850 thinking_signature: None,
2851 controls: None,
2852 metadata: None,
2853 external_actor: None,
2854 created_at: chrono::Utc::now(),
2855 };
2856
2857 let ids = LlmMessage::extract_image_file_ids(&message);
2858 assert_eq!(ids.len(), 2);
2859 assert!(ids.contains(&id1));
2860 assert!(ids.contains(&id2));
2861 }
2862
2863 #[test]
2864 fn test_from_message_with_images_text_only() {
2865 let message = Message {
2866 id: uuid::Uuid::new_v4().into(),
2867 role: MessageRole::User,
2868 content: vec![ContentPart::Text(TextContentPart {
2869 text: "Hello".to_string(),
2870 })],
2871 phase: None,
2872 thinking: None,
2873 thinking_signature: None,
2874 controls: None,
2875 metadata: None,
2876 external_actor: None,
2877 created_at: chrono::Utc::now(),
2878 };
2879
2880 let resolved = std::collections::HashMap::new();
2881 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2882
2883 assert_eq!(llm_message.role, LlmMessageRole::User);
2884 match llm_message.content {
2885 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2886 _ => panic!("Expected text content"),
2887 }
2888 }
2889
2890 #[test]
2891 fn test_from_message_with_images_resolved_image() {
2892 let image_id = uuid::Uuid::new_v4();
2893 let message = Message {
2894 id: uuid::Uuid::new_v4().into(),
2895 role: MessageRole::User,
2896 content: vec![
2897 ContentPart::Text(TextContentPart {
2898 text: "Look at this".to_string(),
2899 }),
2900 ContentPart::ImageFile(ImageFileContentPart {
2901 image_id: image_id.into(),
2902 filename: Some("test.png".to_string()),
2903 }),
2904 ],
2905 phase: None,
2906 thinking: None,
2907 thinking_signature: None,
2908 controls: None,
2909 metadata: None,
2910 external_actor: None,
2911 created_at: chrono::Utc::now(),
2912 };
2913
2914 let mut resolved = std::collections::HashMap::new();
2915 resolved.insert(
2916 image_id,
2917 crate::ResolvedImage::new("base64data", "image/png"),
2918 );
2919
2920 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2921
2922 match &llm_message.content {
2923 LlmMessageContent::Parts(parts) => {
2924 assert_eq!(parts.len(), 2);
2925 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2927 if let LlmContentPart::Image { url } = &parts[1] {
2929 assert!(url.starts_with("data:image/png;base64,"));
2930 } else {
2931 panic!("Expected image content part");
2932 }
2933 }
2934 _ => panic!("Expected parts content"),
2935 }
2936 }
2937
2938 #[test]
2939 fn test_from_message_with_images_unresolved_image() {
2940 let image_id = uuid::Uuid::new_v4();
2941 let message = Message {
2942 id: uuid::Uuid::new_v4().into(),
2943 role: MessageRole::User,
2944 content: vec![ContentPart::ImageFile(ImageFileContentPart {
2945 image_id: image_id.into(),
2946 filename: Some("missing.png".to_string()),
2947 })],
2948 phase: None,
2949 thinking: None,
2950 thinking_signature: None,
2951 controls: None,
2952 metadata: None,
2953 external_actor: None,
2954 created_at: chrono::Utc::now(),
2955 };
2956
2957 let resolved = std::collections::HashMap::new();
2959 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2960
2961 match &llm_message.content {
2964 LlmMessageContent::Text(text) => {
2965 assert!(text.contains("Image not found"));
2966 }
2967 LlmMessageContent::Parts(parts) => {
2968 assert_eq!(parts.len(), 1);
2969 if let LlmContentPart::Text { text } = &parts[0] {
2970 assert!(text.contains("Image not found"));
2971 } else {
2972 panic!("Expected text placeholder for missing image");
2973 }
2974 }
2975 }
2976 }
2977
2978 #[test]
2979 fn test_prepend_text_prefix_simple_text() {
2980 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2981 msg.prepend_text_prefix("[Alice] ");
2982 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2983 }
2984
2985 #[test]
2986 fn test_prepend_text_prefix_parts() {
2987 let mut msg = LlmMessage::parts(
2988 LlmMessageRole::User,
2989 vec![
2990 LlmContentPart::Text {
2991 text: "Hello".to_string(),
2992 },
2993 LlmContentPart::Image {
2994 url: "data:image/png;base64,abc".to_string(),
2995 },
2996 ],
2997 );
2998 msg.prepend_text_prefix("[Bob] ");
2999 match &msg.content {
3000 LlmMessageContent::Parts(parts) => {
3001 if let LlmContentPart::Text { text } = &parts[0] {
3002 assert_eq!(text, "[Bob] Hello");
3003 } else {
3004 panic!("Expected text part");
3005 }
3006 }
3007 _ => panic!("Expected parts content"),
3008 }
3009 }
3010
3011 #[test]
3012 fn test_prepend_text_prefix_parts_no_text() {
3013 let mut msg = LlmMessage::parts(
3014 LlmMessageRole::User,
3015 vec![LlmContentPart::Image {
3016 url: "data:image/png;base64,abc".to_string(),
3017 }],
3018 );
3019 msg.prepend_text_prefix("[Eve] ");
3020 match &msg.content {
3021 LlmMessageContent::Parts(parts) => {
3022 assert_eq!(parts.len(), 2);
3023 if let LlmContentPart::Text { text } = &parts[0] {
3024 assert_eq!(text, "[Eve] ");
3025 } else {
3026 panic!("Expected prepended text part");
3027 }
3028 }
3029 _ => panic!("Expected parts content"),
3030 }
3031 }
3032
3033 #[test]
3034 fn test_openrouter_plugin_config_is_empty() {
3035 assert!(OpenRouterPluginConfig::default().is_empty());
3036 assert!(
3037 !OpenRouterPluginConfig {
3038 web: Some(OpenRouterWebSearchPlugin::default()),
3039 file: None,
3040 }
3041 .is_empty()
3042 );
3043 assert!(
3044 !OpenRouterPluginConfig {
3045 web: None,
3046 file: Some(OpenRouterFilePlugin {}),
3047 }
3048 .is_empty()
3049 );
3050 }
3051
3052 #[test]
3053 fn test_openrouter_routing_is_empty_with_plugins() {
3054 let with_plugins = OpenRouterRoutingConfig {
3055 plugins: Some(OpenRouterPluginConfig {
3056 web: Some(OpenRouterWebSearchPlugin::default()),
3057 file: None,
3058 }),
3059 ..Default::default()
3060 };
3061 assert!(!with_plugins.is_empty());
3062
3063 let empty_plugins = OpenRouterRoutingConfig {
3064 plugins: Some(OpenRouterPluginConfig::default()),
3065 ..Default::default()
3066 };
3067 assert!(empty_plugins.is_empty());
3068 }
3069
3070 #[test]
3071 fn test_openrouter_web_search_plugin_serialization() {
3072 let plugin = OpenRouterWebSearchPlugin {
3073 max_results: Some(10),
3074 search_prompt: Some("search for Rust crates".to_string()),
3075 };
3076 let json = serde_json::to_value(&plugin).unwrap();
3077 assert_eq!(json["max_results"], 10);
3078 assert_eq!(json["search_prompt"], "search for Rust crates");
3079 }
3080
3081 #[test]
3082 fn test_openrouter_web_search_plugin_omits_none_fields() {
3083 let plugin = OpenRouterWebSearchPlugin::default();
3084 let json = serde_json::to_value(&plugin).unwrap();
3085 assert!(json.get("max_results").is_none());
3086 assert!(json.get("search_prompt").is_none());
3087 }
3088
3089 #[test]
3090 fn test_capacity_strategy_shared_capacity_is_noop() {
3091 let base = OpenRouterRoutingConfig {
3092 models: vec!["openai/gpt-5-mini".to_string()],
3093 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3094 ..Default::default()
3095 };
3096 let result = base.apply_capacity_strategy().unwrap();
3097 assert_eq!(
3098 result.capacity_strategy,
3099 Some(OpenRouterCapacityStrategy::SharedCapacity)
3100 );
3101 assert!(result.provider.is_none());
3102 }
3103
3104 #[test]
3105 fn test_capacity_strategy_none_is_noop() {
3106 let base = OpenRouterRoutingConfig {
3107 models: vec!["openai/gpt-5-mini".to_string()],
3108 capacity_strategy: None,
3109 ..Default::default()
3110 };
3111 let result = base.apply_capacity_strategy().unwrap();
3112 assert!(result.provider.is_none());
3113 }
3114
3115 #[test]
3116 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
3117 let base = OpenRouterRoutingConfig {
3118 models: vec!["openai/gpt-5-mini".to_string()],
3119 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3120 ..Default::default()
3121 };
3122 let result = base.apply_capacity_strategy().unwrap();
3123 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
3124 assert_eq!(provider.allow_fallbacks, Some(true));
3125 }
3126
3127 #[test]
3128 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
3129 let base = OpenRouterRoutingConfig {
3131 models: vec!["openai/gpt-5-mini".to_string()],
3132 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3133 provider: Some(OpenRouterProviderRouting {
3134 allow_fallbacks: Some(false),
3135 ..Default::default()
3136 }),
3137 ..Default::default()
3138 };
3139 let result = base.apply_capacity_strategy().unwrap();
3140 let provider = result.provider.as_ref().unwrap();
3141 assert_eq!(provider.allow_fallbacks, Some(false));
3142 }
3143
3144 #[test]
3145 fn test_capacity_strategy_byok_only_requires_provider_only() {
3146 let base = OpenRouterRoutingConfig {
3147 models: vec!["openai/gpt-5-mini".to_string()],
3148 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3149 ..Default::default()
3150 };
3151 let err = base.apply_capacity_strategy().unwrap_err();
3152 assert!(
3153 err.contains("provider.only"),
3154 "error should mention provider.only: {err}"
3155 );
3156 }
3157
3158 #[test]
3159 fn test_capacity_strategy_byok_only_disables_fallbacks() {
3160 let base = OpenRouterRoutingConfig {
3161 models: vec!["openai/gpt-5-mini".to_string()],
3162 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3163 provider: Some(OpenRouterProviderRouting {
3164 only: vec!["my-byok-provider".to_string()],
3165 ..Default::default()
3166 }),
3167 ..Default::default()
3168 };
3169 let result = base.apply_capacity_strategy().unwrap();
3170 let provider = result.provider.as_ref().unwrap();
3171 assert_eq!(provider.allow_fallbacks, Some(false));
3172 assert_eq!(provider.only, vec!["my-byok-provider"]);
3173 }
3174
3175 #[test]
3176 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3177 let with_strategy = OpenRouterRoutingConfig {
3178 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3179 ..Default::default()
3180 };
3181 assert!(!with_strategy.is_empty());
3182
3183 let byok_first = OpenRouterRoutingConfig {
3184 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3185 ..Default::default()
3186 };
3187 assert!(!byok_first.is_empty());
3188
3189 let shared = OpenRouterRoutingConfig {
3190 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3191 ..Default::default()
3192 };
3193 assert!(shared.is_empty());
3194 }
3195
3196 #[test]
3201 fn test_preset_no_presets_is_noop() {
3202 let base = OpenRouterRoutingConfig {
3203 models: vec!["openai/gpt-5-mini".to_string()],
3204 ..Default::default()
3205 };
3206 let result = base.apply_presets().unwrap();
3207 assert_eq!(result, base);
3208 }
3209
3210 #[test]
3211 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3212 let base = OpenRouterRoutingConfig {
3213 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3214 ..Default::default()
3215 };
3216 let result = base.apply_presets().unwrap();
3217 assert!(result.presets.is_empty(), "presets cleared after apply");
3218 let provider = result.provider.expect("provider set by preset");
3219 assert_eq!(provider.require_parameters, Some(true));
3220 assert_eq!(
3221 provider.sort,
3222 Some(OpenRouterProviderSort::Simple(
3223 OpenRouterProviderSortBy::Price
3224 ))
3225 );
3226 }
3227
3228 #[test]
3229 fn test_preset_lowest_latency_review_sets_sort_throughput() {
3230 let base = OpenRouterRoutingConfig {
3231 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3232 ..Default::default()
3233 };
3234 let result = base.apply_presets().unwrap();
3235 let provider = result.provider.expect("provider set by preset");
3236 assert_eq!(
3237 provider.sort,
3238 Some(OpenRouterProviderSort::Simple(
3239 OpenRouterProviderSortBy::Throughput
3240 ))
3241 );
3242 }
3243
3244 #[test]
3245 fn test_preset_zdr_only_sets_zdr() {
3246 let base = OpenRouterRoutingConfig {
3247 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3248 ..Default::default()
3249 };
3250 let result = base.apply_presets().unwrap();
3251 let provider = result.provider.expect("provider set");
3252 assert_eq!(provider.zdr, Some(true));
3253 }
3254
3255 #[test]
3256 fn test_preset_byok_first_sets_allow_fallbacks() {
3257 let base = OpenRouterRoutingConfig {
3258 presets: vec![OpenRouterRoutingPreset::ByokFirst],
3259 ..Default::default()
3260 };
3261 let result = base.apply_presets().unwrap();
3262 let provider = result.provider.expect("provider set");
3263 assert_eq!(provider.allow_fallbacks, Some(true));
3264 }
3265
3266 #[test]
3267 fn test_preset_no_data_collection_sets_data_collection_deny() {
3268 let base = OpenRouterRoutingConfig {
3269 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3270 ..Default::default()
3271 };
3272 let result = base.apply_presets().unwrap();
3273 let provider = result.provider.expect("provider set");
3274 assert_eq!(
3275 provider.data_collection,
3276 Some(OpenRouterDataCollection::Deny)
3277 );
3278 }
3279
3280 #[test]
3281 fn test_preset_strict_json_sets_require_parameters() {
3282 let base = OpenRouterRoutingConfig {
3283 presets: vec![OpenRouterRoutingPreset::StrictJson],
3284 ..Default::default()
3285 };
3286 let result = base.apply_presets().unwrap();
3287 let provider = result.provider.expect("provider set");
3288 assert_eq!(provider.require_parameters, Some(true));
3289 }
3290
3291 #[test]
3292 fn test_preset_reasoning_required_sets_require_parameters() {
3293 let base = OpenRouterRoutingConfig {
3294 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
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_max_price_converts_usd_per_million() {
3304 let base = OpenRouterRoutingConfig {
3305 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3306 prompt_usd_per_million: Some(5.0),
3307 completion_usd_per_million: Some(15.0),
3308 }],
3309 ..Default::default()
3310 };
3311 let result = base.apply_presets().unwrap();
3312 let provider = result.provider.expect("provider set");
3313 let max_price = provider.max_price.expect("max_price set");
3314 let prompt = max_price.prompt.expect("prompt set");
3316 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3317 let completion = max_price.completion.expect("completion set");
3318 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3319 }
3320
3321 #[test]
3322 fn test_preset_max_price_rejects_negative_values() {
3323 let base = OpenRouterRoutingConfig {
3324 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3325 prompt_usd_per_million: Some(-1.0),
3326 completion_usd_per_million: None,
3327 }],
3328 ..Default::default()
3329 };
3330 let err = base.apply_presets().unwrap_err();
3331 assert!(
3332 err.contains("non-negative"),
3333 "error should mention non-negative: {err}"
3334 );
3335 }
3336
3337 #[test]
3338 fn test_preset_max_price_both_none_no_provider_field() {
3339 let base = OpenRouterRoutingConfig {
3340 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3341 prompt_usd_per_million: None,
3342 completion_usd_per_million: None,
3343 }],
3344 ..Default::default()
3345 };
3346 let result = base.apply_presets().unwrap();
3347 assert!(
3348 result.provider.is_none(),
3349 "MaxPrice with no dimensions should not produce a provider field"
3350 );
3351 }
3352
3353 #[test]
3354 fn test_preset_explicit_provider_overrides_preset() {
3355 let base = OpenRouterRoutingConfig {
3356 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3357 provider: Some(OpenRouterProviderRouting {
3358 sort: Some(OpenRouterProviderSort::Simple(
3360 OpenRouterProviderSortBy::Throughput,
3361 )),
3362 ..Default::default()
3363 }),
3364 ..Default::default()
3365 };
3366 let result = base.apply_presets().unwrap();
3367 let provider = result.provider.expect("provider set");
3368 assert_eq!(
3370 provider.sort,
3371 Some(OpenRouterProviderSort::Simple(
3372 OpenRouterProviderSortBy::Throughput
3373 ))
3374 );
3375 assert_eq!(provider.require_parameters, Some(true));
3377 }
3378
3379 #[test]
3380 fn test_preset_multiple_presets_combined() {
3381 let base = OpenRouterRoutingConfig {
3382 presets: vec![
3383 OpenRouterRoutingPreset::ZdrOnly,
3384 OpenRouterRoutingPreset::NoDataCollection,
3385 OpenRouterRoutingPreset::LowestLatencyReview,
3386 ],
3387 ..Default::default()
3388 };
3389 let result = base.apply_presets().unwrap();
3390 let provider = result.provider.expect("provider set");
3391 assert_eq!(provider.zdr, Some(true));
3392 assert_eq!(
3393 provider.data_collection,
3394 Some(OpenRouterDataCollection::Deny)
3395 );
3396 assert_eq!(
3397 provider.sort,
3398 Some(OpenRouterProviderSort::Simple(
3399 OpenRouterProviderSortBy::Throughput
3400 ))
3401 );
3402 }
3403
3404 #[test]
3405 fn test_preset_later_preset_overrides_sort() {
3406 let base = OpenRouterRoutingConfig {
3407 presets: vec![
3408 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3411 ..Default::default()
3412 };
3413 let result = base.apply_presets().unwrap();
3414 let provider = result.provider.expect("provider set");
3415 assert_eq!(
3417 provider.sort,
3418 Some(OpenRouterProviderSort::Simple(
3419 OpenRouterProviderSortBy::Throughput
3420 ))
3421 );
3422 assert_eq!(provider.require_parameters, Some(true));
3424 }
3425
3426 #[test]
3427 fn test_preset_non_empty_in_is_empty() {
3428 let with_preset = OpenRouterRoutingConfig {
3429 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3430 ..Default::default()
3431 };
3432 assert!(!with_preset.is_empty());
3433
3434 let without = OpenRouterRoutingConfig::default();
3435 assert!(without.is_empty());
3436 }
3437}