1use crate::credential_schema::CredentialFormSchema;
18use crate::error::{AgentLoopError, LlmErrorKind, Result};
19use crate::openresponses_protocol::{CompactRequest, CompactResponse};
20use crate::runtime_agent::RuntimeAgent;
21use crate::tool_types::{ToolCall, ToolDefinition};
22use async_trait::async_trait;
23use chrono::{DateTime, Utc};
24use futures::Stream;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::pin::Pin;
28use std::sync::Arc;
29
30pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LlmStreamError {
44 pub code: Option<String>,
46 pub status: Option<u16>,
48 pub message: String,
50}
51
52impl LlmStreamError {
53 pub fn new(message: impl Into<String>) -> Self {
54 Self {
55 code: None,
56 status: None,
57 message: message.into(),
58 }
59 }
60
61 pub fn provider(
63 code: Option<impl Into<String>>,
64 status: Option<u16>,
65 message: impl Into<String>,
66 ) -> Self {
67 Self {
68 code: code.map(Into::into),
69 status,
70 message: message.into(),
71 }
72 }
73
74 pub fn kind(&self) -> LlmErrorKind {
76 if let Some(code) = self.code.as_deref()
77 && let Some(kind) = LlmErrorKind::from_provider_code(code)
78 {
79 return kind;
80 }
81 if let Some(status) = self.status {
82 return LlmErrorKind::from_provider_status(status, &self.message);
83 }
84 LlmErrorKind::from_error_text(&self.message)
85 }
86}
87
88impl std::error::Error for LlmStreamError {}
89
90impl std::fmt::Display for LlmStreamError {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match (&self.code, self.status) {
93 (Some(code), Some(status)) => write!(f, "{code} ({status}): {}", self.message),
94 (Some(code), None) => write!(f, "{code}: {}", self.message),
95 (None, Some(status)) => write!(f, "({status}): {}", self.message),
96 (None, None) => f.write_str(&self.message),
97 }
98 }
99}
100
101impl From<String> for LlmStreamError {
102 fn from(message: String) -> Self {
103 Self::new(message)
104 }
105}
106
107impl From<&str> for LlmStreamError {
108 fn from(message: &str) -> Self {
109 Self::new(message)
110 }
111}
112
113#[derive(Debug, Clone)]
115pub enum LlmStreamEvent {
116 TextDelta(String),
118 ThinkingDelta(String),
120 ThinkingSignature(String),
123 ReasonItem {
129 provider: String,
131 model: Option<String>,
133 item_id: String,
135 encrypted_content: Option<String>,
137 summary: Vec<String>,
139 token_count: Option<u32>,
141 },
142 ToolCalls(Vec<ToolCall>),
144 Done(Box<LlmCompletionMetadata>),
146 Error(LlmStreamError),
148}
149
150#[derive(Debug, Clone)]
161pub struct DiscoveredModel {
162 pub model_id: String,
164 pub display_name: Option<String>,
166 pub created_at: Option<DateTime<Utc>>,
168 pub owned_by: Option<String>,
170 pub discovered_profile: Option<crate::model::ModelProfile>,
173}
174
175#[derive(Debug, Clone, Default)]
189pub struct LlmCompletionMetadata {
190 pub total_tokens: Option<u32>,
192 pub prompt_tokens: Option<u32>,
194 pub completion_tokens: Option<u32>,
196 pub cache_read_tokens: Option<u32>,
198 pub cache_creation_tokens: Option<u32>,
200 pub provider_cost_usd: Option<f64>,
204 pub model: Option<String>,
206 pub finish_reason: Option<String>,
208 pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
210 pub response_id: Option<String>,
213 pub phase: Option<String>,
217}
218
219pub fn disjoint_prompt_tokens(reported_input: u32, cache_read: Option<u32>) -> u32 {
230 reported_input.saturating_sub(cache_read.unwrap_or(0))
231}
232
233#[async_trait]
255pub trait ChatDriver: Send + Sync {
256 async fn chat_completion_stream(
258 &self,
259 messages: Vec<LlmMessage>,
260 config: &LlmCallConfig,
261 ) -> Result<LlmResponseStream>;
262
263 async fn chat_completion(
265 &self,
266 messages: Vec<LlmMessage>,
267 config: &LlmCallConfig,
268 ) -> Result<LlmResponse> {
269 use futures::StreamExt;
270
271 let mut stream = self.chat_completion_stream(messages, config).await?;
272 let mut text = String::new();
273 let mut thinking = String::new();
274 let mut thinking_signature: Option<String> = None;
275 let mut tool_calls = Vec::new();
276 let mut metadata = LlmCompletionMetadata::default();
277
278 while let Some(event) = stream.next().await {
279 match event? {
280 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
281 LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
282 LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
283 LlmStreamEvent::ReasonItem {
284 encrypted_content, ..
285 } => {
286 if let Some(sig) = encrypted_content {
287 thinking_signature = Some(sig);
288 }
289 }
290 LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
291 LlmStreamEvent::Done(meta) => metadata = *meta,
292 LlmStreamEvent::Error(err) => {
293 return Err(crate::error::AgentLoopError::llm_kind(
294 err.kind(),
295 err.to_string(),
296 ));
297 }
298 }
299 }
300
301 Ok(LlmResponse {
302 text,
303 thinking: if thinking.is_empty() {
304 None
305 } else {
306 Some(thinking)
307 },
308 thinking_signature,
309 tool_calls: if tool_calls.is_empty() {
310 None
311 } else {
312 Some(tool_calls)
313 },
314 metadata,
315 })
316 }
317
318 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
326 Ok(None)
328 }
329
330 fn supports_compact(&self) -> bool {
339 false
341 }
342
343 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
354 false
355 }
356
357 async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
377 Ok(None)
379 }
380}
381
382#[async_trait]
384impl ChatDriver for Box<dyn ChatDriver> {
385 async fn chat_completion_stream(
386 &self,
387 messages: Vec<LlmMessage>,
388 config: &LlmCallConfig,
389 ) -> Result<LlmResponseStream> {
390 (**self).chat_completion_stream(messages, config).await
391 }
392
393 async fn chat_completion(
394 &self,
395 messages: Vec<LlmMessage>,
396 config: &LlmCallConfig,
397 ) -> Result<LlmResponse> {
398 (**self).chat_completion(messages, config).await
399 }
400
401 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
402 (**self).list_models().await
403 }
404
405 fn supports_compact(&self) -> bool {
406 (**self).supports_compact()
407 }
408
409 fn supports_parallel_tool_calls(&self, model: &str) -> bool {
410 (**self).supports_parallel_tool_calls(model)
411 }
412
413 async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
414 (**self).compact(request).await
415 }
416}
417
418#[derive(Debug, Clone)]
424pub struct LlmMessage {
425 pub role: LlmMessageRole,
426 pub content: LlmMessageContent,
427 pub tool_calls: Option<Vec<ToolCall>>,
428 pub tool_call_id: Option<String>,
429 pub phase: Option<crate::message::ExecutionPhase>,
434 pub thinking: Option<String>,
437 pub thinking_signature: Option<String>,
440}
441
442impl LlmMessage {
443 pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
445 Self {
446 role,
447 content: LlmMessageContent::Text(content.into()),
448 tool_calls: None,
449 tool_call_id: None,
450 phase: None,
451 thinking: None,
452 thinking_signature: None,
453 }
454 }
455
456 pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
458 Self {
459 role,
460 content: LlmMessageContent::Parts(parts),
461 tool_calls: None,
462 tool_call_id: None,
463 phase: None,
464 thinking: None,
465 thinking_signature: None,
466 }
467 }
468
469 pub fn content_as_text(&self) -> String {
471 self.content.to_text()
472 }
473
474 pub fn prepend_text_prefix(&mut self, prefix: &str) {
479 match &mut self.content {
480 LlmMessageContent::Text(text) => {
481 *text = format!("{}{}", prefix, text);
482 }
483 LlmMessageContent::Parts(parts) => {
484 for part in parts.iter_mut() {
485 if let LlmContentPart::Text { text } = part {
486 *text = format!("{}{}", prefix, text);
487 return;
488 }
489 }
490 parts.insert(
492 0,
493 LlmContentPart::Text {
494 text: prefix.to_string(),
495 },
496 );
497 }
498 }
499 }
500}
501
502pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
513 let mut system: Option<String> = None;
514 for msg in messages {
515 if msg.role == LlmMessageRole::System {
516 let text = msg.content.to_text();
517 system = Some(match system.take() {
518 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
519 _ => text,
520 });
521 }
522 }
523 system
524}
525
526#[derive(Debug, Clone)]
528pub enum LlmMessageContent {
529 Text(String),
531 Parts(Vec<LlmContentPart>),
533}
534
535impl LlmMessageContent {
536 pub fn to_text(&self) -> String {
538 match self {
539 LlmMessageContent::Text(s) => s.clone(),
540 LlmMessageContent::Parts(parts) => parts
541 .iter()
542 .filter_map(|p| match p {
543 LlmContentPart::Text { text } => Some(text.clone()),
544 _ => None,
545 })
546 .collect::<Vec<_>>()
547 .join(""),
548 }
549 }
550
551 pub fn is_text(&self) -> bool {
553 matches!(self, LlmMessageContent::Text(_))
554 }
555
556 pub fn is_parts(&self) -> bool {
558 matches!(self, LlmMessageContent::Parts(_))
559 }
560}
561
562impl From<String> for LlmMessageContent {
563 fn from(s: String) -> Self {
564 LlmMessageContent::Text(s)
565 }
566}
567
568impl From<&str> for LlmMessageContent {
569 fn from(s: &str) -> Self {
570 LlmMessageContent::Text(s.to_string())
571 }
572}
573
574#[derive(Debug, Clone)]
576pub enum LlmContentPart {
577 Text { text: String },
579 Image { url: String },
581 Audio { url: String },
583}
584
585impl LlmContentPart {
586 pub fn text(text: impl Into<String>) -> Self {
588 LlmContentPart::Text { text: text.into() }
589 }
590
591 pub fn image(url: impl Into<String>) -> Self {
593 LlmContentPart::Image { url: url.into() }
594 }
595
596 pub fn audio(url: impl Into<String>) -> Self {
598 LlmContentPart::Audio { url: url.into() }
599 }
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum LlmMessageRole {
605 System,
606 User,
607 Assistant,
608 Tool,
609}
610
611#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
621pub struct ToolSearchConfig {
622 pub enabled: bool,
624 pub threshold: usize,
627}
628
629#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
631#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
632#[serde(rename_all = "snake_case")]
633pub enum PromptCacheStrategy {
634 #[default]
636 Auto,
637}
638
639#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
644#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
645pub struct PromptCacheConfig {
646 pub enabled: bool,
648 #[serde(default)]
650 pub strategy: PromptCacheStrategy,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub gemini_cached_content: Option<String>,
659}
660
661#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
671#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
672#[serde(tag = "kind", rename_all = "snake_case")]
673pub enum OpenRouterRoutingPreset {
674 CheapestWithTools,
676 LowestLatencyReview,
678 ZdrOnly,
680 ByokFirst,
682 NoDataCollection,
684 StrictJson,
686 ReasoningRequired,
688 MaxPrice {
691 #[serde(default, skip_serializing_if = "Option::is_none")]
693 prompt_usd_per_million: Option<f64>,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
696 completion_usd_per_million: Option<f64>,
697 },
698}
699
700#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
708#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
709#[serde(rename_all = "snake_case")]
710pub enum OpenRouterCapacityStrategy {
711 #[default]
713 SharedCapacity,
714 ByokFirst,
718 ByokOnly,
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
733#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
734#[serde(rename_all = "snake_case")]
735pub enum OpenRouterServerToolKind {
736 WebSearch,
737 WebFetch,
738 Datetime,
739 ImageGeneration,
740 ApplyPatch,
741 Fusion,
742 Advisor,
743 Subagent,
744}
745
746impl OpenRouterServerToolKind {
747 pub const ALL: [OpenRouterServerToolKind; 8] = [
749 Self::WebSearch,
750 Self::WebFetch,
751 Self::Datetime,
752 Self::ImageGeneration,
753 Self::ApplyPatch,
754 Self::Fusion,
755 Self::Advisor,
756 Self::Subagent,
757 ];
758
759 pub fn name(&self) -> &'static str {
761 match self {
762 Self::WebSearch => "web_search",
763 Self::WebFetch => "web_fetch",
764 Self::Datetime => "datetime",
765 Self::ImageGeneration => "image_generation",
766 Self::ApplyPatch => "apply_patch",
767 Self::Fusion => "fusion",
768 Self::Advisor => "advisor",
769 Self::Subagent => "subagent",
770 }
771 }
772
773 pub fn display_name(&self) -> &'static str {
775 match self {
776 Self::WebSearch => "Web Search",
777 Self::WebFetch => "Web Fetch",
778 Self::Datetime => "Date & Time",
779 Self::ImageGeneration => "Image Generation",
780 Self::ApplyPatch => "Apply Patch",
781 Self::Fusion => "Fusion",
782 Self::Advisor => "Advisor",
783 Self::Subagent => "Subagent",
784 }
785 }
786
787 pub fn wire_type(&self) -> String {
790 format!("openrouter:{}", self.name())
791 }
792
793 pub fn from_name(name: &str) -> Option<Self> {
795 Self::ALL.into_iter().find(|kind| kind.name() == name)
796 }
797}
798
799#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
803#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
804pub struct OpenRouterServerTool {
805 pub kind: OpenRouterServerToolKind,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
807 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
808 pub parameters: Option<serde_json::Value>,
809}
810
811impl OpenRouterServerTool {
812 pub fn new(kind: OpenRouterServerToolKind) -> Self {
814 Self {
815 kind,
816 parameters: None,
817 }
818 }
819
820 pub fn with_parameters(kind: OpenRouterServerToolKind, parameters: serde_json::Value) -> Self {
822 Self {
823 kind,
824 parameters: Some(parameters),
825 }
826 }
827}
828
829#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
832#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
833pub struct OpenRouterRoutingConfig {
834 #[serde(default, skip_serializing_if = "Vec::is_empty")]
836 pub models: Vec<String>,
837 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub route: Option<OpenRouterRoute>,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub provider: Option<OpenRouterProviderRouting>,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub plugins: Option<OpenRouterPluginConfig>,
847 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
852 #[serde(default, skip_serializing_if = "Vec::is_empty")]
856 pub presets: Vec<OpenRouterRoutingPreset>,
857 #[serde(default, skip_serializing_if = "Vec::is_empty")]
860 pub server_tools: Vec<OpenRouterServerTool>,
861}
862
863impl OpenRouterRoutingConfig {
864 pub fn is_empty(&self) -> bool {
865 self.models.is_empty()
866 && self.route.is_none()
867 && self.provider.is_none()
868 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
869 && matches!(
870 self.capacity_strategy,
871 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
872 )
873 && self.presets.is_empty()
874 && self.server_tools.is_empty()
875 }
876
877 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
879 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
880 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
881 Self {
882 models,
883 route,
884 provider: None,
885 plugins: None,
886 capacity_strategy: None,
887 presets: vec![],
888 server_tools: vec![],
889 }
890 }
891
892 pub fn validate_for_primary_model(
893 &self,
894 primary_model: &str,
895 ) -> std::result::Result<(), String> {
896 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
897 return Err(
898 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
899 );
900 }
901
902 if let Some(first_model) = self.models.first()
903 && first_model != primary_model
904 {
905 return Err(format!(
906 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
907 ));
908 }
909
910 Ok(())
911 }
912
913 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
923 match self.capacity_strategy {
924 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
925 Some(OpenRouterCapacityStrategy::ByokFirst) => {
926 let mut result = self.clone();
927 let provider = result.provider.get_or_insert_with(Default::default);
928 if provider.allow_fallbacks.is_none() {
929 provider.allow_fallbacks = Some(true);
930 }
931 Ok(result)
932 }
933 Some(OpenRouterCapacityStrategy::ByokOnly) => {
934 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
935 if only_is_empty {
936 return Err(
937 "OpenRouter BYOK-only strategy requires provider.only to list at least \
938 one upstream provider slug. Configure the provider list to match the \
939 BYOK providers registered in your OpenRouter workspace."
940 .to_string(),
941 );
942 }
943 let mut result = self.clone();
944 let provider = result.provider.get_or_insert_with(Default::default);
945 provider.allow_fallbacks = Some(false);
946 Ok(result)
947 }
948 }
949 }
950
951 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
961 if self.presets.is_empty() {
962 return Ok(self.clone());
963 }
964
965 let mut derived = OpenRouterProviderRouting::default();
966
967 for preset in &self.presets {
968 match preset {
969 OpenRouterRoutingPreset::CheapestWithTools => {
970 derived.require_parameters = Some(true);
971 derived.sort = Some(OpenRouterProviderSort::Simple(
972 OpenRouterProviderSortBy::Price,
973 ));
974 }
975 OpenRouterRoutingPreset::LowestLatencyReview => {
976 derived.sort = Some(OpenRouterProviderSort::Simple(
977 OpenRouterProviderSortBy::Throughput,
978 ));
979 }
980 OpenRouterRoutingPreset::ZdrOnly => {
981 derived.zdr = Some(true);
982 }
983 OpenRouterRoutingPreset::ByokFirst => {
984 if derived.allow_fallbacks.is_none() {
985 derived.allow_fallbacks = Some(true);
986 }
987 }
988 OpenRouterRoutingPreset::NoDataCollection => {
989 derived.data_collection = Some(OpenRouterDataCollection::Deny);
990 }
991 OpenRouterRoutingPreset::StrictJson
992 | OpenRouterRoutingPreset::ReasoningRequired => {
993 derived.require_parameters = Some(true);
994 }
995 OpenRouterRoutingPreset::MaxPrice {
996 prompt_usd_per_million,
997 completion_usd_per_million,
998 } => {
999 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
1000 || completion_usd_per_million.is_some_and(|v| v < 0.0)
1001 {
1002 return Err(
1003 "MaxPrice preset values must be non-negative USD per million tokens"
1004 .to_string(),
1005 );
1006 }
1007 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
1008 let mp = derived.max_price.get_or_insert_with(Default::default);
1009 if let Some(p) = prompt_usd_per_million {
1010 mp.prompt = Some(p / 1_000_000.0);
1011 }
1012 if let Some(c) = completion_usd_per_million {
1013 mp.completion = Some(c / 1_000_000.0);
1014 }
1015 }
1016 }
1017 }
1018 }
1019
1020 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
1022
1023 let mut result = self.clone();
1024 result.presets = vec![];
1025 result.provider = if merged.is_empty() {
1026 None
1027 } else {
1028 Some(merged)
1029 };
1030 Ok(result)
1031 }
1032}
1033
1034fn merge_provider_routing(
1038 derived: OpenRouterProviderRouting,
1039 explicit: OpenRouterProviderRouting,
1040) -> OpenRouterProviderRouting {
1041 OpenRouterProviderRouting {
1042 order: if !explicit.order.is_empty() {
1043 explicit.order
1044 } else {
1045 derived.order
1046 },
1047 only: if !explicit.only.is_empty() {
1048 explicit.only
1049 } else {
1050 derived.only
1051 },
1052 ignore: if !explicit.ignore.is_empty() {
1053 explicit.ignore
1054 } else {
1055 derived.ignore
1056 },
1057 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
1058 require_parameters: explicit.require_parameters.or(derived.require_parameters),
1059 data_collection: explicit.data_collection.or(derived.data_collection),
1060 zdr: explicit.zdr.or(derived.zdr),
1061 enforce_distillable_text: explicit
1062 .enforce_distillable_text
1063 .or(derived.enforce_distillable_text),
1064 quantizations: if !explicit.quantizations.is_empty() {
1065 explicit.quantizations
1066 } else {
1067 derived.quantizations
1068 },
1069 sort: explicit.sort.or(derived.sort),
1070 max_price: explicit.max_price.or(derived.max_price),
1071 }
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 OpenRouterRoute {
1079 Fallback,
1080}
1081
1082#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1084#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1085pub struct OpenRouterProviderRouting {
1086 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1088 pub order: Vec<String>,
1089 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1091 pub only: Vec<String>,
1092 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1094 pub ignore: Vec<String>,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub allow_fallbacks: Option<bool>,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub require_parameters: Option<bool>,
1101 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub data_collection: Option<OpenRouterDataCollection>,
1104 #[serde(default, skip_serializing_if = "Option::is_none")]
1106 pub zdr: Option<bool>,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1109 pub enforce_distillable_text: Option<bool>,
1110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1112 pub quantizations: Vec<String>,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub sort: Option<OpenRouterProviderSort>,
1116 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub max_price: Option<OpenRouterMaxPrice>,
1119}
1120
1121impl OpenRouterProviderRouting {
1122 pub fn is_empty(&self) -> bool {
1123 self.order.is_empty()
1124 && self.only.is_empty()
1125 && self.ignore.is_empty()
1126 && self.allow_fallbacks.is_none()
1127 && self.require_parameters.is_none()
1128 && self.data_collection.is_none()
1129 && self.zdr.is_none()
1130 && self.enforce_distillable_text.is_none()
1131 && self.quantizations.is_empty()
1132 && self.sort.is_none()
1133 && self.max_price.is_none()
1134 }
1135}
1136
1137#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1139#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1140#[serde(rename_all = "snake_case")]
1141pub enum OpenRouterDataCollection {
1142 Allow,
1143 Deny,
1144}
1145
1146#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1148#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1149#[serde(untagged)]
1150pub enum OpenRouterProviderSort {
1151 Simple(OpenRouterProviderSortBy),
1152 Advanced(OpenRouterProviderSortOptions),
1153}
1154
1155#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1157#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1158#[serde(rename_all = "snake_case")]
1159pub enum OpenRouterProviderSortBy {
1160 Price,
1161 Throughput,
1162 Latency,
1163}
1164
1165#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1168pub struct OpenRouterProviderSortOptions {
1169 pub by: OpenRouterProviderSortBy,
1170 #[serde(default, skip_serializing_if = "Option::is_none")]
1171 pub partition: Option<OpenRouterSortPartition>,
1172}
1173
1174#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1176#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1177#[serde(rename_all = "snake_case")]
1178pub enum OpenRouterSortPartition {
1179 Model,
1180 None,
1181}
1182
1183#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1186#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1187pub struct OpenRouterMaxPrice {
1188 #[serde(default, skip_serializing_if = "Option::is_none")]
1189 pub prompt: Option<f64>,
1190 #[serde(default, skip_serializing_if = "Option::is_none")]
1191 pub completion: Option<f64>,
1192 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub request: Option<f64>,
1194 #[serde(default, skip_serializing_if = "Option::is_none")]
1195 pub image: Option<f64>,
1196}
1197
1198#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1204#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1205pub struct OpenRouterWebSearchPlugin {
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub max_results: Option<u32>,
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1211 pub search_prompt: Option<String>,
1212}
1213
1214#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1219#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1220pub struct OpenRouterFilePlugin {}
1221
1222#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1227#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1228pub struct OpenRouterPluginConfig {
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub web: Option<OpenRouterWebSearchPlugin>,
1232 #[serde(default, skip_serializing_if = "Option::is_none")]
1234 pub file: Option<OpenRouterFilePlugin>,
1235}
1236
1237impl OpenRouterPluginConfig {
1238 pub fn is_empty(&self) -> bool {
1239 self.web.is_none() && self.file.is_none()
1240 }
1241}
1242
1243pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
1245pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
1247
1248#[derive(Debug, Clone)]
1250pub struct LlmCallConfig {
1251 pub model: String,
1252 pub temperature: Option<f32>,
1253 pub max_tokens: Option<u32>,
1254 pub tools: Vec<ToolDefinition>,
1255 pub reasoning_effort: Option<String>,
1257 pub speed: Option<String>,
1261 pub metadata: HashMap<String, String>,
1265 pub previous_response_id: Option<String>,
1268 pub tool_search: Option<ToolSearchConfig>,
1270 pub prompt_cache: Option<PromptCacheConfig>,
1272 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1274 pub parallel_tool_calls: Option<bool>,
1281 pub volatile_suffix_len: usize,
1291}
1292
1293impl LlmCallConfig {
1294 pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1304 if supported {
1305 self.parallel_tool_calls
1306 } else {
1307 None
1308 }
1309 }
1310}
1311
1312impl From<&RuntimeAgent> for LlmCallConfig {
1313 fn from(runtime_agent: &RuntimeAgent) -> Self {
1314 Self {
1315 model: runtime_agent.model.clone(),
1316 temperature: runtime_agent.temperature,
1317 max_tokens: runtime_agent.max_tokens,
1318 tools: runtime_agent.tools.clone(),
1319 reasoning_effort: None, speed: None, metadata: HashMap::new(), previous_response_id: None,
1323 tool_search: runtime_agent.tool_search.clone(),
1324 prompt_cache: runtime_agent.prompt_cache.clone(),
1325 openrouter_routing: runtime_agent.openrouter_routing.clone(),
1326 parallel_tool_calls: runtime_agent.parallel_tool_calls,
1327 volatile_suffix_len: 0,
1328 }
1329 }
1330}
1331
1332#[derive(Debug, Clone)]
1334pub struct LlmResponse {
1335 pub text: String,
1336 pub thinking: Option<String>,
1338 pub thinking_signature: Option<String>,
1340 pub tool_calls: Option<Vec<ToolCall>>,
1341 pub metadata: LlmCompletionMetadata,
1342}
1343
1344pub struct LlmCallConfigBuilder {
1363 config: LlmCallConfig,
1364}
1365
1366impl LlmCallConfigBuilder {
1367 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1369 Self {
1370 config: LlmCallConfig::from(runtime_agent),
1371 }
1372 }
1373
1374 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1376 self.config.reasoning_effort = Some(effort.into());
1377 self
1378 }
1379
1380 pub fn speed(mut self, speed: impl Into<String>) -> Self {
1382 self.config.speed = Some(speed.into());
1383 self
1384 }
1385
1386 pub fn model(mut self, model: impl Into<String>) -> Self {
1388 self.config.model = model.into();
1389 self
1390 }
1391
1392 pub fn temperature(mut self, temp: f32) -> Self {
1394 self.config.temperature = Some(temp);
1395 self
1396 }
1397
1398 pub fn max_tokens(mut self, tokens: u32) -> Self {
1400 self.config.max_tokens = Some(tokens);
1401 self
1402 }
1403
1404 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1406 self.config.tools = tools;
1407 self
1408 }
1409
1410 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1415 self.config.metadata = metadata;
1416 self
1417 }
1418
1419 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1421 self.config.metadata.insert(key.into(), value.into());
1422 self
1423 }
1424
1425 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1427 self.config.previous_response_id = id;
1428 self
1429 }
1430
1431 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1433 self.config.tool_search = Some(config);
1434 self
1435 }
1436
1437 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1439 self.config.prompt_cache = Some(config);
1440 self
1441 }
1442
1443 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1445 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1446 self
1447 }
1448
1449 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1451 self.config.parallel_tool_calls = parallel_tool_calls;
1452 self
1453 }
1454
1455 pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1459 self.config.volatile_suffix_len = len;
1460 self
1461 }
1462
1463 pub fn build(self) -> LlmCallConfig {
1465 self.config
1466 }
1467}
1468
1469impl From<&crate::message::Message> for LlmMessage {
1474 fn from(msg: &crate::message::Message) -> Self {
1480 let role = match msg.role {
1481 crate::message::MessageRole::System => LlmMessageRole::System,
1482 crate::message::MessageRole::User => LlmMessageRole::User,
1483 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1484 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1485 };
1486
1487 let tool_calls: Vec<ToolCall> = msg
1489 .tool_calls()
1490 .into_iter()
1491 .map(|tc| ToolCall {
1492 id: tc.id.clone(),
1493 name: tc.name.clone(),
1494 arguments: tc.arguments.clone(),
1495 })
1496 .collect();
1497
1498 LlmMessage {
1499 role,
1500 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1501 tool_calls: if tool_calls.is_empty() {
1502 None
1503 } else {
1504 Some(tool_calls)
1505 },
1506 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1507 phase: msg.phase,
1508 thinking: msg.thinking.clone(),
1509 thinking_signature: msg.thinking_signature.clone(),
1510 }
1511 }
1512}
1513
1514use crate::traits::ResolvedImage;
1519use uuid::Uuid;
1520
1521impl LlmMessage {
1522 pub fn from_message_with_images(
1542 msg: &crate::message::Message,
1543 resolved_images: &HashMap<Uuid, ResolvedImage>,
1544 ) -> Self {
1545 use crate::message::{ContentPart, MessageRole};
1546
1547 let role = match msg.role {
1548 MessageRole::System => LlmMessageRole::System,
1549 MessageRole::User => LlmMessageRole::User,
1550 MessageRole::Agent => LlmMessageRole::Assistant,
1551 MessageRole::ToolResult => LlmMessageRole::Tool,
1552 };
1553
1554 let mut parts: Vec<LlmContentPart> = Vec::new();
1556 let mut tool_calls: Vec<ToolCall> = Vec::new();
1557
1558 for part in &msg.content {
1559 match part {
1560 ContentPart::Text(t) => {
1561 parts.push(LlmContentPart::Text {
1562 text: t.text.clone(),
1563 });
1564 }
1565 ContentPart::Image(img) => {
1566 if let Some(url) = &img.url {
1568 parts.push(LlmContentPart::Image { url: url.clone() });
1569 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1570 {
1571 let data_url = format!("data:{};base64,{}", media_type, base64);
1572 parts.push(LlmContentPart::Image { url: data_url });
1573 }
1574 }
1575 ContentPart::ImageFile(img_file) => {
1576 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1578 parts.push(LlmContentPart::Image {
1579 url: resolved.to_data_url(),
1580 });
1581 } else {
1582 parts.push(LlmContentPart::Text {
1584 text: format!("[Image not found: {}]", img_file.image_id),
1585 });
1586 }
1587 }
1588 ContentPart::ToolCall(tc) => {
1589 tool_calls.push(ToolCall {
1591 id: tc.id.clone(),
1592 name: tc.name.clone(),
1593 arguments: tc.arguments.clone(),
1594 });
1595 }
1596 ContentPart::ToolResult(tr) => {
1597 let text = if let Some(err) = &tr.error {
1599 format!("Tool error: {}", err)
1600 } else if let Some(res) = &tr.result {
1601 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1602 } else {
1603 "{}".to_string()
1604 };
1605 let text = truncate_tool_result(text);
1609 parts.push(LlmContentPart::Text { text });
1610 }
1611 }
1612 }
1613
1614 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1616 if let LlmContentPart::Text { text } = &parts[0] {
1618 LlmMessageContent::Text(text.clone())
1619 } else {
1620 LlmMessageContent::Parts(parts)
1621 }
1622 } else if parts.is_empty() {
1623 LlmMessageContent::Text(String::new())
1625 } else {
1626 LlmMessageContent::Parts(parts)
1628 };
1629
1630 LlmMessage {
1631 role,
1632 content,
1633 tool_calls: if tool_calls.is_empty() {
1634 None
1635 } else {
1636 Some(tool_calls)
1637 },
1638 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1639 phase: msg.phase,
1640 thinking: msg.thinking.clone(),
1641 thinking_signature: msg.thinking_signature.clone(),
1642 }
1643 }
1644
1645 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1647 msg.content.iter().any(|p| p.is_image_file())
1648 }
1649
1650 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1652 msg.content
1653 .iter()
1654 .filter_map(|p| match p {
1655 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1656 _ => None,
1657 })
1658 .collect()
1659 }
1660}
1661
1662pub use crate::provider::DriverId;
1667
1668#[derive(Debug, Clone, Default, PartialEq, Eq)]
1674pub struct ProviderMetadata {
1675 pub refresh_token: Option<String>,
1677 pub account_id: Option<String>,
1679 pub extra: Option<serde_json::Value>,
1681}
1682
1683#[derive(Debug, Clone)]
1685pub struct ProviderConfig {
1686 pub provider_type: DriverId,
1688 pub api_key: Option<String>,
1690 pub base_url: Option<String>,
1692 pub metadata: ProviderMetadata,
1694}
1695
1696impl ProviderConfig {
1697 pub fn new(provider_type: DriverId) -> Self {
1699 Self {
1700 provider_type,
1701 api_key: None,
1702 base_url: None,
1703 metadata: ProviderMetadata::default(),
1704 }
1705 }
1706
1707 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1709 self.api_key = Some(api_key.into());
1710 self
1711 }
1712
1713 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1715 self.base_url = Some(base_url.into());
1716 self
1717 }
1718
1719 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1721 self.metadata = metadata;
1722 self
1723 }
1724}
1725
1726#[derive(Debug, Clone)]
1732pub struct DriverConfig {
1733 pub provider_type: DriverId,
1735 pub api_key: Option<String>,
1741 pub credentials: std::collections::BTreeMap<String, String>,
1747 pub base_url: Option<String>,
1749 pub metadata: ProviderMetadata,
1751}
1752
1753impl DriverConfig {
1754 pub fn from_provider_config(config: &ProviderConfig) -> Self {
1760 Self {
1761 provider_type: config.provider_type.clone(),
1762 credentials: crate::credential_schema::parse_credential_document(
1763 config.api_key.as_deref(),
1764 ),
1765 api_key: config.api_key.clone(),
1766 base_url: config.base_url.clone(),
1767 metadata: config.metadata.clone(),
1768 }
1769 }
1770
1771 pub fn credential(&self, name: &str) -> Option<&str> {
1773 self.credentials
1774 .get(name)
1775 .map(String::as_str)
1776 .filter(|s| !s.is_empty())
1777 }
1778}
1779
1780impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1781 fn from(model: &crate::traits::ResolvedModel) -> Self {
1782 Self {
1783 provider_type: model.provider_type.clone(),
1784 api_key: model.api_key.clone(),
1785 base_url: model.base_url.clone(),
1786 metadata: model.provider_metadata.clone().unwrap_or_default(),
1787 }
1788 }
1789}
1790
1791pub type BoxedChatDriver = Box<dyn ChatDriver>;
1793
1794#[derive(Debug, Clone)]
1800pub struct EmbedRequest {
1801 pub texts: Vec<String>,
1803 pub model: String,
1805}
1806
1807#[derive(Debug, Clone)]
1809pub struct EmbedResponse {
1810 pub embeddings: Vec<Vec<f32>>,
1812 pub usage_tokens: Option<u32>,
1815}
1816
1817#[derive(Debug, thiserror::Error)]
1819pub enum EmbeddingsDriverError {
1820 #[error("embeddings provider returned an error: {0}")]
1821 Provider(String),
1822 #[error("embeddings request failed: {0}")]
1823 Transport(String),
1824}
1825
1826#[async_trait]
1832pub trait EmbeddingsDriver: Send + Sync {
1833 async fn embed(
1835 &self,
1836 request: EmbedRequest,
1837 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1838}
1839
1840pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1842
1843pub type EmbeddingsDriverFactory =
1845 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1846
1847pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1856
1857#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1863#[serde(rename_all = "snake_case")]
1864pub enum ServiceKind {
1865 Chat,
1867 Embeddings,
1869 Realtime,
1871 Images,
1873 Rerank,
1875}
1876
1877impl std::fmt::Display for ServiceKind {
1878 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1879 let s = match self {
1880 ServiceKind::Chat => "chat",
1881 ServiceKind::Embeddings => "embeddings",
1882 ServiceKind::Realtime => "realtime",
1883 ServiceKind::Images => "images",
1884 ServiceKind::Rerank => "rerank",
1885 };
1886 f.write_str(s)
1887 }
1888}
1889
1890#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1903pub enum DriverOAuthFlow {
1904 OpenRouterPkce,
1912}
1913
1914#[derive(Debug, Clone)]
1919pub struct DriverOAuthConfig {
1920 pub authorize_url: String,
1922 pub token_url: String,
1924 pub flow: DriverOAuthFlow,
1926}
1927
1928impl DriverOAuthConfig {
1929 pub fn openrouter() -> Self {
1931 Self {
1932 authorize_url: "https://openrouter.ai/auth".to_string(),
1933 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1934 flow: DriverOAuthFlow::OpenRouterPkce,
1935 }
1936 }
1937}
1938
1939#[derive(Clone)]
1946pub struct DriverDescriptor {
1947 pub id: DriverId,
1949 pub display_name: String,
1951 pub services: Vec<ServiceKind>,
1953 pub credential_schema: CredentialFormSchema,
1955 pub oauth: Option<DriverOAuthConfig>,
1958 pub chat: Option<DriverFactory>,
1960 pub embeddings: Option<EmbeddingsDriverFactory>,
1962}
1963
1964impl DriverDescriptor {
1965 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1970 where
1971 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1972 {
1973 let id = id.into();
1974 Self {
1975 display_name: default_display_name(&id),
1976 credential_schema: default_credential_schema(&id),
1977 services: vec![ServiceKind::Chat],
1978 oauth: None,
1979 chat: Some(Arc::new(factory)),
1980 embeddings: None,
1981 id,
1982 }
1983 }
1984
1985 pub fn supports(&self, service: ServiceKind) -> bool {
1987 self.services.contains(&service)
1988 }
1989}
1990
1991impl std::fmt::Debug for DriverDescriptor {
1992 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1993 f.debug_struct("DriverDescriptor")
1994 .field("id", &self.id)
1995 .field("display_name", &self.display_name)
1996 .field("services", &self.services)
1997 .field("oauth", &self.oauth.is_some())
1998 .field("chat", &self.chat.is_some())
1999 .field("embeddings", &self.embeddings.is_some())
2000 .finish()
2001 }
2002}
2003
2004fn default_display_name(id: &DriverId) -> String {
2005 match id {
2006 DriverId::OpenAI => "OpenAI".to_string(),
2007 DriverId::OpenRouter => "OpenRouter".to_string(),
2008 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
2009 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
2010 DriverId::Anthropic => "Anthropic".to_string(),
2011 DriverId::Gemini => "Google Gemini".to_string(),
2012 DriverId::Bedrock => "AWS Bedrock".to_string(),
2013 DriverId::Mai => "Microsoft MAI".to_string(),
2014 DriverId::Fireworks => "Fireworks AI".to_string(),
2015 DriverId::LlmSim => "LLM Simulator".to_string(),
2016 DriverId::External(id) => id.to_string(),
2017 }
2018}
2019
2020fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
2021 match id {
2022 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
2024 _ => CredentialFormSchema::api_key(String::new()),
2025 }
2026}
2027
2028#[derive(Clone, Default)]
2048pub struct DriverRegistry {
2049 descriptors: HashMap<DriverId, DriverDescriptor>,
2050}
2051
2052impl DriverRegistry {
2053 pub fn new() -> Self {
2055 Self {
2056 descriptors: HashMap::new(),
2057 }
2058 }
2059
2060 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
2066 if self.descriptors.contains_key(&descriptor.id) {
2067 panic!(
2068 "driver already registered for provider '{}'; \
2069 use register_descriptor_or_replace to overwrite intentionally",
2070 descriptor.id
2071 );
2072 }
2073 self.descriptors.insert(descriptor.id.clone(), descriptor);
2074 }
2075
2076 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
2078 self.descriptors.insert(descriptor.id.clone(), descriptor);
2079 }
2080
2081 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2087 where
2088 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2089 {
2090 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
2091 }
2092
2093 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2098 where
2099 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2100 {
2101 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
2102 }
2103
2104 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
2109 where
2110 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2111 {
2112 self.register(DriverId::external(id), factory);
2113 }
2114
2115 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
2124 let requires_api_key = !matches!(
2128 config.provider_type,
2129 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
2130 );
2131 if requires_api_key && config.api_key.is_none() {
2132 return Err(AgentLoopError::llm(
2133 "API key is required. Configure the API key in provider settings.",
2134 ));
2135 }
2136
2137 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2139 AgentLoopError::driver_not_registered(config.provider_type.to_string())
2140 })?;
2141 let factory = descriptor.chat.as_ref().ok_or_else(|| {
2142 AgentLoopError::llm(format!(
2143 "Provider driver '{}' does not implement the chat service.",
2144 config.provider_type
2145 ))
2146 })?;
2147
2148 let driver_config = DriverConfig::from_provider_config(config);
2150 Ok(factory(&driver_config))
2151 }
2152
2153 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2155 self.descriptors.contains_key(provider_type)
2156 }
2157
2158 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2160 self.descriptors.get(provider_type)
2161 }
2162
2163 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2165 self.descriptors
2166 .get(provider_type)
2167 .is_some_and(|d| d.supports(service))
2168 }
2169
2170 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2172 self.descriptors
2173 .values()
2174 .filter(|d| d.supports(service))
2175 .map(|d| d.id.clone())
2176 .collect()
2177 }
2178
2179 pub fn registered_providers(&self) -> Vec<DriverId> {
2181 self.descriptors.keys().cloned().collect()
2182 }
2183
2184 pub fn create_embeddings_driver(
2192 &self,
2193 config: &ProviderConfig,
2194 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2195 let requires_api_key = !matches!(
2196 config.provider_type,
2197 DriverId::LlmSim | DriverId::External(_)
2198 );
2199 if requires_api_key && config.api_key.is_none() {
2200 return Err(EmbeddingsDriverError::Provider(
2201 "API key is required. Configure the API key in provider settings.".to_string(),
2202 ));
2203 }
2204 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2205 EmbeddingsDriverError::Provider(format!(
2206 "No driver registered for provider '{}'",
2207 config.provider_type
2208 ))
2209 })?;
2210 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2211 EmbeddingsDriverError::Provider(format!(
2212 "Provider driver '{}' does not implement the embeddings service.",
2213 config.provider_type
2214 ))
2215 })?;
2216 let driver_config = DriverConfig::from_provider_config(config);
2217 Ok(factory(&driver_config))
2218 }
2219}
2220
2221const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2226
2227const TRUNCATION_SUFFIX: &str =
2228 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2229
2230fn truncate_tool_result(text: String) -> String {
2231 if text.len() <= MAX_TOOL_RESULT_BYTES {
2232 return text;
2233 }
2234 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2235 let mut end = content_budget;
2236 while end > 0 && !text.is_char_boundary(end) {
2237 end -= 1;
2238 }
2239 let mut truncated = text[..end].to_string();
2240 truncated.push_str(TRUNCATION_SUFFIX);
2241 truncated
2242}
2243
2244#[cfg(test)]
2249mod tests {
2250 use super::*;
2251
2252 #[test]
2253 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2254 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2257 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2259 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2260 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2262 }
2263
2264 #[test]
2265 fn test_resolved_parallel_tool_calls_gating() {
2266 let mut config = LlmCallConfig::from(&RuntimeAgent::new("p", "gpt-5.2"));
2267
2268 assert_eq!(config.resolved_parallel_tool_calls(true), None);
2270 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2271
2272 config.parallel_tool_calls = Some(true);
2274 assert_eq!(config.resolved_parallel_tool_calls(true), Some(true));
2275 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2276
2277 config.parallel_tool_calls = Some(false);
2278 assert_eq!(config.resolved_parallel_tool_calls(true), Some(false));
2279 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2280 }
2281
2282 #[test]
2283 fn test_chat_driver_default_omits_parallel_tool_calls() {
2284 struct DefaultDriver;
2286 #[async_trait]
2287 impl ChatDriver for DefaultDriver {
2288 async fn chat_completion_stream(
2289 &self,
2290 _messages: Vec<LlmMessage>,
2291 _config: &LlmCallConfig,
2292 ) -> Result<LlmResponseStream> {
2293 unreachable!()
2294 }
2295 }
2296 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2297 }
2298
2299 #[test]
2300 fn test_fold_system_messages_none_when_absent() {
2301 let messages = vec![
2302 LlmMessage::text(LlmMessageRole::User, "hi"),
2303 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2304 ];
2305 assert_eq!(fold_system_messages(&messages), None);
2306 }
2307
2308 #[test]
2309 fn test_fold_system_messages_single() {
2310 let messages = vec![
2311 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2312 LlmMessage::text(LlmMessageRole::User, "hi"),
2313 ];
2314 assert_eq!(
2315 fold_system_messages(&messages),
2316 Some("AGENT-PROMPT".to_string())
2317 );
2318 }
2319
2320 #[test]
2321 fn test_fold_system_messages_accumulates_in_order() {
2322 let messages = vec![
2326 LlmMessage::text(LlmMessageRole::System, "A"),
2327 LlmMessage::text(LlmMessageRole::User, "hi"),
2328 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2329 LlmMessage::text(LlmMessageRole::System, "B"),
2330 ];
2331 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2332 }
2333
2334 #[test]
2335 fn test_fold_system_messages_concatenates_parts() {
2336 let messages = vec![LlmMessage::parts(
2337 LlmMessageRole::System,
2338 vec![
2339 LlmContentPart::text("foo"),
2340 LlmContentPart::image("data:image/png;base64,xxx"),
2341 LlmContentPart::text("bar"),
2342 ],
2343 )];
2344 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2345 }
2346
2347 #[test]
2348 fn test_llm_call_config_builder_from_runtime_agent() {
2349 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2350 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2351
2352 assert_eq!(llm_config.model, "gpt-4o");
2353 assert!(llm_config.reasoning_effort.is_none());
2354 assert!(llm_config.temperature.is_none());
2355 assert!(llm_config.max_tokens.is_none());
2356 assert!(llm_config.tools.is_empty());
2357 assert!(llm_config.metadata.is_empty());
2358 assert!(llm_config.openrouter_routing.is_none());
2360 }
2361
2362 #[test]
2363 fn runtime_agent_openrouter_routing_flows_into_call_config() {
2364 let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2368 runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2369 server_tools: vec![OpenRouterServerTool::new(
2370 OpenRouterServerToolKind::WebSearch,
2371 )],
2372 ..Default::default()
2373 });
2374
2375 let llm_config = LlmCallConfig::from(&runtime_agent);
2376 let routing = llm_config
2377 .openrouter_routing
2378 .expect("server-tool routing survives into the call config");
2379 assert_eq!(routing.server_tools.len(), 1);
2380 assert_eq!(
2381 routing.server_tools[0].kind.wire_type(),
2382 "openrouter:web_search"
2383 );
2384 }
2385
2386 #[test]
2387 fn test_llm_call_config_builder_with_metadata() {
2388 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2389 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2390 .with_metadata("session_id", "session_abc123")
2391 .with_metadata("agent_id", "agent_xyz789")
2392 .build();
2393
2394 assert_eq!(
2395 llm_config.metadata.get("session_id"),
2396 Some(&"session_abc123".to_string())
2397 );
2398 assert_eq!(
2399 llm_config.metadata.get("agent_id"),
2400 Some(&"agent_xyz789".to_string())
2401 );
2402 }
2403
2404 #[test]
2405 fn test_llm_call_config_builder_with_metadata_hashmap() {
2406 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2407 let mut metadata = HashMap::new();
2408 metadata.insert("key1".to_string(), "value1".to_string());
2409 metadata.insert("key2".to_string(), "value2".to_string());
2410
2411 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2412 .metadata(metadata)
2413 .build();
2414
2415 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2416 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2417 }
2418
2419 #[test]
2420 fn test_llm_call_config_builder_with_reasoning_effort() {
2421 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2422 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2423 .reasoning_effort("high")
2424 .build();
2425
2426 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2427 }
2428
2429 #[test]
2430 fn test_llm_call_config_builder_with_all_options() {
2431 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2432 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2433 .model("claude-3-opus")
2434 .reasoning_effort("medium")
2435 .temperature(0.7)
2436 .max_tokens(1000)
2437 .build();
2438
2439 assert_eq!(llm_config.model, "claude-3-opus");
2440 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2441 assert_eq!(llm_config.temperature, Some(0.7));
2442 assert_eq!(llm_config.max_tokens, Some(1000));
2443 }
2444
2445 #[test]
2446 fn test_llm_call_config_builder_with_openrouter_routing() {
2447 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2448 let routing = OpenRouterRoutingConfig::fallback_models([
2449 "openai/gpt-5-mini",
2450 "anthropic/claude-sonnet-4.5",
2451 ]);
2452
2453 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2454 .openrouter_routing(routing.clone())
2455 .build();
2456
2457 assert_eq!(llm_config.openrouter_routing, Some(routing));
2458 }
2459
2460 #[test]
2461 fn test_openrouter_fallback_models_empty_is_empty() {
2462 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2463
2464 assert!(routing.is_empty());
2465 assert_eq!(routing.route, None);
2466 }
2467
2468 #[test]
2469 fn test_openrouter_routing_validates_primary_model() {
2470 let routing = OpenRouterRoutingConfig::fallback_models([
2471 "openai/gpt-5-mini",
2472 "anthropic/claude-sonnet-4.5",
2473 ]);
2474
2475 assert!(
2476 routing
2477 .validate_for_primary_model("openai/gpt-5-mini")
2478 .is_ok()
2479 );
2480 let err = routing
2481 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2482 .unwrap_err();
2483 assert!(err.contains("models[0]"));
2484 }
2485
2486 #[test]
2487 fn test_openrouter_routing_rejects_fallback_without_models() {
2488 let routing = OpenRouterRoutingConfig {
2489 route: Some(OpenRouterRoute::Fallback),
2490 ..Default::default()
2491 };
2492
2493 let err = routing
2494 .validate_for_primary_model("openai/gpt-5-mini")
2495 .unwrap_err();
2496 assert!(err.contains("requires at least one model"));
2497 }
2498
2499 #[test]
2500 fn test_openrouter_routing_serializes_request_fields() {
2501 let routing = OpenRouterRoutingConfig {
2502 models: vec![
2503 "openai/gpt-5-mini".to_string(),
2504 "anthropic/claude-sonnet-4.5".to_string(),
2505 ],
2506 route: Some(OpenRouterRoute::Fallback),
2507 provider: Some(OpenRouterProviderRouting {
2508 order: vec!["anthropic".to_string(), "openai".to_string()],
2509 allow_fallbacks: Some(false),
2510 require_parameters: Some(true),
2511 data_collection: Some(OpenRouterDataCollection::Deny),
2512 zdr: Some(true),
2513 sort: Some(OpenRouterProviderSort::Advanced(
2514 OpenRouterProviderSortOptions {
2515 by: OpenRouterProviderSortBy::Throughput,
2516 partition: Some(OpenRouterSortPartition::None),
2517 },
2518 )),
2519 max_price: Some(OpenRouterMaxPrice {
2520 prompt: Some(1.0),
2521 completion: Some(2.0),
2522 ..Default::default()
2523 }),
2524 ..Default::default()
2525 }),
2526 ..Default::default()
2527 };
2528
2529 let json = serde_json::to_value(routing).unwrap();
2530
2531 assert_eq!(
2532 json,
2533 serde_json::json!({
2534 "models": [
2535 "openai/gpt-5-mini",
2536 "anthropic/claude-sonnet-4.5"
2537 ],
2538 "route": "fallback",
2539 "provider": {
2540 "order": ["anthropic", "openai"],
2541 "allow_fallbacks": false,
2542 "require_parameters": true,
2543 "data_collection": "deny",
2544 "zdr": true,
2545 "sort": {
2546 "by": "throughput",
2547 "partition": "none"
2548 },
2549 "max_price": {
2550 "prompt": 1.0,
2551 "completion": 2.0
2552 }
2553 }
2554 })
2555 );
2556 }
2557
2558 #[test]
2559 fn test_provider_type_parsing() {
2560 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2561 assert_eq!(
2562 "openrouter".parse::<DriverId>().unwrap(),
2563 DriverId::OpenRouter
2564 );
2565 assert_eq!(
2566 "openai_completions".parse::<DriverId>().unwrap(),
2567 DriverId::OpenAICompletions
2568 );
2569 assert_eq!(
2570 "azure_openai".parse::<DriverId>().unwrap(),
2571 DriverId::AzureOpenAI
2572 );
2573 assert_eq!(
2574 "anthropic".parse::<DriverId>().unwrap(),
2575 DriverId::Anthropic
2576 );
2577 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2578 assert_eq!(
2580 "ollama".parse::<DriverId>().unwrap(),
2581 DriverId::external("ollama")
2582 );
2583 assert_eq!(
2584 "custom".parse::<DriverId>().unwrap(),
2585 DriverId::external("custom")
2586 );
2587 }
2588
2589 #[test]
2590 fn test_external_provider_id_is_case_insensitive() {
2591 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2594 assert_eq!(
2595 "Ollama".parse::<DriverId>().unwrap(),
2596 "ollama".parse::<DriverId>().unwrap()
2597 );
2598 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2599 assert_eq!(
2601 DriverId::external("MyProvider"),
2602 "myprovider".parse::<DriverId>().unwrap()
2603 );
2604 }
2605
2606 #[test]
2607 fn test_provider_type_display() {
2608 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2609 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2610 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2611 assert_eq!(
2612 DriverId::OpenAICompletions.to_string(),
2613 "openai_completions"
2614 );
2615 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2616 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2617 }
2618
2619 #[test]
2620 fn test_provider_config_builder() {
2621 let config = ProviderConfig::new(DriverId::Anthropic)
2622 .with_api_key("test-key")
2623 .with_base_url("https://custom.api.com");
2624
2625 assert_eq!(config.provider_type, DriverId::Anthropic);
2626 assert_eq!(config.api_key, Some("test-key".to_string()));
2627 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2628 }
2629
2630 #[test]
2631 fn test_driver_registry_requires_api_key() {
2632 let mut registry = DriverRegistry::new();
2634 registry.register(DriverId::OpenAI, |_config| {
2635 struct MockDriver;
2637 #[async_trait]
2638 impl ChatDriver for MockDriver {
2639 async fn chat_completion_stream(
2640 &self,
2641 _messages: Vec<LlmMessage>,
2642 _config: &LlmCallConfig,
2643 ) -> Result<LlmResponseStream> {
2644 unimplemented!()
2645 }
2646 }
2647 Box::new(MockDriver)
2648 });
2649
2650 let config = ProviderConfig::new(DriverId::OpenAI);
2652 let result = registry.create_chat_driver(&config);
2653 assert!(result.is_err());
2654
2655 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2657 let result = registry.create_chat_driver(&config_with_key);
2658 assert!(result.is_ok());
2659 }
2660
2661 #[test]
2662 fn test_driver_registry_returns_error_for_unregistered_provider() {
2663 let registry = DriverRegistry::new();
2664 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2665
2666 let result = registry.create_chat_driver(&config);
2667
2668 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2670 assert_eq!(provider, "anthropic");
2671 } else {
2672 panic!("Expected DriverNotRegistered error");
2673 }
2674 }
2675
2676 #[test]
2677 fn test_driver_registry_registration() {
2678 let mut registry = DriverRegistry::new();
2679
2680 assert!(!registry.has_driver(&DriverId::OpenAI));
2681 assert!(!registry.has_driver(&DriverId::Anthropic));
2682
2683 registry.register(DriverId::OpenAI, |_config| {
2684 struct MockDriver;
2685 #[async_trait]
2686 impl ChatDriver for MockDriver {
2687 async fn chat_completion_stream(
2688 &self,
2689 _messages: Vec<LlmMessage>,
2690 _config: &LlmCallConfig,
2691 ) -> Result<LlmResponseStream> {
2692 unimplemented!()
2693 }
2694 }
2695 Box::new(MockDriver)
2696 });
2697
2698 assert!(registry.has_driver(&DriverId::OpenAI));
2699 assert!(!registry.has_driver(&DriverId::Anthropic));
2700 }
2701
2702 #[test]
2703 fn test_register_external_and_create_driver_without_api_key() {
2704 struct MockDriver;
2705 #[async_trait]
2706 impl ChatDriver for MockDriver {
2707 async fn chat_completion_stream(
2708 &self,
2709 _messages: Vec<LlmMessage>,
2710 _config: &LlmCallConfig,
2711 ) -> Result<LlmResponseStream> {
2712 unimplemented!()
2713 }
2714 }
2715
2716 let mut registry = DriverRegistry::new();
2717 registry.register_external("openai-codex", |config| {
2718 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2720 Box::new(MockDriver)
2721 });
2722
2723 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2724
2725 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2727 ProviderMetadata {
2728 refresh_token: Some("rt".into()),
2729 ..Default::default()
2730 },
2731 );
2732 assert!(registry.create_chat_driver(&config).is_ok());
2733 }
2734
2735 #[test]
2736 fn test_register_defaults_to_chat_only_descriptor() {
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::Anthropic, |_config| Box::new(MockDriver));
2751
2752 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2753 assert_eq!(descriptor.display_name, "Anthropic");
2754 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2755 assert!(descriptor.chat.is_some());
2756 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2758 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2759 assert!(descriptor.credential_schema.fields[0].required);
2760
2761 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2763 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2764 assert!(sim.credential_schema.fields.is_empty());
2765 }
2766
2767 #[test]
2768 fn test_descriptor_services_and_lookup() {
2769 struct MockDriver;
2770 #[async_trait]
2771 impl ChatDriver for MockDriver {
2772 async fn chat_completion_stream(
2773 &self,
2774 _messages: Vec<LlmMessage>,
2775 _config: &LlmCallConfig,
2776 ) -> Result<LlmResponseStream> {
2777 unimplemented!()
2778 }
2779 }
2780
2781 let mut registry = DriverRegistry::new();
2782 registry.register_descriptor(DriverDescriptor {
2783 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2784 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2785 });
2786 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2787
2788 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2789 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2790 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2791 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2792
2793 let realtime = registry.providers_for(ServiceKind::Realtime);
2794 assert_eq!(realtime, vec![DriverId::OpenAI]);
2795 let mut chat = registry.providers_for(ServiceKind::Chat);
2796 chat.sort_by_key(|p| p.to_string());
2797 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2798 }
2799
2800 #[test]
2801 fn test_create_chat_driver_fails_without_chat_factory() {
2802 let mut registry = DriverRegistry::new();
2803 registry.register_descriptor(DriverDescriptor {
2804 id: DriverId::external("embeddings-only"),
2805 display_name: "Embeddings Only".to_string(),
2806 services: vec![ServiceKind::Embeddings],
2807 credential_schema: CredentialFormSchema::empty(),
2808 oauth: None,
2809 chat: None,
2810 embeddings: None,
2811 });
2812
2813 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2814 let err = match registry.create_chat_driver(&config) {
2815 Ok(_) => panic!("expected error for missing chat factory"),
2816 Err(err) => err,
2817 };
2818 assert!(
2819 err.to_string()
2820 .contains("does not implement the chat service"),
2821 "unexpected error: {err}"
2822 );
2823 }
2824
2825 #[test]
2826 #[should_panic(expected = "already registered")]
2827 fn test_register_duplicate_panics() {
2828 struct MockDriver;
2829 #[async_trait]
2830 impl ChatDriver for MockDriver {
2831 async fn chat_completion_stream(
2832 &self,
2833 _messages: Vec<LlmMessage>,
2834 _config: &LlmCallConfig,
2835 ) -> Result<LlmResponseStream> {
2836 unimplemented!()
2837 }
2838 }
2839
2840 let mut registry = DriverRegistry::new();
2841 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2842 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2844 }
2845
2846 #[test]
2847 fn test_register_or_replace_overwrites() {
2848 struct MockDriver;
2849 #[async_trait]
2850 impl ChatDriver for MockDriver {
2851 async fn chat_completion_stream(
2852 &self,
2853 _messages: Vec<LlmMessage>,
2854 _config: &LlmCallConfig,
2855 ) -> Result<LlmResponseStream> {
2856 unimplemented!()
2857 }
2858 }
2859
2860 let mut registry = DriverRegistry::new();
2861 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2862 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2864 assert!(registry.has_driver(&DriverId::LlmSim));
2865 }
2866
2867 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2872
2873 #[test]
2874 fn test_message_has_image_files_with_image_file() {
2875 let message = Message {
2876 id: uuid::Uuid::new_v4().into(),
2877 role: MessageRole::User,
2878 content: vec![
2879 ContentPart::Text(TextContentPart {
2880 text: "Look at this image".to_string(),
2881 }),
2882 ContentPart::ImageFile(ImageFileContentPart {
2883 image_id: uuid::Uuid::new_v4().into(),
2884 filename: Some("test.png".to_string()),
2885 }),
2886 ],
2887 phase: None,
2888 thinking: None,
2889 thinking_signature: None,
2890 controls: None,
2891 metadata: None,
2892 external_actor: None,
2893 created_at: chrono::Utc::now(),
2894 };
2895
2896 assert!(LlmMessage::message_has_image_files(&message));
2897 }
2898
2899 #[test]
2900 fn test_message_has_image_files_without_image_file() {
2901 let message = Message {
2902 id: uuid::Uuid::new_v4().into(),
2903 role: MessageRole::User,
2904 content: vec![ContentPart::Text(TextContentPart {
2905 text: "Just text".to_string(),
2906 })],
2907 phase: None,
2908 thinking: None,
2909 thinking_signature: None,
2910 controls: None,
2911 metadata: None,
2912 external_actor: None,
2913 created_at: chrono::Utc::now(),
2914 };
2915
2916 assert!(!LlmMessage::message_has_image_files(&message));
2917 }
2918
2919 #[test]
2920 fn test_extract_image_file_ids() {
2921 let id1 = uuid::Uuid::new_v4();
2922 let id2 = uuid::Uuid::new_v4();
2923
2924 let message = Message {
2925 id: uuid::Uuid::new_v4().into(),
2926 role: MessageRole::User,
2927 content: vec![
2928 ContentPart::Text(TextContentPart {
2929 text: "Look at these images".to_string(),
2930 }),
2931 ContentPart::ImageFile(ImageFileContentPart {
2932 image_id: id1.into(),
2933 filename: Some("test1.png".to_string()),
2934 }),
2935 ContentPart::ImageFile(ImageFileContentPart {
2936 image_id: id2.into(),
2937 filename: Some("test2.png".to_string()),
2938 }),
2939 ],
2940 phase: None,
2941 thinking: None,
2942 thinking_signature: None,
2943 controls: None,
2944 metadata: None,
2945 external_actor: None,
2946 created_at: chrono::Utc::now(),
2947 };
2948
2949 let ids = LlmMessage::extract_image_file_ids(&message);
2950 assert_eq!(ids.len(), 2);
2951 assert!(ids.contains(&id1));
2952 assert!(ids.contains(&id2));
2953 }
2954
2955 #[test]
2956 fn test_from_message_with_images_text_only() {
2957 let message = Message {
2958 id: uuid::Uuid::new_v4().into(),
2959 role: MessageRole::User,
2960 content: vec![ContentPart::Text(TextContentPart {
2961 text: "Hello".to_string(),
2962 })],
2963 phase: None,
2964 thinking: None,
2965 thinking_signature: None,
2966 controls: None,
2967 metadata: None,
2968 external_actor: None,
2969 created_at: chrono::Utc::now(),
2970 };
2971
2972 let resolved = std::collections::HashMap::new();
2973 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2974
2975 assert_eq!(llm_message.role, LlmMessageRole::User);
2976 match llm_message.content {
2977 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2978 _ => panic!("Expected text content"),
2979 }
2980 }
2981
2982 #[test]
2983 fn test_from_message_with_images_resolved_image() {
2984 let image_id = uuid::Uuid::new_v4();
2985 let message = Message {
2986 id: uuid::Uuid::new_v4().into(),
2987 role: MessageRole::User,
2988 content: vec![
2989 ContentPart::Text(TextContentPart {
2990 text: "Look at this".to_string(),
2991 }),
2992 ContentPart::ImageFile(ImageFileContentPart {
2993 image_id: image_id.into(),
2994 filename: Some("test.png".to_string()),
2995 }),
2996 ],
2997 phase: None,
2998 thinking: None,
2999 thinking_signature: None,
3000 controls: None,
3001 metadata: None,
3002 external_actor: None,
3003 created_at: chrono::Utc::now(),
3004 };
3005
3006 let mut resolved = std::collections::HashMap::new();
3007 resolved.insert(
3008 image_id,
3009 crate::ResolvedImage::new("base64data", "image/png"),
3010 );
3011
3012 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
3013
3014 match &llm_message.content {
3015 LlmMessageContent::Parts(parts) => {
3016 assert_eq!(parts.len(), 2);
3017 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
3019 if let LlmContentPart::Image { url } = &parts[1] {
3021 assert!(url.starts_with("data:image/png;base64,"));
3022 } else {
3023 panic!("Expected image content part");
3024 }
3025 }
3026 _ => panic!("Expected parts content"),
3027 }
3028 }
3029
3030 #[test]
3031 fn test_from_message_with_images_unresolved_image() {
3032 let image_id = uuid::Uuid::new_v4();
3033 let message = Message {
3034 id: uuid::Uuid::new_v4().into(),
3035 role: MessageRole::User,
3036 content: vec![ContentPart::ImageFile(ImageFileContentPart {
3037 image_id: image_id.into(),
3038 filename: Some("missing.png".to_string()),
3039 })],
3040 phase: None,
3041 thinking: None,
3042 thinking_signature: None,
3043 controls: None,
3044 metadata: None,
3045 external_actor: None,
3046 created_at: chrono::Utc::now(),
3047 };
3048
3049 let resolved = std::collections::HashMap::new();
3051 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
3052
3053 match &llm_message.content {
3056 LlmMessageContent::Text(text) => {
3057 assert!(text.contains("Image not found"));
3058 }
3059 LlmMessageContent::Parts(parts) => {
3060 assert_eq!(parts.len(), 1);
3061 if let LlmContentPart::Text { text } = &parts[0] {
3062 assert!(text.contains("Image not found"));
3063 } else {
3064 panic!("Expected text placeholder for missing image");
3065 }
3066 }
3067 }
3068 }
3069
3070 #[test]
3071 fn test_prepend_text_prefix_simple_text() {
3072 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
3073 msg.prepend_text_prefix("[Alice] ");
3074 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
3075 }
3076
3077 #[test]
3078 fn test_prepend_text_prefix_parts() {
3079 let mut msg = LlmMessage::parts(
3080 LlmMessageRole::User,
3081 vec![
3082 LlmContentPart::Text {
3083 text: "Hello".to_string(),
3084 },
3085 LlmContentPart::Image {
3086 url: "data:image/png;base64,abc".to_string(),
3087 },
3088 ],
3089 );
3090 msg.prepend_text_prefix("[Bob] ");
3091 match &msg.content {
3092 LlmMessageContent::Parts(parts) => {
3093 if let LlmContentPart::Text { text } = &parts[0] {
3094 assert_eq!(text, "[Bob] Hello");
3095 } else {
3096 panic!("Expected text part");
3097 }
3098 }
3099 _ => panic!("Expected parts content"),
3100 }
3101 }
3102
3103 #[test]
3104 fn test_prepend_text_prefix_parts_no_text() {
3105 let mut msg = LlmMessage::parts(
3106 LlmMessageRole::User,
3107 vec![LlmContentPart::Image {
3108 url: "data:image/png;base64,abc".to_string(),
3109 }],
3110 );
3111 msg.prepend_text_prefix("[Eve] ");
3112 match &msg.content {
3113 LlmMessageContent::Parts(parts) => {
3114 assert_eq!(parts.len(), 2);
3115 if let LlmContentPart::Text { text } = &parts[0] {
3116 assert_eq!(text, "[Eve] ");
3117 } else {
3118 panic!("Expected prepended text part");
3119 }
3120 }
3121 _ => panic!("Expected parts content"),
3122 }
3123 }
3124
3125 #[test]
3126 fn test_openrouter_plugin_config_is_empty() {
3127 assert!(OpenRouterPluginConfig::default().is_empty());
3128 assert!(
3129 !OpenRouterPluginConfig {
3130 web: Some(OpenRouterWebSearchPlugin::default()),
3131 file: None,
3132 }
3133 .is_empty()
3134 );
3135 assert!(
3136 !OpenRouterPluginConfig {
3137 web: None,
3138 file: Some(OpenRouterFilePlugin {}),
3139 }
3140 .is_empty()
3141 );
3142 }
3143
3144 #[test]
3145 fn test_openrouter_routing_is_empty_with_plugins() {
3146 let with_plugins = OpenRouterRoutingConfig {
3147 plugins: Some(OpenRouterPluginConfig {
3148 web: Some(OpenRouterWebSearchPlugin::default()),
3149 file: None,
3150 }),
3151 ..Default::default()
3152 };
3153 assert!(!with_plugins.is_empty());
3154
3155 let empty_plugins = OpenRouterRoutingConfig {
3156 plugins: Some(OpenRouterPluginConfig::default()),
3157 ..Default::default()
3158 };
3159 assert!(empty_plugins.is_empty());
3160 }
3161
3162 #[test]
3163 fn test_openrouter_web_search_plugin_serialization() {
3164 let plugin = OpenRouterWebSearchPlugin {
3165 max_results: Some(10),
3166 search_prompt: Some("search for Rust crates".to_string()),
3167 };
3168 let json = serde_json::to_value(&plugin).unwrap();
3169 assert_eq!(json["max_results"], 10);
3170 assert_eq!(json["search_prompt"], "search for Rust crates");
3171 }
3172
3173 #[test]
3174 fn test_openrouter_web_search_plugin_omits_none_fields() {
3175 let plugin = OpenRouterWebSearchPlugin::default();
3176 let json = serde_json::to_value(&plugin).unwrap();
3177 assert!(json.get("max_results").is_none());
3178 assert!(json.get("search_prompt").is_none());
3179 }
3180
3181 #[test]
3182 fn test_capacity_strategy_shared_capacity_is_noop() {
3183 let base = OpenRouterRoutingConfig {
3184 models: vec!["openai/gpt-5-mini".to_string()],
3185 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3186 ..Default::default()
3187 };
3188 let result = base.apply_capacity_strategy().unwrap();
3189 assert_eq!(
3190 result.capacity_strategy,
3191 Some(OpenRouterCapacityStrategy::SharedCapacity)
3192 );
3193 assert!(result.provider.is_none());
3194 }
3195
3196 #[test]
3197 fn test_capacity_strategy_none_is_noop() {
3198 let base = OpenRouterRoutingConfig {
3199 models: vec!["openai/gpt-5-mini".to_string()],
3200 capacity_strategy: None,
3201 ..Default::default()
3202 };
3203 let result = base.apply_capacity_strategy().unwrap();
3204 assert!(result.provider.is_none());
3205 }
3206
3207 #[test]
3208 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
3209 let base = OpenRouterRoutingConfig {
3210 models: vec!["openai/gpt-5-mini".to_string()],
3211 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3212 ..Default::default()
3213 };
3214 let result = base.apply_capacity_strategy().unwrap();
3215 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
3216 assert_eq!(provider.allow_fallbacks, Some(true));
3217 }
3218
3219 #[test]
3220 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
3221 let base = OpenRouterRoutingConfig {
3223 models: vec!["openai/gpt-5-mini".to_string()],
3224 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3225 provider: Some(OpenRouterProviderRouting {
3226 allow_fallbacks: Some(false),
3227 ..Default::default()
3228 }),
3229 ..Default::default()
3230 };
3231 let result = base.apply_capacity_strategy().unwrap();
3232 let provider = result.provider.as_ref().unwrap();
3233 assert_eq!(provider.allow_fallbacks, Some(false));
3234 }
3235
3236 #[test]
3237 fn test_capacity_strategy_byok_only_requires_provider_only() {
3238 let base = OpenRouterRoutingConfig {
3239 models: vec!["openai/gpt-5-mini".to_string()],
3240 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3241 ..Default::default()
3242 };
3243 let err = base.apply_capacity_strategy().unwrap_err();
3244 assert!(
3245 err.contains("provider.only"),
3246 "error should mention provider.only: {err}"
3247 );
3248 }
3249
3250 #[test]
3251 fn test_capacity_strategy_byok_only_disables_fallbacks() {
3252 let base = OpenRouterRoutingConfig {
3253 models: vec!["openai/gpt-5-mini".to_string()],
3254 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3255 provider: Some(OpenRouterProviderRouting {
3256 only: vec!["my-byok-provider".to_string()],
3257 ..Default::default()
3258 }),
3259 ..Default::default()
3260 };
3261 let result = base.apply_capacity_strategy().unwrap();
3262 let provider = result.provider.as_ref().unwrap();
3263 assert_eq!(provider.allow_fallbacks, Some(false));
3264 assert_eq!(provider.only, vec!["my-byok-provider"]);
3265 }
3266
3267 #[test]
3268 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3269 let with_strategy = OpenRouterRoutingConfig {
3270 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3271 ..Default::default()
3272 };
3273 assert!(!with_strategy.is_empty());
3274
3275 let byok_first = OpenRouterRoutingConfig {
3276 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3277 ..Default::default()
3278 };
3279 assert!(!byok_first.is_empty());
3280
3281 let shared = OpenRouterRoutingConfig {
3282 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3283 ..Default::default()
3284 };
3285 assert!(shared.is_empty());
3286 }
3287
3288 #[test]
3293 fn test_preset_no_presets_is_noop() {
3294 let base = OpenRouterRoutingConfig {
3295 models: vec!["openai/gpt-5-mini".to_string()],
3296 ..Default::default()
3297 };
3298 let result = base.apply_presets().unwrap();
3299 assert_eq!(result, base);
3300 }
3301
3302 #[test]
3303 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3304 let base = OpenRouterRoutingConfig {
3305 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3306 ..Default::default()
3307 };
3308 let result = base.apply_presets().unwrap();
3309 assert!(result.presets.is_empty(), "presets cleared after apply");
3310 let provider = result.provider.expect("provider set by preset");
3311 assert_eq!(provider.require_parameters, Some(true));
3312 assert_eq!(
3313 provider.sort,
3314 Some(OpenRouterProviderSort::Simple(
3315 OpenRouterProviderSortBy::Price
3316 ))
3317 );
3318 }
3319
3320 #[test]
3321 fn test_preset_lowest_latency_review_sets_sort_throughput() {
3322 let base = OpenRouterRoutingConfig {
3323 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3324 ..Default::default()
3325 };
3326 let result = base.apply_presets().unwrap();
3327 let provider = result.provider.expect("provider set by preset");
3328 assert_eq!(
3329 provider.sort,
3330 Some(OpenRouterProviderSort::Simple(
3331 OpenRouterProviderSortBy::Throughput
3332 ))
3333 );
3334 }
3335
3336 #[test]
3337 fn test_preset_zdr_only_sets_zdr() {
3338 let base = OpenRouterRoutingConfig {
3339 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3340 ..Default::default()
3341 };
3342 let result = base.apply_presets().unwrap();
3343 let provider = result.provider.expect("provider set");
3344 assert_eq!(provider.zdr, Some(true));
3345 }
3346
3347 #[test]
3348 fn test_preset_byok_first_sets_allow_fallbacks() {
3349 let base = OpenRouterRoutingConfig {
3350 presets: vec![OpenRouterRoutingPreset::ByokFirst],
3351 ..Default::default()
3352 };
3353 let result = base.apply_presets().unwrap();
3354 let provider = result.provider.expect("provider set");
3355 assert_eq!(provider.allow_fallbacks, Some(true));
3356 }
3357
3358 #[test]
3359 fn test_preset_no_data_collection_sets_data_collection_deny() {
3360 let base = OpenRouterRoutingConfig {
3361 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3362 ..Default::default()
3363 };
3364 let result = base.apply_presets().unwrap();
3365 let provider = result.provider.expect("provider set");
3366 assert_eq!(
3367 provider.data_collection,
3368 Some(OpenRouterDataCollection::Deny)
3369 );
3370 }
3371
3372 #[test]
3373 fn test_preset_strict_json_sets_require_parameters() {
3374 let base = OpenRouterRoutingConfig {
3375 presets: vec![OpenRouterRoutingPreset::StrictJson],
3376 ..Default::default()
3377 };
3378 let result = base.apply_presets().unwrap();
3379 let provider = result.provider.expect("provider set");
3380 assert_eq!(provider.require_parameters, Some(true));
3381 }
3382
3383 #[test]
3384 fn test_preset_reasoning_required_sets_require_parameters() {
3385 let base = OpenRouterRoutingConfig {
3386 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3387 ..Default::default()
3388 };
3389 let result = base.apply_presets().unwrap();
3390 let provider = result.provider.expect("provider set");
3391 assert_eq!(provider.require_parameters, Some(true));
3392 }
3393
3394 #[test]
3395 fn test_preset_max_price_converts_usd_per_million() {
3396 let base = OpenRouterRoutingConfig {
3397 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3398 prompt_usd_per_million: Some(5.0),
3399 completion_usd_per_million: Some(15.0),
3400 }],
3401 ..Default::default()
3402 };
3403 let result = base.apply_presets().unwrap();
3404 let provider = result.provider.expect("provider set");
3405 let max_price = provider.max_price.expect("max_price set");
3406 let prompt = max_price.prompt.expect("prompt set");
3408 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3409 let completion = max_price.completion.expect("completion set");
3410 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3411 }
3412
3413 #[test]
3414 fn test_preset_max_price_rejects_negative_values() {
3415 let base = OpenRouterRoutingConfig {
3416 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3417 prompt_usd_per_million: Some(-1.0),
3418 completion_usd_per_million: None,
3419 }],
3420 ..Default::default()
3421 };
3422 let err = base.apply_presets().unwrap_err();
3423 assert!(
3424 err.contains("non-negative"),
3425 "error should mention non-negative: {err}"
3426 );
3427 }
3428
3429 #[test]
3430 fn test_preset_max_price_both_none_no_provider_field() {
3431 let base = OpenRouterRoutingConfig {
3432 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3433 prompt_usd_per_million: None,
3434 completion_usd_per_million: None,
3435 }],
3436 ..Default::default()
3437 };
3438 let result = base.apply_presets().unwrap();
3439 assert!(
3440 result.provider.is_none(),
3441 "MaxPrice with no dimensions should not produce a provider field"
3442 );
3443 }
3444
3445 #[test]
3446 fn test_preset_explicit_provider_overrides_preset() {
3447 let base = OpenRouterRoutingConfig {
3448 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3449 provider: Some(OpenRouterProviderRouting {
3450 sort: Some(OpenRouterProviderSort::Simple(
3452 OpenRouterProviderSortBy::Throughput,
3453 )),
3454 ..Default::default()
3455 }),
3456 ..Default::default()
3457 };
3458 let result = base.apply_presets().unwrap();
3459 let provider = result.provider.expect("provider set");
3460 assert_eq!(
3462 provider.sort,
3463 Some(OpenRouterProviderSort::Simple(
3464 OpenRouterProviderSortBy::Throughput
3465 ))
3466 );
3467 assert_eq!(provider.require_parameters, Some(true));
3469 }
3470
3471 #[test]
3472 fn test_preset_multiple_presets_combined() {
3473 let base = OpenRouterRoutingConfig {
3474 presets: vec![
3475 OpenRouterRoutingPreset::ZdrOnly,
3476 OpenRouterRoutingPreset::NoDataCollection,
3477 OpenRouterRoutingPreset::LowestLatencyReview,
3478 ],
3479 ..Default::default()
3480 };
3481 let result = base.apply_presets().unwrap();
3482 let provider = result.provider.expect("provider set");
3483 assert_eq!(provider.zdr, Some(true));
3484 assert_eq!(
3485 provider.data_collection,
3486 Some(OpenRouterDataCollection::Deny)
3487 );
3488 assert_eq!(
3489 provider.sort,
3490 Some(OpenRouterProviderSort::Simple(
3491 OpenRouterProviderSortBy::Throughput
3492 ))
3493 );
3494 }
3495
3496 #[test]
3497 fn test_preset_later_preset_overrides_sort() {
3498 let base = OpenRouterRoutingConfig {
3499 presets: vec![
3500 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3503 ..Default::default()
3504 };
3505 let result = base.apply_presets().unwrap();
3506 let provider = result.provider.expect("provider set");
3507 assert_eq!(
3509 provider.sort,
3510 Some(OpenRouterProviderSort::Simple(
3511 OpenRouterProviderSortBy::Throughput
3512 ))
3513 );
3514 assert_eq!(provider.require_parameters, Some(true));
3516 }
3517
3518 #[test]
3519 fn test_preset_non_empty_in_is_empty() {
3520 let with_preset = OpenRouterRoutingConfig {
3521 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3522 ..Default::default()
3523 };
3524 assert!(!with_preset.is_empty());
3525
3526 let without = OpenRouterRoutingConfig::default();
3527 assert!(without.is_empty());
3528 }
3529}