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