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 verbosity: Option<String>,
1265 pub metadata: HashMap<String, String>,
1269 pub previous_response_id: Option<String>,
1272 pub tool_search: Option<ToolSearchConfig>,
1274 pub prompt_cache: Option<PromptCacheConfig>,
1276 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1278 pub parallel_tool_calls: Option<bool>,
1285 pub volatile_suffix_len: usize,
1295}
1296
1297impl LlmCallConfig {
1298 pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1308 if supported {
1309 self.parallel_tool_calls
1310 } else {
1311 None
1312 }
1313 }
1314}
1315
1316impl From<&RuntimeAgent> for LlmCallConfig {
1317 fn from(runtime_agent: &RuntimeAgent) -> Self {
1318 Self {
1319 model: runtime_agent.model.clone(),
1320 temperature: runtime_agent.temperature,
1321 max_tokens: runtime_agent.max_tokens,
1322 tools: runtime_agent.tools.clone(),
1323 reasoning_effort: None, speed: None, verbosity: None, metadata: HashMap::new(), previous_response_id: None,
1328 tool_search: runtime_agent.tool_search.clone(),
1329 prompt_cache: runtime_agent.prompt_cache.clone(),
1330 openrouter_routing: runtime_agent.openrouter_routing.clone(),
1331 parallel_tool_calls: runtime_agent.parallel_tool_calls,
1332 volatile_suffix_len: 0,
1333 }
1334 }
1335}
1336
1337#[derive(Debug, Clone)]
1339pub struct LlmResponse {
1340 pub text: String,
1341 pub thinking: Option<String>,
1343 pub thinking_signature: Option<String>,
1345 pub tool_calls: Option<Vec<ToolCall>>,
1346 pub metadata: LlmCompletionMetadata,
1347}
1348
1349pub struct LlmCallConfigBuilder {
1368 config: LlmCallConfig,
1369}
1370
1371impl LlmCallConfigBuilder {
1372 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1374 Self {
1375 config: LlmCallConfig::from(runtime_agent),
1376 }
1377 }
1378
1379 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1381 self.config.reasoning_effort = Some(effort.into());
1382 self
1383 }
1384
1385 pub fn speed(mut self, speed: impl Into<String>) -> Self {
1387 self.config.speed = Some(speed.into());
1388 self
1389 }
1390
1391 pub fn verbosity(mut self, verbosity: impl Into<String>) -> Self {
1393 self.config.verbosity = Some(verbosity.into());
1394 self
1395 }
1396
1397 pub fn model(mut self, model: impl Into<String>) -> Self {
1399 self.config.model = model.into();
1400 self
1401 }
1402
1403 pub fn temperature(mut self, temp: f32) -> Self {
1405 self.config.temperature = Some(temp);
1406 self
1407 }
1408
1409 pub fn max_tokens(mut self, tokens: u32) -> Self {
1411 self.config.max_tokens = Some(tokens);
1412 self
1413 }
1414
1415 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1417 self.config.tools = tools;
1418 self
1419 }
1420
1421 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1426 self.config.metadata = metadata;
1427 self
1428 }
1429
1430 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1432 self.config.metadata.insert(key.into(), value.into());
1433 self
1434 }
1435
1436 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1438 self.config.previous_response_id = id;
1439 self
1440 }
1441
1442 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1444 self.config.tool_search = Some(config);
1445 self
1446 }
1447
1448 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1450 self.config.prompt_cache = Some(config);
1451 self
1452 }
1453
1454 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1456 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1457 self
1458 }
1459
1460 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1462 self.config.parallel_tool_calls = parallel_tool_calls;
1463 self
1464 }
1465
1466 pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1470 self.config.volatile_suffix_len = len;
1471 self
1472 }
1473
1474 pub fn build(self) -> LlmCallConfig {
1476 self.config
1477 }
1478}
1479
1480impl From<&crate::message::Message> for LlmMessage {
1485 fn from(msg: &crate::message::Message) -> Self {
1491 let role = match msg.role {
1492 crate::message::MessageRole::System => LlmMessageRole::System,
1493 crate::message::MessageRole::User => LlmMessageRole::User,
1494 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1495 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1496 };
1497
1498 let tool_calls: Vec<ToolCall> = msg
1500 .tool_calls()
1501 .into_iter()
1502 .map(|tc| ToolCall {
1503 id: tc.id.clone(),
1504 name: tc.name.clone(),
1505 arguments: tc.arguments.clone(),
1506 })
1507 .collect();
1508
1509 LlmMessage {
1510 role,
1511 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1512 tool_calls: if tool_calls.is_empty() {
1513 None
1514 } else {
1515 Some(tool_calls)
1516 },
1517 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1518 phase: msg.phase,
1519 thinking: msg.thinking.clone(),
1520 thinking_signature: msg.thinking_signature.clone(),
1521 }
1522 }
1523}
1524
1525use crate::traits::ResolvedImage;
1530use uuid::Uuid;
1531
1532impl LlmMessage {
1533 pub fn from_message_with_images(
1553 msg: &crate::message::Message,
1554 resolved_images: &HashMap<Uuid, ResolvedImage>,
1555 ) -> Self {
1556 use crate::message::{ContentPart, MessageRole};
1557
1558 let role = match msg.role {
1559 MessageRole::System => LlmMessageRole::System,
1560 MessageRole::User => LlmMessageRole::User,
1561 MessageRole::Agent => LlmMessageRole::Assistant,
1562 MessageRole::ToolResult => LlmMessageRole::Tool,
1563 };
1564
1565 let mut parts: Vec<LlmContentPart> = Vec::new();
1567 let mut tool_calls: Vec<ToolCall> = Vec::new();
1568
1569 for part in &msg.content {
1570 match part {
1571 ContentPart::Text(t) => {
1572 parts.push(LlmContentPart::Text {
1573 text: t.text.clone(),
1574 });
1575 }
1576 ContentPart::Image(img) => {
1577 if let Some(url) = &img.url {
1579 parts.push(LlmContentPart::Image { url: url.clone() });
1580 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1581 {
1582 let data_url = format!("data:{};base64,{}", media_type, base64);
1583 parts.push(LlmContentPart::Image { url: data_url });
1584 }
1585 }
1586 ContentPart::ImageFile(img_file) => {
1587 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1589 parts.push(LlmContentPart::Image {
1590 url: resolved.to_data_url(),
1591 });
1592 } else {
1593 parts.push(LlmContentPart::Text {
1595 text: format!("[Image not found: {}]", img_file.image_id),
1596 });
1597 }
1598 }
1599 ContentPart::ToolCall(tc) => {
1600 tool_calls.push(ToolCall {
1602 id: tc.id.clone(),
1603 name: tc.name.clone(),
1604 arguments: tc.arguments.clone(),
1605 });
1606 }
1607 ContentPart::ToolResult(tr) => {
1608 let text = if let Some(err) = &tr.error {
1610 format!("Tool error: {}", err)
1611 } else if let Some(res) = &tr.result {
1612 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1613 } else {
1614 "{}".to_string()
1615 };
1616 let text = truncate_tool_result(text);
1620 parts.push(LlmContentPart::Text { text });
1621 }
1622 }
1623 }
1624
1625 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1627 if let LlmContentPart::Text { text } = &parts[0] {
1629 LlmMessageContent::Text(text.clone())
1630 } else {
1631 LlmMessageContent::Parts(parts)
1632 }
1633 } else if parts.is_empty() {
1634 LlmMessageContent::Text(String::new())
1636 } else {
1637 LlmMessageContent::Parts(parts)
1639 };
1640
1641 LlmMessage {
1642 role,
1643 content,
1644 tool_calls: if tool_calls.is_empty() {
1645 None
1646 } else {
1647 Some(tool_calls)
1648 },
1649 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1650 phase: msg.phase,
1651 thinking: msg.thinking.clone(),
1652 thinking_signature: msg.thinking_signature.clone(),
1653 }
1654 }
1655
1656 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1658 msg.content.iter().any(|p| p.is_image_file())
1659 }
1660
1661 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1663 msg.content
1664 .iter()
1665 .filter_map(|p| match p {
1666 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1667 _ => None,
1668 })
1669 .collect()
1670 }
1671}
1672
1673pub use crate::provider::DriverId;
1678
1679#[derive(Debug, Clone, Default, PartialEq, Eq)]
1685pub struct ProviderMetadata {
1686 pub refresh_token: Option<String>,
1688 pub account_id: Option<String>,
1690 pub extra: Option<serde_json::Value>,
1692}
1693
1694#[derive(Debug, Clone)]
1696pub struct ProviderConfig {
1697 pub provider_type: DriverId,
1699 pub api_key: Option<String>,
1701 pub base_url: Option<String>,
1703 pub metadata: ProviderMetadata,
1705}
1706
1707impl ProviderConfig {
1708 pub fn new(provider_type: DriverId) -> Self {
1710 Self {
1711 provider_type,
1712 api_key: None,
1713 base_url: None,
1714 metadata: ProviderMetadata::default(),
1715 }
1716 }
1717
1718 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1720 self.api_key = Some(api_key.into());
1721 self
1722 }
1723
1724 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1726 self.base_url = Some(base_url.into());
1727 self
1728 }
1729
1730 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1732 self.metadata = metadata;
1733 self
1734 }
1735}
1736
1737#[derive(Debug, Clone)]
1743pub struct DriverConfig {
1744 pub provider_type: DriverId,
1746 pub api_key: Option<String>,
1752 pub credentials: std::collections::BTreeMap<String, String>,
1758 pub base_url: Option<String>,
1760 pub metadata: ProviderMetadata,
1762}
1763
1764impl DriverConfig {
1765 pub fn from_provider_config(config: &ProviderConfig) -> Self {
1771 Self {
1772 provider_type: config.provider_type.clone(),
1773 credentials: crate::credential_schema::parse_credential_document(
1774 config.api_key.as_deref(),
1775 ),
1776 api_key: config.api_key.clone(),
1777 base_url: config.base_url.clone(),
1778 metadata: config.metadata.clone(),
1779 }
1780 }
1781
1782 pub fn credential(&self, name: &str) -> Option<&str> {
1784 self.credentials
1785 .get(name)
1786 .map(String::as_str)
1787 .filter(|s| !s.is_empty())
1788 }
1789}
1790
1791impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1792 fn from(model: &crate::traits::ResolvedModel) -> Self {
1793 Self {
1794 provider_type: model.provider_type.clone(),
1795 api_key: model.api_key.clone(),
1796 base_url: model.base_url.clone(),
1797 metadata: model.provider_metadata.clone().unwrap_or_default(),
1798 }
1799 }
1800}
1801
1802pub type BoxedChatDriver = Box<dyn ChatDriver>;
1804
1805#[derive(Debug, Clone)]
1811pub struct EmbedRequest {
1812 pub texts: Vec<String>,
1814 pub model: String,
1816}
1817
1818#[derive(Debug, Clone)]
1820pub struct EmbedResponse {
1821 pub embeddings: Vec<Vec<f32>>,
1823 pub usage_tokens: Option<u32>,
1826}
1827
1828#[derive(Debug, thiserror::Error)]
1830pub enum EmbeddingsDriverError {
1831 #[error("embeddings provider returned an error: {0}")]
1832 Provider(String),
1833 #[error("embeddings request failed: {0}")]
1834 Transport(String),
1835}
1836
1837#[async_trait]
1843pub trait EmbeddingsDriver: Send + Sync {
1844 async fn embed(
1846 &self,
1847 request: EmbedRequest,
1848 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1849}
1850
1851pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1853
1854pub type EmbeddingsDriverFactory =
1856 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1857
1858pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1867
1868#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1874#[serde(rename_all = "snake_case")]
1875pub enum ServiceKind {
1876 Chat,
1878 Embeddings,
1880 Realtime,
1882 Images,
1884 Rerank,
1886}
1887
1888impl std::fmt::Display for ServiceKind {
1889 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1890 let s = match self {
1891 ServiceKind::Chat => "chat",
1892 ServiceKind::Embeddings => "embeddings",
1893 ServiceKind::Realtime => "realtime",
1894 ServiceKind::Images => "images",
1895 ServiceKind::Rerank => "rerank",
1896 };
1897 f.write_str(s)
1898 }
1899}
1900
1901#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1914pub enum DriverOAuthFlow {
1915 OpenRouterPkce,
1923}
1924
1925#[derive(Debug, Clone)]
1930pub struct DriverOAuthConfig {
1931 pub authorize_url: String,
1933 pub token_url: String,
1935 pub flow: DriverOAuthFlow,
1937}
1938
1939impl DriverOAuthConfig {
1940 pub fn openrouter() -> Self {
1942 Self {
1943 authorize_url: "https://openrouter.ai/auth".to_string(),
1944 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1945 flow: DriverOAuthFlow::OpenRouterPkce,
1946 }
1947 }
1948}
1949
1950#[derive(Clone)]
1957pub struct DriverDescriptor {
1958 pub id: DriverId,
1960 pub display_name: String,
1962 pub services: Vec<ServiceKind>,
1964 pub credential_schema: CredentialFormSchema,
1966 pub oauth: Option<DriverOAuthConfig>,
1969 pub chat: Option<DriverFactory>,
1971 pub embeddings: Option<EmbeddingsDriverFactory>,
1973}
1974
1975impl DriverDescriptor {
1976 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1981 where
1982 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1983 {
1984 let id = id.into();
1985 Self {
1986 display_name: default_display_name(&id),
1987 credential_schema: default_credential_schema(&id),
1988 services: vec![ServiceKind::Chat],
1989 oauth: None,
1990 chat: Some(Arc::new(factory)),
1991 embeddings: None,
1992 id,
1993 }
1994 }
1995
1996 pub fn supports(&self, service: ServiceKind) -> bool {
1998 self.services.contains(&service)
1999 }
2000}
2001
2002impl std::fmt::Debug for DriverDescriptor {
2003 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2004 f.debug_struct("DriverDescriptor")
2005 .field("id", &self.id)
2006 .field("display_name", &self.display_name)
2007 .field("services", &self.services)
2008 .field("oauth", &self.oauth.is_some())
2009 .field("chat", &self.chat.is_some())
2010 .field("embeddings", &self.embeddings.is_some())
2011 .finish()
2012 }
2013}
2014
2015fn default_display_name(id: &DriverId) -> String {
2016 match id {
2017 DriverId::OpenAI => "OpenAI".to_string(),
2018 DriverId::OpenRouter => "OpenRouter".to_string(),
2019 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
2020 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
2021 DriverId::Anthropic => "Anthropic".to_string(),
2022 DriverId::Gemini => "Google Gemini".to_string(),
2023 DriverId::Bedrock => "AWS Bedrock".to_string(),
2024 DriverId::Mai => "Microsoft MAI".to_string(),
2025 DriverId::Fireworks => "Fireworks AI".to_string(),
2026 DriverId::LlmSim => "LLM Simulator".to_string(),
2027 DriverId::External(id) => id.to_string(),
2028 }
2029}
2030
2031fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
2032 match id {
2033 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
2035 _ => CredentialFormSchema::api_key(String::new()),
2036 }
2037}
2038
2039#[derive(Clone, Default)]
2059pub struct DriverRegistry {
2060 descriptors: HashMap<DriverId, DriverDescriptor>,
2061}
2062
2063impl DriverRegistry {
2064 pub fn new() -> Self {
2066 Self {
2067 descriptors: HashMap::new(),
2068 }
2069 }
2070
2071 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
2077 if self.descriptors.contains_key(&descriptor.id) {
2078 panic!(
2079 "driver already registered for provider '{}'; \
2080 use register_descriptor_or_replace to overwrite intentionally",
2081 descriptor.id
2082 );
2083 }
2084 self.descriptors.insert(descriptor.id.clone(), descriptor);
2085 }
2086
2087 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
2089 self.descriptors.insert(descriptor.id.clone(), descriptor);
2090 }
2091
2092 pub fn register<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(DriverDescriptor::chat_only(provider_type, factory));
2102 }
2103
2104 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2109 where
2110 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2111 {
2112 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
2113 }
2114
2115 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
2120 where
2121 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2122 {
2123 self.register(DriverId::external(id), factory);
2124 }
2125
2126 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
2135 let requires_api_key = !matches!(
2139 config.provider_type,
2140 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
2141 );
2142 if requires_api_key && config.api_key.is_none() {
2143 return Err(AgentLoopError::llm(
2144 "API key is required. Configure the API key in provider settings.",
2145 ));
2146 }
2147
2148 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2150 AgentLoopError::driver_not_registered(config.provider_type.to_string())
2151 })?;
2152 let factory = descriptor.chat.as_ref().ok_or_else(|| {
2153 AgentLoopError::llm(format!(
2154 "Provider driver '{}' does not implement the chat service.",
2155 config.provider_type
2156 ))
2157 })?;
2158
2159 let driver_config = DriverConfig::from_provider_config(config);
2161 Ok(factory(&driver_config))
2162 }
2163
2164 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2166 self.descriptors.contains_key(provider_type)
2167 }
2168
2169 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2171 self.descriptors.get(provider_type)
2172 }
2173
2174 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2176 self.descriptors
2177 .get(provider_type)
2178 .is_some_and(|d| d.supports(service))
2179 }
2180
2181 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2183 self.descriptors
2184 .values()
2185 .filter(|d| d.supports(service))
2186 .map(|d| d.id.clone())
2187 .collect()
2188 }
2189
2190 pub fn registered_providers(&self) -> Vec<DriverId> {
2192 self.descriptors.keys().cloned().collect()
2193 }
2194
2195 pub fn create_embeddings_driver(
2203 &self,
2204 config: &ProviderConfig,
2205 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2206 let requires_api_key = !matches!(
2207 config.provider_type,
2208 DriverId::LlmSim | DriverId::External(_)
2209 );
2210 if requires_api_key && config.api_key.is_none() {
2211 return Err(EmbeddingsDriverError::Provider(
2212 "API key is required. Configure the API key in provider settings.".to_string(),
2213 ));
2214 }
2215 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2216 EmbeddingsDriverError::Provider(format!(
2217 "No driver registered for provider '{}'",
2218 config.provider_type
2219 ))
2220 })?;
2221 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2222 EmbeddingsDriverError::Provider(format!(
2223 "Provider driver '{}' does not implement the embeddings service.",
2224 config.provider_type
2225 ))
2226 })?;
2227 let driver_config = DriverConfig::from_provider_config(config);
2228 Ok(factory(&driver_config))
2229 }
2230}
2231
2232const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2237
2238const TRUNCATION_SUFFIX: &str =
2239 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2240
2241fn truncate_tool_result(text: String) -> String {
2242 if text.len() <= MAX_TOOL_RESULT_BYTES {
2243 return text;
2244 }
2245 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2246 let mut end = content_budget;
2247 while end > 0 && !text.is_char_boundary(end) {
2248 end -= 1;
2249 }
2250 let mut truncated = text[..end].to_string();
2251 truncated.push_str(TRUNCATION_SUFFIX);
2252 truncated
2253}
2254
2255#[cfg(test)]
2260mod tests {
2261 use super::*;
2262
2263 #[test]
2264 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2265 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2268 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2270 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2271 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2273 }
2274
2275 #[test]
2276 fn test_resolved_parallel_tool_calls_gating() {
2277 let mut config = LlmCallConfig::from(&RuntimeAgent::new("p", "gpt-5.2"));
2278
2279 assert_eq!(config.resolved_parallel_tool_calls(true), None);
2281 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2282
2283 config.parallel_tool_calls = Some(true);
2285 assert_eq!(config.resolved_parallel_tool_calls(true), Some(true));
2286 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2287
2288 config.parallel_tool_calls = Some(false);
2289 assert_eq!(config.resolved_parallel_tool_calls(true), Some(false));
2290 assert_eq!(config.resolved_parallel_tool_calls(false), None);
2291 }
2292
2293 #[test]
2294 fn test_chat_driver_default_omits_parallel_tool_calls() {
2295 struct DefaultDriver;
2297 #[async_trait]
2298 impl ChatDriver for DefaultDriver {
2299 async fn chat_completion_stream(
2300 &self,
2301 _messages: Vec<LlmMessage>,
2302 _config: &LlmCallConfig,
2303 ) -> Result<LlmResponseStream> {
2304 unreachable!()
2305 }
2306 }
2307 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2308 }
2309
2310 #[test]
2311 fn test_fold_system_messages_none_when_absent() {
2312 let messages = vec![
2313 LlmMessage::text(LlmMessageRole::User, "hi"),
2314 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2315 ];
2316 assert_eq!(fold_system_messages(&messages), None);
2317 }
2318
2319 #[test]
2320 fn test_fold_system_messages_single() {
2321 let messages = vec![
2322 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2323 LlmMessage::text(LlmMessageRole::User, "hi"),
2324 ];
2325 assert_eq!(
2326 fold_system_messages(&messages),
2327 Some("AGENT-PROMPT".to_string())
2328 );
2329 }
2330
2331 #[test]
2332 fn test_fold_system_messages_accumulates_in_order() {
2333 let messages = vec![
2337 LlmMessage::text(LlmMessageRole::System, "A"),
2338 LlmMessage::text(LlmMessageRole::User, "hi"),
2339 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2340 LlmMessage::text(LlmMessageRole::System, "B"),
2341 ];
2342 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2343 }
2344
2345 #[test]
2346 fn test_fold_system_messages_concatenates_parts() {
2347 let messages = vec![LlmMessage::parts(
2348 LlmMessageRole::System,
2349 vec![
2350 LlmContentPart::text("foo"),
2351 LlmContentPart::image("data:image/png;base64,xxx"),
2352 LlmContentPart::text("bar"),
2353 ],
2354 )];
2355 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2356 }
2357
2358 #[test]
2359 fn test_llm_call_config_builder_from_runtime_agent() {
2360 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2361 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2362
2363 assert_eq!(llm_config.model, "gpt-4o");
2364 assert!(llm_config.reasoning_effort.is_none());
2365 assert!(llm_config.temperature.is_none());
2366 assert!(llm_config.max_tokens.is_none());
2367 assert!(llm_config.tools.is_empty());
2368 assert!(llm_config.metadata.is_empty());
2369 assert!(llm_config.openrouter_routing.is_none());
2371 }
2372
2373 #[test]
2374 fn runtime_agent_openrouter_routing_flows_into_call_config() {
2375 let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2379 runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2380 server_tools: vec![OpenRouterServerTool::new(
2381 OpenRouterServerToolKind::WebSearch,
2382 )],
2383 ..Default::default()
2384 });
2385
2386 let llm_config = LlmCallConfig::from(&runtime_agent);
2387 let routing = llm_config
2388 .openrouter_routing
2389 .expect("server-tool routing survives into the call config");
2390 assert_eq!(routing.server_tools.len(), 1);
2391 assert_eq!(
2392 routing.server_tools[0].kind.wire_type(),
2393 "openrouter:web_search"
2394 );
2395 }
2396
2397 #[test]
2398 fn test_llm_call_config_builder_with_metadata() {
2399 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2400 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2401 .with_metadata("session_id", "session_abc123")
2402 .with_metadata("agent_id", "agent_xyz789")
2403 .build();
2404
2405 assert_eq!(
2406 llm_config.metadata.get("session_id"),
2407 Some(&"session_abc123".to_string())
2408 );
2409 assert_eq!(
2410 llm_config.metadata.get("agent_id"),
2411 Some(&"agent_xyz789".to_string())
2412 );
2413 }
2414
2415 #[test]
2416 fn test_llm_call_config_builder_with_metadata_hashmap() {
2417 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2418 let mut metadata = HashMap::new();
2419 metadata.insert("key1".to_string(), "value1".to_string());
2420 metadata.insert("key2".to_string(), "value2".to_string());
2421
2422 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2423 .metadata(metadata)
2424 .build();
2425
2426 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2427 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2428 }
2429
2430 #[test]
2431 fn test_llm_call_config_builder_with_reasoning_effort() {
2432 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2433 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2434 .reasoning_effort("high")
2435 .build();
2436
2437 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2438 }
2439
2440 #[test]
2441 fn test_llm_call_config_builder_with_all_options() {
2442 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2443 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2444 .model("claude-3-opus")
2445 .reasoning_effort("medium")
2446 .temperature(0.7)
2447 .max_tokens(1000)
2448 .build();
2449
2450 assert_eq!(llm_config.model, "claude-3-opus");
2451 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2452 assert_eq!(llm_config.temperature, Some(0.7));
2453 assert_eq!(llm_config.max_tokens, Some(1000));
2454 }
2455
2456 #[test]
2457 fn test_llm_call_config_builder_with_openrouter_routing() {
2458 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2459 let routing = OpenRouterRoutingConfig::fallback_models([
2460 "openai/gpt-5-mini",
2461 "anthropic/claude-sonnet-4.5",
2462 ]);
2463
2464 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2465 .openrouter_routing(routing.clone())
2466 .build();
2467
2468 assert_eq!(llm_config.openrouter_routing, Some(routing));
2469 }
2470
2471 #[test]
2472 fn test_openrouter_fallback_models_empty_is_empty() {
2473 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2474
2475 assert!(routing.is_empty());
2476 assert_eq!(routing.route, None);
2477 }
2478
2479 #[test]
2480 fn test_openrouter_routing_validates_primary_model() {
2481 let routing = OpenRouterRoutingConfig::fallback_models([
2482 "openai/gpt-5-mini",
2483 "anthropic/claude-sonnet-4.5",
2484 ]);
2485
2486 assert!(
2487 routing
2488 .validate_for_primary_model("openai/gpt-5-mini")
2489 .is_ok()
2490 );
2491 let err = routing
2492 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2493 .unwrap_err();
2494 assert!(err.contains("models[0]"));
2495 }
2496
2497 #[test]
2498 fn test_openrouter_routing_rejects_fallback_without_models() {
2499 let routing = OpenRouterRoutingConfig {
2500 route: Some(OpenRouterRoute::Fallback),
2501 ..Default::default()
2502 };
2503
2504 let err = routing
2505 .validate_for_primary_model("openai/gpt-5-mini")
2506 .unwrap_err();
2507 assert!(err.contains("requires at least one model"));
2508 }
2509
2510 #[test]
2511 fn test_openrouter_routing_serializes_request_fields() {
2512 let routing = OpenRouterRoutingConfig {
2513 models: vec![
2514 "openai/gpt-5-mini".to_string(),
2515 "anthropic/claude-sonnet-4.5".to_string(),
2516 ],
2517 route: Some(OpenRouterRoute::Fallback),
2518 provider: Some(OpenRouterProviderRouting {
2519 order: vec!["anthropic".to_string(), "openai".to_string()],
2520 allow_fallbacks: Some(false),
2521 require_parameters: Some(true),
2522 data_collection: Some(OpenRouterDataCollection::Deny),
2523 zdr: Some(true),
2524 sort: Some(OpenRouterProviderSort::Advanced(
2525 OpenRouterProviderSortOptions {
2526 by: OpenRouterProviderSortBy::Throughput,
2527 partition: Some(OpenRouterSortPartition::None),
2528 },
2529 )),
2530 max_price: Some(OpenRouterMaxPrice {
2531 prompt: Some(1.0),
2532 completion: Some(2.0),
2533 ..Default::default()
2534 }),
2535 ..Default::default()
2536 }),
2537 ..Default::default()
2538 };
2539
2540 let json = serde_json::to_value(routing).unwrap();
2541
2542 assert_eq!(
2543 json,
2544 serde_json::json!({
2545 "models": [
2546 "openai/gpt-5-mini",
2547 "anthropic/claude-sonnet-4.5"
2548 ],
2549 "route": "fallback",
2550 "provider": {
2551 "order": ["anthropic", "openai"],
2552 "allow_fallbacks": false,
2553 "require_parameters": true,
2554 "data_collection": "deny",
2555 "zdr": true,
2556 "sort": {
2557 "by": "throughput",
2558 "partition": "none"
2559 },
2560 "max_price": {
2561 "prompt": 1.0,
2562 "completion": 2.0
2563 }
2564 }
2565 })
2566 );
2567 }
2568
2569 #[test]
2570 fn test_provider_type_parsing() {
2571 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2572 assert_eq!(
2573 "openrouter".parse::<DriverId>().unwrap(),
2574 DriverId::OpenRouter
2575 );
2576 assert_eq!(
2577 "openai_completions".parse::<DriverId>().unwrap(),
2578 DriverId::OpenAICompletions
2579 );
2580 assert_eq!(
2581 "azure_openai".parse::<DriverId>().unwrap(),
2582 DriverId::AzureOpenAI
2583 );
2584 assert_eq!(
2585 "anthropic".parse::<DriverId>().unwrap(),
2586 DriverId::Anthropic
2587 );
2588 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2589 assert_eq!(
2591 "ollama".parse::<DriverId>().unwrap(),
2592 DriverId::external("ollama")
2593 );
2594 assert_eq!(
2595 "custom".parse::<DriverId>().unwrap(),
2596 DriverId::external("custom")
2597 );
2598 }
2599
2600 #[test]
2601 fn test_external_provider_id_is_case_insensitive() {
2602 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2605 assert_eq!(
2606 "Ollama".parse::<DriverId>().unwrap(),
2607 "ollama".parse::<DriverId>().unwrap()
2608 );
2609 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2610 assert_eq!(
2612 DriverId::external("MyProvider"),
2613 "myprovider".parse::<DriverId>().unwrap()
2614 );
2615 }
2616
2617 #[test]
2618 fn test_provider_type_display() {
2619 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2620 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2621 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2622 assert_eq!(
2623 DriverId::OpenAICompletions.to_string(),
2624 "openai_completions"
2625 );
2626 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2627 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2628 }
2629
2630 #[test]
2631 fn test_provider_config_builder() {
2632 let config = ProviderConfig::new(DriverId::Anthropic)
2633 .with_api_key("test-key")
2634 .with_base_url("https://custom.api.com");
2635
2636 assert_eq!(config.provider_type, DriverId::Anthropic);
2637 assert_eq!(config.api_key, Some("test-key".to_string()));
2638 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2639 }
2640
2641 #[test]
2642 fn test_driver_registry_requires_api_key() {
2643 let mut registry = DriverRegistry::new();
2645 registry.register(DriverId::OpenAI, |_config| {
2646 struct MockDriver;
2648 #[async_trait]
2649 impl ChatDriver for MockDriver {
2650 async fn chat_completion_stream(
2651 &self,
2652 _messages: Vec<LlmMessage>,
2653 _config: &LlmCallConfig,
2654 ) -> Result<LlmResponseStream> {
2655 unimplemented!()
2656 }
2657 }
2658 Box::new(MockDriver)
2659 });
2660
2661 let config = ProviderConfig::new(DriverId::OpenAI);
2663 let result = registry.create_chat_driver(&config);
2664 assert!(result.is_err());
2665
2666 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2668 let result = registry.create_chat_driver(&config_with_key);
2669 assert!(result.is_ok());
2670 }
2671
2672 #[test]
2673 fn test_driver_registry_returns_error_for_unregistered_provider() {
2674 let registry = DriverRegistry::new();
2675 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2676
2677 let result = registry.create_chat_driver(&config);
2678
2679 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2681 assert_eq!(provider, "anthropic");
2682 } else {
2683 panic!("Expected DriverNotRegistered error");
2684 }
2685 }
2686
2687 #[test]
2688 fn test_driver_registry_registration() {
2689 let mut registry = DriverRegistry::new();
2690
2691 assert!(!registry.has_driver(&DriverId::OpenAI));
2692 assert!(!registry.has_driver(&DriverId::Anthropic));
2693
2694 registry.register(DriverId::OpenAI, |_config| {
2695 struct MockDriver;
2696 #[async_trait]
2697 impl ChatDriver for MockDriver {
2698 async fn chat_completion_stream(
2699 &self,
2700 _messages: Vec<LlmMessage>,
2701 _config: &LlmCallConfig,
2702 ) -> Result<LlmResponseStream> {
2703 unimplemented!()
2704 }
2705 }
2706 Box::new(MockDriver)
2707 });
2708
2709 assert!(registry.has_driver(&DriverId::OpenAI));
2710 assert!(!registry.has_driver(&DriverId::Anthropic));
2711 }
2712
2713 #[test]
2714 fn test_register_external_and_create_driver_without_api_key() {
2715 struct MockDriver;
2716 #[async_trait]
2717 impl ChatDriver for MockDriver {
2718 async fn chat_completion_stream(
2719 &self,
2720 _messages: Vec<LlmMessage>,
2721 _config: &LlmCallConfig,
2722 ) -> Result<LlmResponseStream> {
2723 unimplemented!()
2724 }
2725 }
2726
2727 let mut registry = DriverRegistry::new();
2728 registry.register_external("openai-codex", |config| {
2729 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2731 Box::new(MockDriver)
2732 });
2733
2734 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2735
2736 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2738 ProviderMetadata {
2739 refresh_token: Some("rt".into()),
2740 ..Default::default()
2741 },
2742 );
2743 assert!(registry.create_chat_driver(&config).is_ok());
2744 }
2745
2746 #[test]
2747 fn test_register_defaults_to_chat_only_descriptor() {
2748 struct MockDriver;
2749 #[async_trait]
2750 impl ChatDriver for MockDriver {
2751 async fn chat_completion_stream(
2752 &self,
2753 _messages: Vec<LlmMessage>,
2754 _config: &LlmCallConfig,
2755 ) -> Result<LlmResponseStream> {
2756 unimplemented!()
2757 }
2758 }
2759
2760 let mut registry = DriverRegistry::new();
2761 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2762
2763 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2764 assert_eq!(descriptor.display_name, "Anthropic");
2765 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2766 assert!(descriptor.chat.is_some());
2767 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2769 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2770 assert!(descriptor.credential_schema.fields[0].required);
2771
2772 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2774 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2775 assert!(sim.credential_schema.fields.is_empty());
2776 }
2777
2778 #[test]
2779 fn test_descriptor_services_and_lookup() {
2780 struct MockDriver;
2781 #[async_trait]
2782 impl ChatDriver for MockDriver {
2783 async fn chat_completion_stream(
2784 &self,
2785 _messages: Vec<LlmMessage>,
2786 _config: &LlmCallConfig,
2787 ) -> Result<LlmResponseStream> {
2788 unimplemented!()
2789 }
2790 }
2791
2792 let mut registry = DriverRegistry::new();
2793 registry.register_descriptor(DriverDescriptor {
2794 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2795 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2796 });
2797 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2798
2799 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2800 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2801 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2802 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2803
2804 let realtime = registry.providers_for(ServiceKind::Realtime);
2805 assert_eq!(realtime, vec![DriverId::OpenAI]);
2806 let mut chat = registry.providers_for(ServiceKind::Chat);
2807 chat.sort_by_key(|p| p.to_string());
2808 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2809 }
2810
2811 #[test]
2812 fn test_create_chat_driver_fails_without_chat_factory() {
2813 let mut registry = DriverRegistry::new();
2814 registry.register_descriptor(DriverDescriptor {
2815 id: DriverId::external("embeddings-only"),
2816 display_name: "Embeddings Only".to_string(),
2817 services: vec![ServiceKind::Embeddings],
2818 credential_schema: CredentialFormSchema::empty(),
2819 oauth: None,
2820 chat: None,
2821 embeddings: None,
2822 });
2823
2824 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2825 let err = match registry.create_chat_driver(&config) {
2826 Ok(_) => panic!("expected error for missing chat factory"),
2827 Err(err) => err,
2828 };
2829 assert!(
2830 err.to_string()
2831 .contains("does not implement the chat service"),
2832 "unexpected error: {err}"
2833 );
2834 }
2835
2836 #[test]
2837 #[should_panic(expected = "already registered")]
2838 fn test_register_duplicate_panics() {
2839 struct MockDriver;
2840 #[async_trait]
2841 impl ChatDriver for MockDriver {
2842 async fn chat_completion_stream(
2843 &self,
2844 _messages: Vec<LlmMessage>,
2845 _config: &LlmCallConfig,
2846 ) -> Result<LlmResponseStream> {
2847 unimplemented!()
2848 }
2849 }
2850
2851 let mut registry = DriverRegistry::new();
2852 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2853 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2855 }
2856
2857 #[test]
2858 fn test_register_or_replace_overwrites() {
2859 struct MockDriver;
2860 #[async_trait]
2861 impl ChatDriver for MockDriver {
2862 async fn chat_completion_stream(
2863 &self,
2864 _messages: Vec<LlmMessage>,
2865 _config: &LlmCallConfig,
2866 ) -> Result<LlmResponseStream> {
2867 unimplemented!()
2868 }
2869 }
2870
2871 let mut registry = DriverRegistry::new();
2872 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2873 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2875 assert!(registry.has_driver(&DriverId::LlmSim));
2876 }
2877
2878 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2883
2884 #[test]
2885 fn test_message_has_image_files_with_image_file() {
2886 let message = Message {
2887 id: uuid::Uuid::new_v4().into(),
2888 role: MessageRole::User,
2889 content: vec![
2890 ContentPart::Text(TextContentPart {
2891 text: "Look at this image".to_string(),
2892 }),
2893 ContentPart::ImageFile(ImageFileContentPart {
2894 image_id: uuid::Uuid::new_v4().into(),
2895 filename: Some("test.png".to_string()),
2896 }),
2897 ],
2898 phase: None,
2899 thinking: None,
2900 thinking_signature: None,
2901 controls: None,
2902 metadata: None,
2903 external_actor: None,
2904 created_at: chrono::Utc::now(),
2905 };
2906
2907 assert!(LlmMessage::message_has_image_files(&message));
2908 }
2909
2910 #[test]
2911 fn test_message_has_image_files_without_image_file() {
2912 let message = Message {
2913 id: uuid::Uuid::new_v4().into(),
2914 role: MessageRole::User,
2915 content: vec![ContentPart::Text(TextContentPart {
2916 text: "Just text".to_string(),
2917 })],
2918 phase: None,
2919 thinking: None,
2920 thinking_signature: None,
2921 controls: None,
2922 metadata: None,
2923 external_actor: None,
2924 created_at: chrono::Utc::now(),
2925 };
2926
2927 assert!(!LlmMessage::message_has_image_files(&message));
2928 }
2929
2930 #[test]
2931 fn test_extract_image_file_ids() {
2932 let id1 = uuid::Uuid::new_v4();
2933 let id2 = uuid::Uuid::new_v4();
2934
2935 let message = Message {
2936 id: uuid::Uuid::new_v4().into(),
2937 role: MessageRole::User,
2938 content: vec![
2939 ContentPart::Text(TextContentPart {
2940 text: "Look at these images".to_string(),
2941 }),
2942 ContentPart::ImageFile(ImageFileContentPart {
2943 image_id: id1.into(),
2944 filename: Some("test1.png".to_string()),
2945 }),
2946 ContentPart::ImageFile(ImageFileContentPart {
2947 image_id: id2.into(),
2948 filename: Some("test2.png".to_string()),
2949 }),
2950 ],
2951 phase: None,
2952 thinking: None,
2953 thinking_signature: None,
2954 controls: None,
2955 metadata: None,
2956 external_actor: None,
2957 created_at: chrono::Utc::now(),
2958 };
2959
2960 let ids = LlmMessage::extract_image_file_ids(&message);
2961 assert_eq!(ids.len(), 2);
2962 assert!(ids.contains(&id1));
2963 assert!(ids.contains(&id2));
2964 }
2965
2966 #[test]
2967 fn test_from_message_with_images_text_only() {
2968 let message = Message {
2969 id: uuid::Uuid::new_v4().into(),
2970 role: MessageRole::User,
2971 content: vec![ContentPart::Text(TextContentPart {
2972 text: "Hello".to_string(),
2973 })],
2974 phase: None,
2975 thinking: None,
2976 thinking_signature: None,
2977 controls: None,
2978 metadata: None,
2979 external_actor: None,
2980 created_at: chrono::Utc::now(),
2981 };
2982
2983 let resolved = std::collections::HashMap::new();
2984 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2985
2986 assert_eq!(llm_message.role, LlmMessageRole::User);
2987 match llm_message.content {
2988 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2989 _ => panic!("Expected text content"),
2990 }
2991 }
2992
2993 #[test]
2994 fn test_from_message_with_images_resolved_image() {
2995 let image_id = uuid::Uuid::new_v4();
2996 let message = Message {
2997 id: uuid::Uuid::new_v4().into(),
2998 role: MessageRole::User,
2999 content: vec![
3000 ContentPart::Text(TextContentPart {
3001 text: "Look at this".to_string(),
3002 }),
3003 ContentPart::ImageFile(ImageFileContentPart {
3004 image_id: image_id.into(),
3005 filename: Some("test.png".to_string()),
3006 }),
3007 ],
3008 phase: None,
3009 thinking: None,
3010 thinking_signature: None,
3011 controls: None,
3012 metadata: None,
3013 external_actor: None,
3014 created_at: chrono::Utc::now(),
3015 };
3016
3017 let mut resolved = std::collections::HashMap::new();
3018 resolved.insert(
3019 image_id,
3020 crate::ResolvedImage::new("base64data", "image/png"),
3021 );
3022
3023 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
3024
3025 match &llm_message.content {
3026 LlmMessageContent::Parts(parts) => {
3027 assert_eq!(parts.len(), 2);
3028 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
3030 if let LlmContentPart::Image { url } = &parts[1] {
3032 assert!(url.starts_with("data:image/png;base64,"));
3033 } else {
3034 panic!("Expected image content part");
3035 }
3036 }
3037 _ => panic!("Expected parts content"),
3038 }
3039 }
3040
3041 #[test]
3042 fn test_from_message_with_images_unresolved_image() {
3043 let image_id = uuid::Uuid::new_v4();
3044 let message = Message {
3045 id: uuid::Uuid::new_v4().into(),
3046 role: MessageRole::User,
3047 content: vec![ContentPart::ImageFile(ImageFileContentPart {
3048 image_id: image_id.into(),
3049 filename: Some("missing.png".to_string()),
3050 })],
3051 phase: None,
3052 thinking: None,
3053 thinking_signature: None,
3054 controls: None,
3055 metadata: None,
3056 external_actor: None,
3057 created_at: chrono::Utc::now(),
3058 };
3059
3060 let resolved = std::collections::HashMap::new();
3062 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
3063
3064 match &llm_message.content {
3067 LlmMessageContent::Text(text) => {
3068 assert!(text.contains("Image not found"));
3069 }
3070 LlmMessageContent::Parts(parts) => {
3071 assert_eq!(parts.len(), 1);
3072 if let LlmContentPart::Text { text } = &parts[0] {
3073 assert!(text.contains("Image not found"));
3074 } else {
3075 panic!("Expected text placeholder for missing image");
3076 }
3077 }
3078 }
3079 }
3080
3081 #[test]
3082 fn test_prepend_text_prefix_simple_text() {
3083 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
3084 msg.prepend_text_prefix("[Alice] ");
3085 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
3086 }
3087
3088 #[test]
3089 fn test_prepend_text_prefix_parts() {
3090 let mut msg = LlmMessage::parts(
3091 LlmMessageRole::User,
3092 vec![
3093 LlmContentPart::Text {
3094 text: "Hello".to_string(),
3095 },
3096 LlmContentPart::Image {
3097 url: "data:image/png;base64,abc".to_string(),
3098 },
3099 ],
3100 );
3101 msg.prepend_text_prefix("[Bob] ");
3102 match &msg.content {
3103 LlmMessageContent::Parts(parts) => {
3104 if let LlmContentPart::Text { text } = &parts[0] {
3105 assert_eq!(text, "[Bob] Hello");
3106 } else {
3107 panic!("Expected text part");
3108 }
3109 }
3110 _ => panic!("Expected parts content"),
3111 }
3112 }
3113
3114 #[test]
3115 fn test_prepend_text_prefix_parts_no_text() {
3116 let mut msg = LlmMessage::parts(
3117 LlmMessageRole::User,
3118 vec![LlmContentPart::Image {
3119 url: "data:image/png;base64,abc".to_string(),
3120 }],
3121 );
3122 msg.prepend_text_prefix("[Eve] ");
3123 match &msg.content {
3124 LlmMessageContent::Parts(parts) => {
3125 assert_eq!(parts.len(), 2);
3126 if let LlmContentPart::Text { text } = &parts[0] {
3127 assert_eq!(text, "[Eve] ");
3128 } else {
3129 panic!("Expected prepended text part");
3130 }
3131 }
3132 _ => panic!("Expected parts content"),
3133 }
3134 }
3135
3136 #[test]
3137 fn test_openrouter_plugin_config_is_empty() {
3138 assert!(OpenRouterPluginConfig::default().is_empty());
3139 assert!(
3140 !OpenRouterPluginConfig {
3141 web: Some(OpenRouterWebSearchPlugin::default()),
3142 file: None,
3143 }
3144 .is_empty()
3145 );
3146 assert!(
3147 !OpenRouterPluginConfig {
3148 web: None,
3149 file: Some(OpenRouterFilePlugin {}),
3150 }
3151 .is_empty()
3152 );
3153 }
3154
3155 #[test]
3156 fn test_openrouter_routing_is_empty_with_plugins() {
3157 let with_plugins = OpenRouterRoutingConfig {
3158 plugins: Some(OpenRouterPluginConfig {
3159 web: Some(OpenRouterWebSearchPlugin::default()),
3160 file: None,
3161 }),
3162 ..Default::default()
3163 };
3164 assert!(!with_plugins.is_empty());
3165
3166 let empty_plugins = OpenRouterRoutingConfig {
3167 plugins: Some(OpenRouterPluginConfig::default()),
3168 ..Default::default()
3169 };
3170 assert!(empty_plugins.is_empty());
3171 }
3172
3173 #[test]
3174 fn test_openrouter_web_search_plugin_serialization() {
3175 let plugin = OpenRouterWebSearchPlugin {
3176 max_results: Some(10),
3177 search_prompt: Some("search for Rust crates".to_string()),
3178 };
3179 let json = serde_json::to_value(&plugin).unwrap();
3180 assert_eq!(json["max_results"], 10);
3181 assert_eq!(json["search_prompt"], "search for Rust crates");
3182 }
3183
3184 #[test]
3185 fn test_openrouter_web_search_plugin_omits_none_fields() {
3186 let plugin = OpenRouterWebSearchPlugin::default();
3187 let json = serde_json::to_value(&plugin).unwrap();
3188 assert!(json.get("max_results").is_none());
3189 assert!(json.get("search_prompt").is_none());
3190 }
3191
3192 #[test]
3193 fn test_capacity_strategy_shared_capacity_is_noop() {
3194 let base = OpenRouterRoutingConfig {
3195 models: vec!["openai/gpt-5-mini".to_string()],
3196 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3197 ..Default::default()
3198 };
3199 let result = base.apply_capacity_strategy().unwrap();
3200 assert_eq!(
3201 result.capacity_strategy,
3202 Some(OpenRouterCapacityStrategy::SharedCapacity)
3203 );
3204 assert!(result.provider.is_none());
3205 }
3206
3207 #[test]
3208 fn test_capacity_strategy_none_is_noop() {
3209 let base = OpenRouterRoutingConfig {
3210 models: vec!["openai/gpt-5-mini".to_string()],
3211 capacity_strategy: None,
3212 ..Default::default()
3213 };
3214 let result = base.apply_capacity_strategy().unwrap();
3215 assert!(result.provider.is_none());
3216 }
3217
3218 #[test]
3219 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
3220 let base = OpenRouterRoutingConfig {
3221 models: vec!["openai/gpt-5-mini".to_string()],
3222 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3223 ..Default::default()
3224 };
3225 let result = base.apply_capacity_strategy().unwrap();
3226 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
3227 assert_eq!(provider.allow_fallbacks, Some(true));
3228 }
3229
3230 #[test]
3231 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
3232 let base = OpenRouterRoutingConfig {
3234 models: vec!["openai/gpt-5-mini".to_string()],
3235 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3236 provider: Some(OpenRouterProviderRouting {
3237 allow_fallbacks: Some(false),
3238 ..Default::default()
3239 }),
3240 ..Default::default()
3241 };
3242 let result = base.apply_capacity_strategy().unwrap();
3243 let provider = result.provider.as_ref().unwrap();
3244 assert_eq!(provider.allow_fallbacks, Some(false));
3245 }
3246
3247 #[test]
3248 fn test_capacity_strategy_byok_only_requires_provider_only() {
3249 let base = OpenRouterRoutingConfig {
3250 models: vec!["openai/gpt-5-mini".to_string()],
3251 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3252 ..Default::default()
3253 };
3254 let err = base.apply_capacity_strategy().unwrap_err();
3255 assert!(
3256 err.contains("provider.only"),
3257 "error should mention provider.only: {err}"
3258 );
3259 }
3260
3261 #[test]
3262 fn test_capacity_strategy_byok_only_disables_fallbacks() {
3263 let base = OpenRouterRoutingConfig {
3264 models: vec!["openai/gpt-5-mini".to_string()],
3265 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3266 provider: Some(OpenRouterProviderRouting {
3267 only: vec!["my-byok-provider".to_string()],
3268 ..Default::default()
3269 }),
3270 ..Default::default()
3271 };
3272 let result = base.apply_capacity_strategy().unwrap();
3273 let provider = result.provider.as_ref().unwrap();
3274 assert_eq!(provider.allow_fallbacks, Some(false));
3275 assert_eq!(provider.only, vec!["my-byok-provider"]);
3276 }
3277
3278 #[test]
3279 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3280 let with_strategy = OpenRouterRoutingConfig {
3281 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3282 ..Default::default()
3283 };
3284 assert!(!with_strategy.is_empty());
3285
3286 let byok_first = OpenRouterRoutingConfig {
3287 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3288 ..Default::default()
3289 };
3290 assert!(!byok_first.is_empty());
3291
3292 let shared = OpenRouterRoutingConfig {
3293 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3294 ..Default::default()
3295 };
3296 assert!(shared.is_empty());
3297 }
3298
3299 #[test]
3304 fn test_preset_no_presets_is_noop() {
3305 let base = OpenRouterRoutingConfig {
3306 models: vec!["openai/gpt-5-mini".to_string()],
3307 ..Default::default()
3308 };
3309 let result = base.apply_presets().unwrap();
3310 assert_eq!(result, base);
3311 }
3312
3313 #[test]
3314 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3315 let base = OpenRouterRoutingConfig {
3316 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3317 ..Default::default()
3318 };
3319 let result = base.apply_presets().unwrap();
3320 assert!(result.presets.is_empty(), "presets cleared after apply");
3321 let provider = result.provider.expect("provider set by preset");
3322 assert_eq!(provider.require_parameters, Some(true));
3323 assert_eq!(
3324 provider.sort,
3325 Some(OpenRouterProviderSort::Simple(
3326 OpenRouterProviderSortBy::Price
3327 ))
3328 );
3329 }
3330
3331 #[test]
3332 fn test_preset_lowest_latency_review_sets_sort_throughput() {
3333 let base = OpenRouterRoutingConfig {
3334 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3335 ..Default::default()
3336 };
3337 let result = base.apply_presets().unwrap();
3338 let provider = result.provider.expect("provider set by preset");
3339 assert_eq!(
3340 provider.sort,
3341 Some(OpenRouterProviderSort::Simple(
3342 OpenRouterProviderSortBy::Throughput
3343 ))
3344 );
3345 }
3346
3347 #[test]
3348 fn test_preset_zdr_only_sets_zdr() {
3349 let base = OpenRouterRoutingConfig {
3350 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3351 ..Default::default()
3352 };
3353 let result = base.apply_presets().unwrap();
3354 let provider = result.provider.expect("provider set");
3355 assert_eq!(provider.zdr, Some(true));
3356 }
3357
3358 #[test]
3359 fn test_preset_byok_first_sets_allow_fallbacks() {
3360 let base = OpenRouterRoutingConfig {
3361 presets: vec![OpenRouterRoutingPreset::ByokFirst],
3362 ..Default::default()
3363 };
3364 let result = base.apply_presets().unwrap();
3365 let provider = result.provider.expect("provider set");
3366 assert_eq!(provider.allow_fallbacks, Some(true));
3367 }
3368
3369 #[test]
3370 fn test_preset_no_data_collection_sets_data_collection_deny() {
3371 let base = OpenRouterRoutingConfig {
3372 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3373 ..Default::default()
3374 };
3375 let result = base.apply_presets().unwrap();
3376 let provider = result.provider.expect("provider set");
3377 assert_eq!(
3378 provider.data_collection,
3379 Some(OpenRouterDataCollection::Deny)
3380 );
3381 }
3382
3383 #[test]
3384 fn test_preset_strict_json_sets_require_parameters() {
3385 let base = OpenRouterRoutingConfig {
3386 presets: vec![OpenRouterRoutingPreset::StrictJson],
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_reasoning_required_sets_require_parameters() {
3396 let base = OpenRouterRoutingConfig {
3397 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3398 ..Default::default()
3399 };
3400 let result = base.apply_presets().unwrap();
3401 let provider = result.provider.expect("provider set");
3402 assert_eq!(provider.require_parameters, Some(true));
3403 }
3404
3405 #[test]
3406 fn test_preset_max_price_converts_usd_per_million() {
3407 let base = OpenRouterRoutingConfig {
3408 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3409 prompt_usd_per_million: Some(5.0),
3410 completion_usd_per_million: Some(15.0),
3411 }],
3412 ..Default::default()
3413 };
3414 let result = base.apply_presets().unwrap();
3415 let provider = result.provider.expect("provider set");
3416 let max_price = provider.max_price.expect("max_price set");
3417 let prompt = max_price.prompt.expect("prompt set");
3419 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3420 let completion = max_price.completion.expect("completion set");
3421 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3422 }
3423
3424 #[test]
3425 fn test_preset_max_price_rejects_negative_values() {
3426 let base = OpenRouterRoutingConfig {
3427 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3428 prompt_usd_per_million: Some(-1.0),
3429 completion_usd_per_million: None,
3430 }],
3431 ..Default::default()
3432 };
3433 let err = base.apply_presets().unwrap_err();
3434 assert!(
3435 err.contains("non-negative"),
3436 "error should mention non-negative: {err}"
3437 );
3438 }
3439
3440 #[test]
3441 fn test_preset_max_price_both_none_no_provider_field() {
3442 let base = OpenRouterRoutingConfig {
3443 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3444 prompt_usd_per_million: None,
3445 completion_usd_per_million: None,
3446 }],
3447 ..Default::default()
3448 };
3449 let result = base.apply_presets().unwrap();
3450 assert!(
3451 result.provider.is_none(),
3452 "MaxPrice with no dimensions should not produce a provider field"
3453 );
3454 }
3455
3456 #[test]
3457 fn test_preset_explicit_provider_overrides_preset() {
3458 let base = OpenRouterRoutingConfig {
3459 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3460 provider: Some(OpenRouterProviderRouting {
3461 sort: Some(OpenRouterProviderSort::Simple(
3463 OpenRouterProviderSortBy::Throughput,
3464 )),
3465 ..Default::default()
3466 }),
3467 ..Default::default()
3468 };
3469 let result = base.apply_presets().unwrap();
3470 let provider = result.provider.expect("provider set");
3471 assert_eq!(
3473 provider.sort,
3474 Some(OpenRouterProviderSort::Simple(
3475 OpenRouterProviderSortBy::Throughput
3476 ))
3477 );
3478 assert_eq!(provider.require_parameters, Some(true));
3480 }
3481
3482 #[test]
3483 fn test_preset_multiple_presets_combined() {
3484 let base = OpenRouterRoutingConfig {
3485 presets: vec![
3486 OpenRouterRoutingPreset::ZdrOnly,
3487 OpenRouterRoutingPreset::NoDataCollection,
3488 OpenRouterRoutingPreset::LowestLatencyReview,
3489 ],
3490 ..Default::default()
3491 };
3492 let result = base.apply_presets().unwrap();
3493 let provider = result.provider.expect("provider set");
3494 assert_eq!(provider.zdr, Some(true));
3495 assert_eq!(
3496 provider.data_collection,
3497 Some(OpenRouterDataCollection::Deny)
3498 );
3499 assert_eq!(
3500 provider.sort,
3501 Some(OpenRouterProviderSort::Simple(
3502 OpenRouterProviderSortBy::Throughput
3503 ))
3504 );
3505 }
3506
3507 #[test]
3508 fn test_preset_later_preset_overrides_sort() {
3509 let base = OpenRouterRoutingConfig {
3510 presets: vec![
3511 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3514 ..Default::default()
3515 };
3516 let result = base.apply_presets().unwrap();
3517 let provider = result.provider.expect("provider set");
3518 assert_eq!(
3520 provider.sort,
3521 Some(OpenRouterProviderSort::Simple(
3522 OpenRouterProviderSortBy::Throughput
3523 ))
3524 );
3525 assert_eq!(provider.require_parameters, Some(true));
3527 }
3528
3529 #[test]
3530 fn test_preset_non_empty_in_is_empty() {
3531 let with_preset = OpenRouterRoutingConfig {
3532 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3533 ..Default::default()
3534 };
3535 assert!(!with_preset.is_empty());
3536
3537 let without = OpenRouterRoutingConfig::default();
3538 assert!(without.is_empty());
3539 }
3540}