1use crate::credential_schema::CredentialFormSchema;
18use crate::error::{AgentLoopError, Result};
19use crate::openresponses_protocol::{CompactRequest, CompactResponse};
20use crate::runtime_agent::RuntimeAgent;
21use crate::tool_types::{ToolCall, ToolDefinition};
22use async_trait::async_trait;
23use chrono::{DateTime, Utc};
24use futures::Stream;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::pin::Pin;
28use std::sync::Arc;
29
30pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
36
37#[derive(Debug, Clone)]
39pub enum LlmStreamEvent {
40 TextDelta(String),
42 ThinkingDelta(String),
44 ThinkingSignature(String),
47 ReasonItem {
53 provider: String,
55 model: Option<String>,
57 item_id: String,
59 encrypted_content: Option<String>,
61 summary: Vec<String>,
63 token_count: Option<u32>,
65 },
66 ToolCalls(Vec<ToolCall>),
68 Done(Box<LlmCompletionMetadata>),
70 Error(String),
72}
73
74#[derive(Debug, Clone)]
85pub struct DiscoveredModel {
86 pub model_id: String,
88 pub display_name: Option<String>,
90 pub created_at: Option<DateTime<Utc>>,
92 pub owned_by: Option<String>,
94 pub discovered_profile: Option<crate::model::ModelProfile>,
97}
98
99#[derive(Debug, Clone, Default)]
107pub struct LlmCompletionMetadata {
108 pub total_tokens: Option<u32>,
110 pub prompt_tokens: Option<u32>,
112 pub completion_tokens: Option<u32>,
114 pub cache_read_tokens: Option<u32>,
116 pub cache_creation_tokens: Option<u32>,
118 pub provider_cost_usd: Option<f64>,
122 pub model: Option<String>,
124 pub finish_reason: Option<String>,
126 pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
128 pub response_id: Option<String>,
131 pub phase: Option<String>,
135}
136
137#[async_trait]
159pub trait ChatDriver: Send + Sync {
160 async fn chat_completion_stream(
162 &self,
163 messages: Vec<LlmMessage>,
164 config: &LlmCallConfig,
165 ) -> Result<LlmResponseStream>;
166
167 async fn chat_completion(
169 &self,
170 messages: Vec<LlmMessage>,
171 config: &LlmCallConfig,
172 ) -> Result<LlmResponse> {
173 use futures::StreamExt;
174
175 let mut stream = self.chat_completion_stream(messages, config).await?;
176 let mut text = String::new();
177 let mut thinking = String::new();
178 let mut thinking_signature: Option<String> = None;
179 let mut tool_calls = Vec::new();
180 let mut metadata = LlmCompletionMetadata::default();
181
182 while let Some(event) = stream.next().await {
183 match event? {
184 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
185 LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
186 LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
187 LlmStreamEvent::ReasonItem {
188 encrypted_content, ..
189 } => {
190 if let Some(sig) = encrypted_content {
191 thinking_signature = Some(sig);
192 }
193 }
194 LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
195 LlmStreamEvent::Done(meta) => metadata = *meta,
196 LlmStreamEvent::Error(err) => return Err(crate::error::AgentLoopError::llm(err)),
197 }
198 }
199
200 Ok(LlmResponse {
201 text,
202 thinking: if thinking.is_empty() {
203 None
204 } else {
205 Some(thinking)
206 },
207 thinking_signature,
208 tool_calls: if tool_calls.is_empty() {
209 None
210 } else {
211 Some(tool_calls)
212 },
213 metadata,
214 })
215 }
216
217 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
225 Ok(None)
227 }
228
229 fn supports_compact(&self) -> bool {
238 false
240 }
241
242 async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
262 Ok(None)
264 }
265}
266
267#[async_trait]
269impl ChatDriver for Box<dyn ChatDriver> {
270 async fn chat_completion_stream(
271 &self,
272 messages: Vec<LlmMessage>,
273 config: &LlmCallConfig,
274 ) -> Result<LlmResponseStream> {
275 (**self).chat_completion_stream(messages, config).await
276 }
277
278 async fn chat_completion(
279 &self,
280 messages: Vec<LlmMessage>,
281 config: &LlmCallConfig,
282 ) -> Result<LlmResponse> {
283 (**self).chat_completion(messages, config).await
284 }
285
286 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
287 (**self).list_models().await
288 }
289
290 fn supports_compact(&self) -> bool {
291 (**self).supports_compact()
292 }
293
294 async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
295 (**self).compact(request).await
296 }
297}
298
299#[derive(Debug, Clone)]
305pub struct LlmMessage {
306 pub role: LlmMessageRole,
307 pub content: LlmMessageContent,
308 pub tool_calls: Option<Vec<ToolCall>>,
309 pub tool_call_id: Option<String>,
310 pub phase: Option<crate::message::ExecutionPhase>,
315 pub thinking: Option<String>,
318 pub thinking_signature: Option<String>,
321}
322
323impl LlmMessage {
324 pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
326 Self {
327 role,
328 content: LlmMessageContent::Text(content.into()),
329 tool_calls: None,
330 tool_call_id: None,
331 phase: None,
332 thinking: None,
333 thinking_signature: None,
334 }
335 }
336
337 pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
339 Self {
340 role,
341 content: LlmMessageContent::Parts(parts),
342 tool_calls: None,
343 tool_call_id: None,
344 phase: None,
345 thinking: None,
346 thinking_signature: None,
347 }
348 }
349
350 pub fn content_as_text(&self) -> String {
352 self.content.to_text()
353 }
354
355 pub fn prepend_text_prefix(&mut self, prefix: &str) {
360 match &mut self.content {
361 LlmMessageContent::Text(text) => {
362 *text = format!("{}{}", prefix, text);
363 }
364 LlmMessageContent::Parts(parts) => {
365 for part in parts.iter_mut() {
366 if let LlmContentPart::Text { text } = part {
367 *text = format!("{}{}", prefix, text);
368 return;
369 }
370 }
371 parts.insert(
373 0,
374 LlmContentPart::Text {
375 text: prefix.to_string(),
376 },
377 );
378 }
379 }
380 }
381}
382
383#[derive(Debug, Clone)]
385pub enum LlmMessageContent {
386 Text(String),
388 Parts(Vec<LlmContentPart>),
390}
391
392impl LlmMessageContent {
393 pub fn to_text(&self) -> String {
395 match self {
396 LlmMessageContent::Text(s) => s.clone(),
397 LlmMessageContent::Parts(parts) => parts
398 .iter()
399 .filter_map(|p| match p {
400 LlmContentPart::Text { text } => Some(text.clone()),
401 _ => None,
402 })
403 .collect::<Vec<_>>()
404 .join(""),
405 }
406 }
407
408 pub fn is_text(&self) -> bool {
410 matches!(self, LlmMessageContent::Text(_))
411 }
412
413 pub fn is_parts(&self) -> bool {
415 matches!(self, LlmMessageContent::Parts(_))
416 }
417}
418
419impl From<String> for LlmMessageContent {
420 fn from(s: String) -> Self {
421 LlmMessageContent::Text(s)
422 }
423}
424
425impl From<&str> for LlmMessageContent {
426 fn from(s: &str) -> Self {
427 LlmMessageContent::Text(s.to_string())
428 }
429}
430
431#[derive(Debug, Clone)]
433pub enum LlmContentPart {
434 Text { text: String },
436 Image { url: String },
438 Audio { url: String },
440}
441
442impl LlmContentPart {
443 pub fn text(text: impl Into<String>) -> Self {
445 LlmContentPart::Text { text: text.into() }
446 }
447
448 pub fn image(url: impl Into<String>) -> Self {
450 LlmContentPart::Image { url: url.into() }
451 }
452
453 pub fn audio(url: impl Into<String>) -> Self {
455 LlmContentPart::Audio { url: url.into() }
456 }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
461pub enum LlmMessageRole {
462 System,
463 User,
464 Assistant,
465 Tool,
466}
467
468#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
478pub struct ToolSearchConfig {
479 pub enabled: bool,
481 pub threshold: usize,
484}
485
486#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
488#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
489#[serde(rename_all = "snake_case")]
490pub enum PromptCacheStrategy {
491 #[default]
493 Auto,
494}
495
496#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
501#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
502pub struct PromptCacheConfig {
503 pub enabled: bool,
505 #[serde(default)]
507 pub strategy: PromptCacheStrategy,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub gemini_cached_content: Option<String>,
516}
517
518#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
528#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
529#[serde(tag = "kind", rename_all = "snake_case")]
530pub enum OpenRouterRoutingPreset {
531 CheapestWithTools,
533 LowestLatencyReview,
535 ZdrOnly,
537 ByokFirst,
539 NoDataCollection,
541 StrictJson,
543 ReasoningRequired,
545 MaxPrice {
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 prompt_usd_per_million: Option<f64>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 completion_usd_per_million: Option<f64>,
554 },
555}
556
557#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
565#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
566#[serde(rename_all = "snake_case")]
567pub enum OpenRouterCapacityStrategy {
568 #[default]
570 SharedCapacity,
571 ByokFirst,
575 ByokOnly,
580}
581
582#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
585#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
586pub struct OpenRouterRoutingConfig {
587 #[serde(default, skip_serializing_if = "Vec::is_empty")]
589 pub models: Vec<String>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub route: Option<OpenRouterRoute>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub provider: Option<OpenRouterProviderRouting>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub plugins: Option<OpenRouterPluginConfig>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
605 #[serde(default, skip_serializing_if = "Vec::is_empty")]
609 pub presets: Vec<OpenRouterRoutingPreset>,
610}
611
612impl OpenRouterRoutingConfig {
613 pub fn is_empty(&self) -> bool {
614 self.models.is_empty()
615 && self.route.is_none()
616 && self.provider.is_none()
617 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
618 && matches!(
619 self.capacity_strategy,
620 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
621 )
622 && self.presets.is_empty()
623 }
624
625 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
627 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
628 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
629 Self {
630 models,
631 route,
632 provider: None,
633 plugins: None,
634 capacity_strategy: None,
635 presets: vec![],
636 }
637 }
638
639 pub fn validate_for_primary_model(
640 &self,
641 primary_model: &str,
642 ) -> std::result::Result<(), String> {
643 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
644 return Err(
645 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
646 );
647 }
648
649 if let Some(first_model) = self.models.first()
650 && first_model != primary_model
651 {
652 return Err(format!(
653 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
654 ));
655 }
656
657 Ok(())
658 }
659
660 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
670 match self.capacity_strategy {
671 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
672 Some(OpenRouterCapacityStrategy::ByokFirst) => {
673 let mut result = self.clone();
674 let provider = result.provider.get_or_insert_with(Default::default);
675 if provider.allow_fallbacks.is_none() {
676 provider.allow_fallbacks = Some(true);
677 }
678 Ok(result)
679 }
680 Some(OpenRouterCapacityStrategy::ByokOnly) => {
681 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
682 if only_is_empty {
683 return Err(
684 "OpenRouter BYOK-only strategy requires provider.only to list at least \
685 one upstream provider slug. Configure the provider list to match the \
686 BYOK providers registered in your OpenRouter workspace."
687 .to_string(),
688 );
689 }
690 let mut result = self.clone();
691 let provider = result.provider.get_or_insert_with(Default::default);
692 provider.allow_fallbacks = Some(false);
693 Ok(result)
694 }
695 }
696 }
697
698 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
708 if self.presets.is_empty() {
709 return Ok(self.clone());
710 }
711
712 let mut derived = OpenRouterProviderRouting::default();
713
714 for preset in &self.presets {
715 match preset {
716 OpenRouterRoutingPreset::CheapestWithTools => {
717 derived.require_parameters = Some(true);
718 derived.sort = Some(OpenRouterProviderSort::Simple(
719 OpenRouterProviderSortBy::Price,
720 ));
721 }
722 OpenRouterRoutingPreset::LowestLatencyReview => {
723 derived.sort = Some(OpenRouterProviderSort::Simple(
724 OpenRouterProviderSortBy::Throughput,
725 ));
726 }
727 OpenRouterRoutingPreset::ZdrOnly => {
728 derived.zdr = Some(true);
729 }
730 OpenRouterRoutingPreset::ByokFirst => {
731 if derived.allow_fallbacks.is_none() {
732 derived.allow_fallbacks = Some(true);
733 }
734 }
735 OpenRouterRoutingPreset::NoDataCollection => {
736 derived.data_collection = Some(OpenRouterDataCollection::Deny);
737 }
738 OpenRouterRoutingPreset::StrictJson
739 | OpenRouterRoutingPreset::ReasoningRequired => {
740 derived.require_parameters = Some(true);
741 }
742 OpenRouterRoutingPreset::MaxPrice {
743 prompt_usd_per_million,
744 completion_usd_per_million,
745 } => {
746 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
747 || completion_usd_per_million.is_some_and(|v| v < 0.0)
748 {
749 return Err(
750 "MaxPrice preset values must be non-negative USD per million tokens"
751 .to_string(),
752 );
753 }
754 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
755 let mp = derived.max_price.get_or_insert_with(Default::default);
756 if let Some(p) = prompt_usd_per_million {
757 mp.prompt = Some(p / 1_000_000.0);
758 }
759 if let Some(c) = completion_usd_per_million {
760 mp.completion = Some(c / 1_000_000.0);
761 }
762 }
763 }
764 }
765 }
766
767 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
769
770 let mut result = self.clone();
771 result.presets = vec![];
772 result.provider = if merged.is_empty() {
773 None
774 } else {
775 Some(merged)
776 };
777 Ok(result)
778 }
779}
780
781fn merge_provider_routing(
785 derived: OpenRouterProviderRouting,
786 explicit: OpenRouterProviderRouting,
787) -> OpenRouterProviderRouting {
788 OpenRouterProviderRouting {
789 order: if !explicit.order.is_empty() {
790 explicit.order
791 } else {
792 derived.order
793 },
794 only: if !explicit.only.is_empty() {
795 explicit.only
796 } else {
797 derived.only
798 },
799 ignore: if !explicit.ignore.is_empty() {
800 explicit.ignore
801 } else {
802 derived.ignore
803 },
804 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
805 require_parameters: explicit.require_parameters.or(derived.require_parameters),
806 data_collection: explicit.data_collection.or(derived.data_collection),
807 zdr: explicit.zdr.or(derived.zdr),
808 enforce_distillable_text: explicit
809 .enforce_distillable_text
810 .or(derived.enforce_distillable_text),
811 quantizations: if !explicit.quantizations.is_empty() {
812 explicit.quantizations
813 } else {
814 derived.quantizations
815 },
816 sort: explicit.sort.or(derived.sort),
817 max_price: explicit.max_price.or(derived.max_price),
818 }
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
823#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
824#[serde(rename_all = "snake_case")]
825pub enum OpenRouterRoute {
826 Fallback,
827}
828
829#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
831#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
832pub struct OpenRouterProviderRouting {
833 #[serde(default, skip_serializing_if = "Vec::is_empty")]
835 pub order: Vec<String>,
836 #[serde(default, skip_serializing_if = "Vec::is_empty")]
838 pub only: Vec<String>,
839 #[serde(default, skip_serializing_if = "Vec::is_empty")]
841 pub ignore: Vec<String>,
842 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub allow_fallbacks: Option<bool>,
845 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub require_parameters: Option<bool>,
848 #[serde(default, skip_serializing_if = "Option::is_none")]
850 pub data_collection: Option<OpenRouterDataCollection>,
851 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub zdr: Option<bool>,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub enforce_distillable_text: Option<bool>,
857 #[serde(default, skip_serializing_if = "Vec::is_empty")]
859 pub quantizations: Vec<String>,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub sort: Option<OpenRouterProviderSort>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
865 pub max_price: Option<OpenRouterMaxPrice>,
866}
867
868impl OpenRouterProviderRouting {
869 pub fn is_empty(&self) -> bool {
870 self.order.is_empty()
871 && self.only.is_empty()
872 && self.ignore.is_empty()
873 && self.allow_fallbacks.is_none()
874 && self.require_parameters.is_none()
875 && self.data_collection.is_none()
876 && self.zdr.is_none()
877 && self.enforce_distillable_text.is_none()
878 && self.quantizations.is_empty()
879 && self.sort.is_none()
880 && self.max_price.is_none()
881 }
882}
883
884#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
886#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
887#[serde(rename_all = "snake_case")]
888pub enum OpenRouterDataCollection {
889 Allow,
890 Deny,
891}
892
893#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
895#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
896#[serde(untagged)]
897pub enum OpenRouterProviderSort {
898 Simple(OpenRouterProviderSortBy),
899 Advanced(OpenRouterProviderSortOptions),
900}
901
902#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
904#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
905#[serde(rename_all = "snake_case")]
906pub enum OpenRouterProviderSortBy {
907 Price,
908 Throughput,
909 Latency,
910}
911
912#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
914#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
915pub struct OpenRouterProviderSortOptions {
916 pub by: OpenRouterProviderSortBy,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub partition: Option<OpenRouterSortPartition>,
919}
920
921#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
923#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
924#[serde(rename_all = "snake_case")]
925pub enum OpenRouterSortPartition {
926 Model,
927 None,
928}
929
930#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
933#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
934pub struct OpenRouterMaxPrice {
935 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub prompt: Option<f64>,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub completion: Option<f64>,
939 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub request: Option<f64>,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
942 pub image: Option<f64>,
943}
944
945#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
951#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
952pub struct OpenRouterWebSearchPlugin {
953 #[serde(default, skip_serializing_if = "Option::is_none")]
955 pub max_results: Option<u32>,
956 #[serde(default, skip_serializing_if = "Option::is_none")]
958 pub search_prompt: Option<String>,
959}
960
961#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
966#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
967pub struct OpenRouterFilePlugin {}
968
969#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
974#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
975pub struct OpenRouterPluginConfig {
976 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub web: Option<OpenRouterWebSearchPlugin>,
979 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub file: Option<OpenRouterFilePlugin>,
982}
983
984impl OpenRouterPluginConfig {
985 pub fn is_empty(&self) -> bool {
986 self.web.is_none() && self.file.is_none()
987 }
988}
989
990#[derive(Debug, Clone)]
992pub struct LlmCallConfig {
993 pub model: String,
994 pub temperature: Option<f32>,
995 pub max_tokens: Option<u32>,
996 pub tools: Vec<ToolDefinition>,
997 pub reasoning_effort: Option<String>,
999 pub metadata: HashMap<String, String>,
1003 pub previous_response_id: Option<String>,
1006 pub tool_search: Option<ToolSearchConfig>,
1008 pub prompt_cache: Option<PromptCacheConfig>,
1010 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1012}
1013
1014impl From<&RuntimeAgent> for LlmCallConfig {
1015 fn from(runtime_agent: &RuntimeAgent) -> Self {
1016 Self {
1017 model: runtime_agent.model.clone(),
1018 temperature: runtime_agent.temperature,
1019 max_tokens: runtime_agent.max_tokens,
1020 tools: runtime_agent.tools.clone(),
1021 reasoning_effort: None, metadata: HashMap::new(), previous_response_id: None,
1024 tool_search: runtime_agent.tool_search.clone(),
1025 prompt_cache: runtime_agent.prompt_cache.clone(),
1026 openrouter_routing: None,
1027 }
1028 }
1029}
1030
1031#[derive(Debug, Clone)]
1033pub struct LlmResponse {
1034 pub text: String,
1035 pub thinking: Option<String>,
1037 pub thinking_signature: Option<String>,
1039 pub tool_calls: Option<Vec<ToolCall>>,
1040 pub metadata: LlmCompletionMetadata,
1041}
1042
1043pub struct LlmCallConfigBuilder {
1062 config: LlmCallConfig,
1063}
1064
1065impl LlmCallConfigBuilder {
1066 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1068 Self {
1069 config: LlmCallConfig::from(runtime_agent),
1070 }
1071 }
1072
1073 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1075 self.config.reasoning_effort = Some(effort.into());
1076 self
1077 }
1078
1079 pub fn model(mut self, model: impl Into<String>) -> Self {
1081 self.config.model = model.into();
1082 self
1083 }
1084
1085 pub fn temperature(mut self, temp: f32) -> Self {
1087 self.config.temperature = Some(temp);
1088 self
1089 }
1090
1091 pub fn max_tokens(mut self, tokens: u32) -> Self {
1093 self.config.max_tokens = Some(tokens);
1094 self
1095 }
1096
1097 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1099 self.config.tools = tools;
1100 self
1101 }
1102
1103 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1108 self.config.metadata = metadata;
1109 self
1110 }
1111
1112 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1114 self.config.metadata.insert(key.into(), value.into());
1115 self
1116 }
1117
1118 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1120 self.config.previous_response_id = id;
1121 self
1122 }
1123
1124 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1126 self.config.tool_search = Some(config);
1127 self
1128 }
1129
1130 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1132 self.config.prompt_cache = Some(config);
1133 self
1134 }
1135
1136 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1138 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1139 self
1140 }
1141
1142 pub fn build(self) -> LlmCallConfig {
1144 self.config
1145 }
1146}
1147
1148impl From<&crate::message::Message> for LlmMessage {
1153 fn from(msg: &crate::message::Message) -> Self {
1159 let role = match msg.role {
1160 crate::message::MessageRole::System => LlmMessageRole::System,
1161 crate::message::MessageRole::User => LlmMessageRole::User,
1162 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1163 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1164 };
1165
1166 let tool_calls: Vec<ToolCall> = msg
1168 .tool_calls()
1169 .into_iter()
1170 .map(|tc| ToolCall {
1171 id: tc.id.clone(),
1172 name: tc.name.clone(),
1173 arguments: tc.arguments.clone(),
1174 })
1175 .collect();
1176
1177 LlmMessage {
1178 role,
1179 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1180 tool_calls: if tool_calls.is_empty() {
1181 None
1182 } else {
1183 Some(tool_calls)
1184 },
1185 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1186 phase: msg.phase,
1187 thinking: msg.thinking.clone(),
1188 thinking_signature: msg.thinking_signature.clone(),
1189 }
1190 }
1191}
1192
1193use crate::traits::ResolvedImage;
1198use uuid::Uuid;
1199
1200impl LlmMessage {
1201 pub fn from_message_with_images(
1221 msg: &crate::message::Message,
1222 resolved_images: &HashMap<Uuid, ResolvedImage>,
1223 ) -> Self {
1224 use crate::message::{ContentPart, MessageRole};
1225
1226 let role = match msg.role {
1227 MessageRole::System => LlmMessageRole::System,
1228 MessageRole::User => LlmMessageRole::User,
1229 MessageRole::Agent => LlmMessageRole::Assistant,
1230 MessageRole::ToolResult => LlmMessageRole::Tool,
1231 };
1232
1233 let mut parts: Vec<LlmContentPart> = Vec::new();
1235 let mut tool_calls: Vec<ToolCall> = Vec::new();
1236
1237 for part in &msg.content {
1238 match part {
1239 ContentPart::Text(t) => {
1240 parts.push(LlmContentPart::Text {
1241 text: t.text.clone(),
1242 });
1243 }
1244 ContentPart::Image(img) => {
1245 if let Some(url) = &img.url {
1247 parts.push(LlmContentPart::Image { url: url.clone() });
1248 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1249 {
1250 let data_url = format!("data:{};base64,{}", media_type, base64);
1251 parts.push(LlmContentPart::Image { url: data_url });
1252 }
1253 }
1254 ContentPart::ImageFile(img_file) => {
1255 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1257 parts.push(LlmContentPart::Image {
1258 url: resolved.to_data_url(),
1259 });
1260 } else {
1261 parts.push(LlmContentPart::Text {
1263 text: format!("[Image not found: {}]", img_file.image_id),
1264 });
1265 }
1266 }
1267 ContentPart::ToolCall(tc) => {
1268 tool_calls.push(ToolCall {
1270 id: tc.id.clone(),
1271 name: tc.name.clone(),
1272 arguments: tc.arguments.clone(),
1273 });
1274 }
1275 ContentPart::ToolResult(tr) => {
1276 let text = if let Some(err) = &tr.error {
1278 format!("Tool error: {}", err)
1279 } else if let Some(res) = &tr.result {
1280 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1281 } else {
1282 "{}".to_string()
1283 };
1284 let text = truncate_tool_result(text);
1288 parts.push(LlmContentPart::Text { text });
1289 }
1290 }
1291 }
1292
1293 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1295 if let LlmContentPart::Text { text } = &parts[0] {
1297 LlmMessageContent::Text(text.clone())
1298 } else {
1299 LlmMessageContent::Parts(parts)
1300 }
1301 } else if parts.is_empty() {
1302 LlmMessageContent::Text(String::new())
1304 } else {
1305 LlmMessageContent::Parts(parts)
1307 };
1308
1309 LlmMessage {
1310 role,
1311 content,
1312 tool_calls: if tool_calls.is_empty() {
1313 None
1314 } else {
1315 Some(tool_calls)
1316 },
1317 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1318 phase: msg.phase,
1319 thinking: msg.thinking.clone(),
1320 thinking_signature: msg.thinking_signature.clone(),
1321 }
1322 }
1323
1324 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1326 msg.content.iter().any(|p| p.is_image_file())
1327 }
1328
1329 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1331 msg.content
1332 .iter()
1333 .filter_map(|p| match p {
1334 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1335 _ => None,
1336 })
1337 .collect()
1338 }
1339}
1340
1341pub use crate::provider::DriverId;
1346
1347#[derive(Debug, Clone, Default, PartialEq, Eq)]
1353pub struct ProviderMetadata {
1354 pub refresh_token: Option<String>,
1356 pub account_id: Option<String>,
1358 pub extra: Option<serde_json::Value>,
1360}
1361
1362#[derive(Debug, Clone)]
1364pub struct ProviderConfig {
1365 pub provider_type: DriverId,
1367 pub api_key: Option<String>,
1369 pub base_url: Option<String>,
1371 pub metadata: ProviderMetadata,
1373}
1374
1375impl ProviderConfig {
1376 pub fn new(provider_type: DriverId) -> Self {
1378 Self {
1379 provider_type,
1380 api_key: None,
1381 base_url: None,
1382 metadata: ProviderMetadata::default(),
1383 }
1384 }
1385
1386 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1388 self.api_key = Some(api_key.into());
1389 self
1390 }
1391
1392 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1394 self.base_url = Some(base_url.into());
1395 self
1396 }
1397
1398 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1400 self.metadata = metadata;
1401 self
1402 }
1403}
1404
1405#[derive(Debug, Clone)]
1411pub struct DriverConfig {
1412 pub provider_type: DriverId,
1414 pub api_key: Option<String>,
1417 pub base_url: Option<String>,
1419 pub metadata: ProviderMetadata,
1421}
1422
1423impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1424 fn from(model: &crate::traits::ResolvedModel) -> Self {
1425 Self {
1426 provider_type: model.provider_type.clone(),
1427 api_key: model.api_key.clone(),
1428 base_url: model.base_url.clone(),
1429 metadata: model.provider_metadata.clone().unwrap_or_default(),
1430 }
1431 }
1432}
1433
1434pub type BoxedChatDriver = Box<dyn ChatDriver>;
1436
1437#[derive(Debug, Clone)]
1443pub struct EmbedRequest {
1444 pub texts: Vec<String>,
1446 pub model: String,
1448}
1449
1450#[derive(Debug, Clone)]
1452pub struct EmbedResponse {
1453 pub embeddings: Vec<Vec<f32>>,
1455 pub usage_tokens: Option<u32>,
1458}
1459
1460#[derive(Debug, thiserror::Error)]
1462pub enum EmbeddingsDriverError {
1463 #[error("embeddings provider returned an error: {0}")]
1464 Provider(String),
1465 #[error("embeddings request failed: {0}")]
1466 Transport(String),
1467}
1468
1469#[async_trait]
1475pub trait EmbeddingsDriver: Send + Sync {
1476 async fn embed(
1478 &self,
1479 request: EmbedRequest,
1480 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1481}
1482
1483pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1485
1486pub type EmbeddingsDriverFactory =
1488 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1489
1490pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1499
1500#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1506#[serde(rename_all = "snake_case")]
1507pub enum ServiceKind {
1508 Chat,
1510 Embeddings,
1512 Realtime,
1514 Images,
1516 Rerank,
1518}
1519
1520impl std::fmt::Display for ServiceKind {
1521 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1522 let s = match self {
1523 ServiceKind::Chat => "chat",
1524 ServiceKind::Embeddings => "embeddings",
1525 ServiceKind::Realtime => "realtime",
1526 ServiceKind::Images => "images",
1527 ServiceKind::Rerank => "rerank",
1528 };
1529 f.write_str(s)
1530 }
1531}
1532
1533#[derive(Clone)]
1540pub struct DriverDescriptor {
1541 pub id: DriverId,
1543 pub display_name: String,
1545 pub services: Vec<ServiceKind>,
1547 pub credential_schema: CredentialFormSchema,
1549 pub chat: Option<DriverFactory>,
1551 pub embeddings: Option<EmbeddingsDriverFactory>,
1553}
1554
1555impl DriverDescriptor {
1556 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1561 where
1562 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1563 {
1564 let id = id.into();
1565 Self {
1566 display_name: default_display_name(&id),
1567 credential_schema: default_credential_schema(&id),
1568 services: vec![ServiceKind::Chat],
1569 chat: Some(Arc::new(factory)),
1570 embeddings: None,
1571 id,
1572 }
1573 }
1574
1575 pub fn supports(&self, service: ServiceKind) -> bool {
1577 self.services.contains(&service)
1578 }
1579}
1580
1581impl std::fmt::Debug for DriverDescriptor {
1582 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1583 f.debug_struct("DriverDescriptor")
1584 .field("id", &self.id)
1585 .field("display_name", &self.display_name)
1586 .field("services", &self.services)
1587 .field("chat", &self.chat.is_some())
1588 .field("embeddings", &self.embeddings.is_some())
1589 .finish()
1590 }
1591}
1592
1593fn default_display_name(id: &DriverId) -> String {
1594 match id {
1595 DriverId::OpenAI => "OpenAI".to_string(),
1596 DriverId::OpenRouter => "OpenRouter".to_string(),
1597 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1598 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1599 DriverId::Anthropic => "Anthropic".to_string(),
1600 DriverId::Gemini => "Google Gemini".to_string(),
1601 DriverId::Bedrock => "AWS Bedrock".to_string(),
1602 DriverId::LlmSim => "LLM Simulator".to_string(),
1603 DriverId::External(id) => id.to_string(),
1604 }
1605}
1606
1607fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1608 match id {
1609 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1611 _ => CredentialFormSchema::api_key(String::new()),
1612 }
1613}
1614
1615#[derive(Clone, Default)]
1635pub struct DriverRegistry {
1636 descriptors: HashMap<DriverId, DriverDescriptor>,
1637}
1638
1639impl DriverRegistry {
1640 pub fn new() -> Self {
1642 Self {
1643 descriptors: HashMap::new(),
1644 }
1645 }
1646
1647 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1653 if self.descriptors.contains_key(&descriptor.id) {
1654 panic!(
1655 "driver already registered for provider '{}'; \
1656 use register_descriptor_or_replace to overwrite intentionally",
1657 descriptor.id
1658 );
1659 }
1660 self.descriptors.insert(descriptor.id.clone(), descriptor);
1661 }
1662
1663 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1665 self.descriptors.insert(descriptor.id.clone(), descriptor);
1666 }
1667
1668 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1674 where
1675 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1676 {
1677 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1678 }
1679
1680 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1685 where
1686 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1687 {
1688 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1689 }
1690
1691 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1696 where
1697 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1698 {
1699 self.register(DriverId::external(id), factory);
1700 }
1701
1702 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1711 let requires_api_key = !matches!(
1714 config.provider_type,
1715 DriverId::LlmSim | DriverId::External(_)
1716 );
1717 if requires_api_key && config.api_key.is_none() {
1718 return Err(AgentLoopError::llm(
1719 "API key is required. Configure the API key in provider settings.",
1720 ));
1721 }
1722
1723 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1725 AgentLoopError::driver_not_registered(config.provider_type.to_string())
1726 })?;
1727 let factory = descriptor.chat.as_ref().ok_or_else(|| {
1728 AgentLoopError::llm(format!(
1729 "Provider driver '{}' does not implement the chat service.",
1730 config.provider_type
1731 ))
1732 })?;
1733
1734 let driver_config = DriverConfig {
1736 provider_type: config.provider_type.clone(),
1737 api_key: config.api_key.clone(),
1738 base_url: config.base_url.clone(),
1739 metadata: config.metadata.clone(),
1740 };
1741 Ok(factory(&driver_config))
1742 }
1743
1744 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1746 self.descriptors.contains_key(provider_type)
1747 }
1748
1749 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1751 self.descriptors.get(provider_type)
1752 }
1753
1754 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1756 self.descriptors
1757 .get(provider_type)
1758 .is_some_and(|d| d.supports(service))
1759 }
1760
1761 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1763 self.descriptors
1764 .values()
1765 .filter(|d| d.supports(service))
1766 .map(|d| d.id.clone())
1767 .collect()
1768 }
1769
1770 pub fn registered_providers(&self) -> Vec<DriverId> {
1772 self.descriptors.keys().cloned().collect()
1773 }
1774
1775 pub fn create_embeddings_driver(
1783 &self,
1784 config: &ProviderConfig,
1785 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
1786 let requires_api_key = !matches!(
1787 config.provider_type,
1788 DriverId::LlmSim | DriverId::External(_)
1789 );
1790 if requires_api_key && config.api_key.is_none() {
1791 return Err(EmbeddingsDriverError::Provider(
1792 "API key is required. Configure the API key in provider settings.".to_string(),
1793 ));
1794 }
1795 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1796 EmbeddingsDriverError::Provider(format!(
1797 "No driver registered for provider '{}'",
1798 config.provider_type
1799 ))
1800 })?;
1801 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
1802 EmbeddingsDriverError::Provider(format!(
1803 "Provider driver '{}' does not implement the embeddings service.",
1804 config.provider_type
1805 ))
1806 })?;
1807 let driver_config = DriverConfig {
1808 provider_type: config.provider_type.clone(),
1809 api_key: config.api_key.clone(),
1810 base_url: config.base_url.clone(),
1811 metadata: config.metadata.clone(),
1812 };
1813 Ok(factory(&driver_config))
1814 }
1815}
1816
1817const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
1822
1823const TRUNCATION_SUFFIX: &str =
1824 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
1825
1826fn truncate_tool_result(text: String) -> String {
1827 if text.len() <= MAX_TOOL_RESULT_BYTES {
1828 return text;
1829 }
1830 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
1831 let mut end = content_budget;
1832 while end > 0 && !text.is_char_boundary(end) {
1833 end -= 1;
1834 }
1835 let mut truncated = text[..end].to_string();
1836 truncated.push_str(TRUNCATION_SUFFIX);
1837 truncated
1838}
1839
1840#[cfg(test)]
1845mod tests {
1846 use super::*;
1847
1848 #[test]
1849 fn test_llm_call_config_builder_from_runtime_agent() {
1850 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1851 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
1852
1853 assert_eq!(llm_config.model, "gpt-4o");
1854 assert!(llm_config.reasoning_effort.is_none());
1855 assert!(llm_config.temperature.is_none());
1856 assert!(llm_config.max_tokens.is_none());
1857 assert!(llm_config.tools.is_empty());
1858 assert!(llm_config.metadata.is_empty());
1859 }
1860
1861 #[test]
1862 fn test_llm_call_config_builder_with_metadata() {
1863 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1864 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1865 .with_metadata("session_id", "session_abc123")
1866 .with_metadata("agent_id", "agent_xyz789")
1867 .build();
1868
1869 assert_eq!(
1870 llm_config.metadata.get("session_id"),
1871 Some(&"session_abc123".to_string())
1872 );
1873 assert_eq!(
1874 llm_config.metadata.get("agent_id"),
1875 Some(&"agent_xyz789".to_string())
1876 );
1877 }
1878
1879 #[test]
1880 fn test_llm_call_config_builder_with_metadata_hashmap() {
1881 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1882 let mut metadata = HashMap::new();
1883 metadata.insert("key1".to_string(), "value1".to_string());
1884 metadata.insert("key2".to_string(), "value2".to_string());
1885
1886 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1887 .metadata(metadata)
1888 .build();
1889
1890 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
1891 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
1892 }
1893
1894 #[test]
1895 fn test_llm_call_config_builder_with_reasoning_effort() {
1896 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1897 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1898 .reasoning_effort("high")
1899 .build();
1900
1901 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
1902 }
1903
1904 #[test]
1905 fn test_llm_call_config_builder_with_all_options() {
1906 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1907 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1908 .model("claude-3-opus")
1909 .reasoning_effort("medium")
1910 .temperature(0.7)
1911 .max_tokens(1000)
1912 .build();
1913
1914 assert_eq!(llm_config.model, "claude-3-opus");
1915 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
1916 assert_eq!(llm_config.temperature, Some(0.7));
1917 assert_eq!(llm_config.max_tokens, Some(1000));
1918 }
1919
1920 #[test]
1921 fn test_llm_call_config_builder_with_openrouter_routing() {
1922 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
1923 let routing = OpenRouterRoutingConfig::fallback_models([
1924 "openai/gpt-5-mini",
1925 "anthropic/claude-sonnet-4.5",
1926 ]);
1927
1928 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1929 .openrouter_routing(routing.clone())
1930 .build();
1931
1932 assert_eq!(llm_config.openrouter_routing, Some(routing));
1933 }
1934
1935 #[test]
1936 fn test_openrouter_fallback_models_empty_is_empty() {
1937 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
1938
1939 assert!(routing.is_empty());
1940 assert_eq!(routing.route, None);
1941 }
1942
1943 #[test]
1944 fn test_openrouter_routing_validates_primary_model() {
1945 let routing = OpenRouterRoutingConfig::fallback_models([
1946 "openai/gpt-5-mini",
1947 "anthropic/claude-sonnet-4.5",
1948 ]);
1949
1950 assert!(
1951 routing
1952 .validate_for_primary_model("openai/gpt-5-mini")
1953 .is_ok()
1954 );
1955 let err = routing
1956 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
1957 .unwrap_err();
1958 assert!(err.contains("models[0]"));
1959 }
1960
1961 #[test]
1962 fn test_openrouter_routing_rejects_fallback_without_models() {
1963 let routing = OpenRouterRoutingConfig {
1964 route: Some(OpenRouterRoute::Fallback),
1965 ..Default::default()
1966 };
1967
1968 let err = routing
1969 .validate_for_primary_model("openai/gpt-5-mini")
1970 .unwrap_err();
1971 assert!(err.contains("requires at least one model"));
1972 }
1973
1974 #[test]
1975 fn test_openrouter_routing_serializes_request_fields() {
1976 let routing = OpenRouterRoutingConfig {
1977 models: vec![
1978 "openai/gpt-5-mini".to_string(),
1979 "anthropic/claude-sonnet-4.5".to_string(),
1980 ],
1981 route: Some(OpenRouterRoute::Fallback),
1982 provider: Some(OpenRouterProviderRouting {
1983 order: vec!["anthropic".to_string(), "openai".to_string()],
1984 allow_fallbacks: Some(false),
1985 require_parameters: Some(true),
1986 data_collection: Some(OpenRouterDataCollection::Deny),
1987 zdr: Some(true),
1988 sort: Some(OpenRouterProviderSort::Advanced(
1989 OpenRouterProviderSortOptions {
1990 by: OpenRouterProviderSortBy::Throughput,
1991 partition: Some(OpenRouterSortPartition::None),
1992 },
1993 )),
1994 max_price: Some(OpenRouterMaxPrice {
1995 prompt: Some(1.0),
1996 completion: Some(2.0),
1997 ..Default::default()
1998 }),
1999 ..Default::default()
2000 }),
2001 ..Default::default()
2002 };
2003
2004 let json = serde_json::to_value(routing).unwrap();
2005
2006 assert_eq!(
2007 json,
2008 serde_json::json!({
2009 "models": [
2010 "openai/gpt-5-mini",
2011 "anthropic/claude-sonnet-4.5"
2012 ],
2013 "route": "fallback",
2014 "provider": {
2015 "order": ["anthropic", "openai"],
2016 "allow_fallbacks": false,
2017 "require_parameters": true,
2018 "data_collection": "deny",
2019 "zdr": true,
2020 "sort": {
2021 "by": "throughput",
2022 "partition": "none"
2023 },
2024 "max_price": {
2025 "prompt": 1.0,
2026 "completion": 2.0
2027 }
2028 }
2029 })
2030 );
2031 }
2032
2033 #[test]
2034 fn test_provider_type_parsing() {
2035 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2036 assert_eq!(
2037 "openrouter".parse::<DriverId>().unwrap(),
2038 DriverId::OpenRouter
2039 );
2040 assert_eq!(
2041 "openai_completions".parse::<DriverId>().unwrap(),
2042 DriverId::OpenAICompletions
2043 );
2044 assert_eq!(
2045 "azure_openai".parse::<DriverId>().unwrap(),
2046 DriverId::AzureOpenAI
2047 );
2048 assert_eq!(
2049 "anthropic".parse::<DriverId>().unwrap(),
2050 DriverId::Anthropic
2051 );
2052 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2053 assert_eq!(
2055 "ollama".parse::<DriverId>().unwrap(),
2056 DriverId::external("ollama")
2057 );
2058 assert_eq!(
2059 "custom".parse::<DriverId>().unwrap(),
2060 DriverId::external("custom")
2061 );
2062 }
2063
2064 #[test]
2065 fn test_external_provider_id_is_case_insensitive() {
2066 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2069 assert_eq!(
2070 "Ollama".parse::<DriverId>().unwrap(),
2071 "ollama".parse::<DriverId>().unwrap()
2072 );
2073 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2074 assert_eq!(
2076 DriverId::external("MyProvider"),
2077 "myprovider".parse::<DriverId>().unwrap()
2078 );
2079 }
2080
2081 #[test]
2082 fn test_provider_type_display() {
2083 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2084 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2085 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2086 assert_eq!(
2087 DriverId::OpenAICompletions.to_string(),
2088 "openai_completions"
2089 );
2090 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2091 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2092 }
2093
2094 #[test]
2095 fn test_provider_config_builder() {
2096 let config = ProviderConfig::new(DriverId::Anthropic)
2097 .with_api_key("test-key")
2098 .with_base_url("https://custom.api.com");
2099
2100 assert_eq!(config.provider_type, DriverId::Anthropic);
2101 assert_eq!(config.api_key, Some("test-key".to_string()));
2102 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2103 }
2104
2105 #[test]
2106 fn test_driver_registry_requires_api_key() {
2107 let mut registry = DriverRegistry::new();
2109 registry.register(DriverId::OpenAI, |_config| {
2110 struct MockDriver;
2112 #[async_trait]
2113 impl ChatDriver for MockDriver {
2114 async fn chat_completion_stream(
2115 &self,
2116 _messages: Vec<LlmMessage>,
2117 _config: &LlmCallConfig,
2118 ) -> Result<LlmResponseStream> {
2119 unimplemented!()
2120 }
2121 }
2122 Box::new(MockDriver)
2123 });
2124
2125 let config = ProviderConfig::new(DriverId::OpenAI);
2127 let result = registry.create_chat_driver(&config);
2128 assert!(result.is_err());
2129
2130 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2132 let result = registry.create_chat_driver(&config_with_key);
2133 assert!(result.is_ok());
2134 }
2135
2136 #[test]
2137 fn test_driver_registry_returns_error_for_unregistered_provider() {
2138 let registry = DriverRegistry::new();
2139 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2140
2141 let result = registry.create_chat_driver(&config);
2142
2143 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2145 assert_eq!(provider, "anthropic");
2146 } else {
2147 panic!("Expected DriverNotRegistered error");
2148 }
2149 }
2150
2151 #[test]
2152 fn test_driver_registry_registration() {
2153 let mut registry = DriverRegistry::new();
2154
2155 assert!(!registry.has_driver(&DriverId::OpenAI));
2156 assert!(!registry.has_driver(&DriverId::Anthropic));
2157
2158 registry.register(DriverId::OpenAI, |_config| {
2159 struct MockDriver;
2160 #[async_trait]
2161 impl ChatDriver for MockDriver {
2162 async fn chat_completion_stream(
2163 &self,
2164 _messages: Vec<LlmMessage>,
2165 _config: &LlmCallConfig,
2166 ) -> Result<LlmResponseStream> {
2167 unimplemented!()
2168 }
2169 }
2170 Box::new(MockDriver)
2171 });
2172
2173 assert!(registry.has_driver(&DriverId::OpenAI));
2174 assert!(!registry.has_driver(&DriverId::Anthropic));
2175 }
2176
2177 #[test]
2178 fn test_register_external_and_create_driver_without_api_key() {
2179 struct MockDriver;
2180 #[async_trait]
2181 impl ChatDriver for MockDriver {
2182 async fn chat_completion_stream(
2183 &self,
2184 _messages: Vec<LlmMessage>,
2185 _config: &LlmCallConfig,
2186 ) -> Result<LlmResponseStream> {
2187 unimplemented!()
2188 }
2189 }
2190
2191 let mut registry = DriverRegistry::new();
2192 registry.register_external("openai-codex", |config| {
2193 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2195 Box::new(MockDriver)
2196 });
2197
2198 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2199
2200 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2202 ProviderMetadata {
2203 refresh_token: Some("rt".into()),
2204 ..Default::default()
2205 },
2206 );
2207 assert!(registry.create_chat_driver(&config).is_ok());
2208 }
2209
2210 #[test]
2211 fn test_register_defaults_to_chat_only_descriptor() {
2212 struct MockDriver;
2213 #[async_trait]
2214 impl ChatDriver for MockDriver {
2215 async fn chat_completion_stream(
2216 &self,
2217 _messages: Vec<LlmMessage>,
2218 _config: &LlmCallConfig,
2219 ) -> Result<LlmResponseStream> {
2220 unimplemented!()
2221 }
2222 }
2223
2224 let mut registry = DriverRegistry::new();
2225 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2226
2227 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2228 assert_eq!(descriptor.display_name, "Anthropic");
2229 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2230 assert!(descriptor.chat.is_some());
2231 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2233 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2234 assert!(descriptor.credential_schema.fields[0].required);
2235
2236 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2238 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2239 assert!(sim.credential_schema.fields.is_empty());
2240 }
2241
2242 #[test]
2243 fn test_descriptor_services_and_lookup() {
2244 struct MockDriver;
2245 #[async_trait]
2246 impl ChatDriver for MockDriver {
2247 async fn chat_completion_stream(
2248 &self,
2249 _messages: Vec<LlmMessage>,
2250 _config: &LlmCallConfig,
2251 ) -> Result<LlmResponseStream> {
2252 unimplemented!()
2253 }
2254 }
2255
2256 let mut registry = DriverRegistry::new();
2257 registry.register_descriptor(DriverDescriptor {
2258 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2259 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2260 });
2261 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2262
2263 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2264 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2265 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2266 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2267
2268 let realtime = registry.providers_for(ServiceKind::Realtime);
2269 assert_eq!(realtime, vec![DriverId::OpenAI]);
2270 let mut chat = registry.providers_for(ServiceKind::Chat);
2271 chat.sort_by_key(|p| p.to_string());
2272 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2273 }
2274
2275 #[test]
2276 fn test_create_chat_driver_fails_without_chat_factory() {
2277 let mut registry = DriverRegistry::new();
2278 registry.register_descriptor(DriverDescriptor {
2279 id: DriverId::external("embeddings-only"),
2280 display_name: "Embeddings Only".to_string(),
2281 services: vec![ServiceKind::Embeddings],
2282 credential_schema: CredentialFormSchema::empty(),
2283 chat: None,
2284 embeddings: None,
2285 });
2286
2287 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2288 let err = match registry.create_chat_driver(&config) {
2289 Ok(_) => panic!("expected error for missing chat factory"),
2290 Err(err) => err,
2291 };
2292 assert!(
2293 err.to_string()
2294 .contains("does not implement the chat service"),
2295 "unexpected error: {err}"
2296 );
2297 }
2298
2299 #[test]
2300 #[should_panic(expected = "already registered")]
2301 fn test_register_duplicate_panics() {
2302 struct MockDriver;
2303 #[async_trait]
2304 impl ChatDriver for MockDriver {
2305 async fn chat_completion_stream(
2306 &self,
2307 _messages: Vec<LlmMessage>,
2308 _config: &LlmCallConfig,
2309 ) -> Result<LlmResponseStream> {
2310 unimplemented!()
2311 }
2312 }
2313
2314 let mut registry = DriverRegistry::new();
2315 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2316 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2318 }
2319
2320 #[test]
2321 fn test_register_or_replace_overwrites() {
2322 struct MockDriver;
2323 #[async_trait]
2324 impl ChatDriver for MockDriver {
2325 async fn chat_completion_stream(
2326 &self,
2327 _messages: Vec<LlmMessage>,
2328 _config: &LlmCallConfig,
2329 ) -> Result<LlmResponseStream> {
2330 unimplemented!()
2331 }
2332 }
2333
2334 let mut registry = DriverRegistry::new();
2335 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2336 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2338 assert!(registry.has_driver(&DriverId::LlmSim));
2339 }
2340
2341 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2346
2347 #[test]
2348 fn test_message_has_image_files_with_image_file() {
2349 let message = Message {
2350 id: uuid::Uuid::new_v4().into(),
2351 role: MessageRole::User,
2352 content: vec![
2353 ContentPart::Text(TextContentPart {
2354 text: "Look at this image".to_string(),
2355 }),
2356 ContentPart::ImageFile(ImageFileContentPart {
2357 image_id: uuid::Uuid::new_v4().into(),
2358 filename: Some("test.png".to_string()),
2359 }),
2360 ],
2361 phase: None,
2362 thinking: None,
2363 thinking_signature: None,
2364 controls: None,
2365 metadata: None,
2366 external_actor: None,
2367 created_at: chrono::Utc::now(),
2368 };
2369
2370 assert!(LlmMessage::message_has_image_files(&message));
2371 }
2372
2373 #[test]
2374 fn test_message_has_image_files_without_image_file() {
2375 let message = Message {
2376 id: uuid::Uuid::new_v4().into(),
2377 role: MessageRole::User,
2378 content: vec![ContentPart::Text(TextContentPart {
2379 text: "Just text".to_string(),
2380 })],
2381 phase: None,
2382 thinking: None,
2383 thinking_signature: None,
2384 controls: None,
2385 metadata: None,
2386 external_actor: None,
2387 created_at: chrono::Utc::now(),
2388 };
2389
2390 assert!(!LlmMessage::message_has_image_files(&message));
2391 }
2392
2393 #[test]
2394 fn test_extract_image_file_ids() {
2395 let id1 = uuid::Uuid::new_v4();
2396 let id2 = uuid::Uuid::new_v4();
2397
2398 let message = Message {
2399 id: uuid::Uuid::new_v4().into(),
2400 role: MessageRole::User,
2401 content: vec![
2402 ContentPart::Text(TextContentPart {
2403 text: "Look at these images".to_string(),
2404 }),
2405 ContentPart::ImageFile(ImageFileContentPart {
2406 image_id: id1.into(),
2407 filename: Some("test1.png".to_string()),
2408 }),
2409 ContentPart::ImageFile(ImageFileContentPart {
2410 image_id: id2.into(),
2411 filename: Some("test2.png".to_string()),
2412 }),
2413 ],
2414 phase: None,
2415 thinking: None,
2416 thinking_signature: None,
2417 controls: None,
2418 metadata: None,
2419 external_actor: None,
2420 created_at: chrono::Utc::now(),
2421 };
2422
2423 let ids = LlmMessage::extract_image_file_ids(&message);
2424 assert_eq!(ids.len(), 2);
2425 assert!(ids.contains(&id1));
2426 assert!(ids.contains(&id2));
2427 }
2428
2429 #[test]
2430 fn test_from_message_with_images_text_only() {
2431 let message = Message {
2432 id: uuid::Uuid::new_v4().into(),
2433 role: MessageRole::User,
2434 content: vec![ContentPart::Text(TextContentPart {
2435 text: "Hello".to_string(),
2436 })],
2437 phase: None,
2438 thinking: None,
2439 thinking_signature: None,
2440 controls: None,
2441 metadata: None,
2442 external_actor: None,
2443 created_at: chrono::Utc::now(),
2444 };
2445
2446 let resolved = std::collections::HashMap::new();
2447 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2448
2449 assert_eq!(llm_message.role, LlmMessageRole::User);
2450 match llm_message.content {
2451 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2452 _ => panic!("Expected text content"),
2453 }
2454 }
2455
2456 #[test]
2457 fn test_from_message_with_images_resolved_image() {
2458 let image_id = uuid::Uuid::new_v4();
2459 let message = Message {
2460 id: uuid::Uuid::new_v4().into(),
2461 role: MessageRole::User,
2462 content: vec![
2463 ContentPart::Text(TextContentPart {
2464 text: "Look at this".to_string(),
2465 }),
2466 ContentPart::ImageFile(ImageFileContentPart {
2467 image_id: image_id.into(),
2468 filename: Some("test.png".to_string()),
2469 }),
2470 ],
2471 phase: None,
2472 thinking: None,
2473 thinking_signature: None,
2474 controls: None,
2475 metadata: None,
2476 external_actor: None,
2477 created_at: chrono::Utc::now(),
2478 };
2479
2480 let mut resolved = std::collections::HashMap::new();
2481 resolved.insert(
2482 image_id,
2483 crate::ResolvedImage::new("base64data", "image/png"),
2484 );
2485
2486 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2487
2488 match &llm_message.content {
2489 LlmMessageContent::Parts(parts) => {
2490 assert_eq!(parts.len(), 2);
2491 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2493 if let LlmContentPart::Image { url } = &parts[1] {
2495 assert!(url.starts_with("data:image/png;base64,"));
2496 } else {
2497 panic!("Expected image content part");
2498 }
2499 }
2500 _ => panic!("Expected parts content"),
2501 }
2502 }
2503
2504 #[test]
2505 fn test_from_message_with_images_unresolved_image() {
2506 let image_id = uuid::Uuid::new_v4();
2507 let message = Message {
2508 id: uuid::Uuid::new_v4().into(),
2509 role: MessageRole::User,
2510 content: vec![ContentPart::ImageFile(ImageFileContentPart {
2511 image_id: image_id.into(),
2512 filename: Some("missing.png".to_string()),
2513 })],
2514 phase: None,
2515 thinking: None,
2516 thinking_signature: None,
2517 controls: None,
2518 metadata: None,
2519 external_actor: None,
2520 created_at: chrono::Utc::now(),
2521 };
2522
2523 let resolved = std::collections::HashMap::new();
2525 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2526
2527 match &llm_message.content {
2530 LlmMessageContent::Text(text) => {
2531 assert!(text.contains("Image not found"));
2532 }
2533 LlmMessageContent::Parts(parts) => {
2534 assert_eq!(parts.len(), 1);
2535 if let LlmContentPart::Text { text } = &parts[0] {
2536 assert!(text.contains("Image not found"));
2537 } else {
2538 panic!("Expected text placeholder for missing image");
2539 }
2540 }
2541 }
2542 }
2543
2544 #[test]
2545 fn test_prepend_text_prefix_simple_text() {
2546 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2547 msg.prepend_text_prefix("[Alice] ");
2548 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2549 }
2550
2551 #[test]
2552 fn test_prepend_text_prefix_parts() {
2553 let mut msg = LlmMessage::parts(
2554 LlmMessageRole::User,
2555 vec![
2556 LlmContentPart::Text {
2557 text: "Hello".to_string(),
2558 },
2559 LlmContentPart::Image {
2560 url: "data:image/png;base64,abc".to_string(),
2561 },
2562 ],
2563 );
2564 msg.prepend_text_prefix("[Bob] ");
2565 match &msg.content {
2566 LlmMessageContent::Parts(parts) => {
2567 if let LlmContentPart::Text { text } = &parts[0] {
2568 assert_eq!(text, "[Bob] Hello");
2569 } else {
2570 panic!("Expected text part");
2571 }
2572 }
2573 _ => panic!("Expected parts content"),
2574 }
2575 }
2576
2577 #[test]
2578 fn test_prepend_text_prefix_parts_no_text() {
2579 let mut msg = LlmMessage::parts(
2580 LlmMessageRole::User,
2581 vec![LlmContentPart::Image {
2582 url: "data:image/png;base64,abc".to_string(),
2583 }],
2584 );
2585 msg.prepend_text_prefix("[Eve] ");
2586 match &msg.content {
2587 LlmMessageContent::Parts(parts) => {
2588 assert_eq!(parts.len(), 2);
2589 if let LlmContentPart::Text { text } = &parts[0] {
2590 assert_eq!(text, "[Eve] ");
2591 } else {
2592 panic!("Expected prepended text part");
2593 }
2594 }
2595 _ => panic!("Expected parts content"),
2596 }
2597 }
2598
2599 #[test]
2600 fn test_openrouter_plugin_config_is_empty() {
2601 assert!(OpenRouterPluginConfig::default().is_empty());
2602 assert!(
2603 !OpenRouterPluginConfig {
2604 web: Some(OpenRouterWebSearchPlugin::default()),
2605 file: None,
2606 }
2607 .is_empty()
2608 );
2609 assert!(
2610 !OpenRouterPluginConfig {
2611 web: None,
2612 file: Some(OpenRouterFilePlugin {}),
2613 }
2614 .is_empty()
2615 );
2616 }
2617
2618 #[test]
2619 fn test_openrouter_routing_is_empty_with_plugins() {
2620 let with_plugins = OpenRouterRoutingConfig {
2621 plugins: Some(OpenRouterPluginConfig {
2622 web: Some(OpenRouterWebSearchPlugin::default()),
2623 file: None,
2624 }),
2625 ..Default::default()
2626 };
2627 assert!(!with_plugins.is_empty());
2628
2629 let empty_plugins = OpenRouterRoutingConfig {
2630 plugins: Some(OpenRouterPluginConfig::default()),
2631 ..Default::default()
2632 };
2633 assert!(empty_plugins.is_empty());
2634 }
2635
2636 #[test]
2637 fn test_openrouter_web_search_plugin_serialization() {
2638 let plugin = OpenRouterWebSearchPlugin {
2639 max_results: Some(10),
2640 search_prompt: Some("search for Rust crates".to_string()),
2641 };
2642 let json = serde_json::to_value(&plugin).unwrap();
2643 assert_eq!(json["max_results"], 10);
2644 assert_eq!(json["search_prompt"], "search for Rust crates");
2645 }
2646
2647 #[test]
2648 fn test_openrouter_web_search_plugin_omits_none_fields() {
2649 let plugin = OpenRouterWebSearchPlugin::default();
2650 let json = serde_json::to_value(&plugin).unwrap();
2651 assert!(json.get("max_results").is_none());
2652 assert!(json.get("search_prompt").is_none());
2653 }
2654
2655 #[test]
2656 fn test_capacity_strategy_shared_capacity_is_noop() {
2657 let base = OpenRouterRoutingConfig {
2658 models: vec!["openai/gpt-5-mini".to_string()],
2659 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2660 ..Default::default()
2661 };
2662 let result = base.apply_capacity_strategy().unwrap();
2663 assert_eq!(
2664 result.capacity_strategy,
2665 Some(OpenRouterCapacityStrategy::SharedCapacity)
2666 );
2667 assert!(result.provider.is_none());
2668 }
2669
2670 #[test]
2671 fn test_capacity_strategy_none_is_noop() {
2672 let base = OpenRouterRoutingConfig {
2673 models: vec!["openai/gpt-5-mini".to_string()],
2674 capacity_strategy: None,
2675 ..Default::default()
2676 };
2677 let result = base.apply_capacity_strategy().unwrap();
2678 assert!(result.provider.is_none());
2679 }
2680
2681 #[test]
2682 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2683 let base = OpenRouterRoutingConfig {
2684 models: vec!["openai/gpt-5-mini".to_string()],
2685 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2686 ..Default::default()
2687 };
2688 let result = base.apply_capacity_strategy().unwrap();
2689 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2690 assert_eq!(provider.allow_fallbacks, Some(true));
2691 }
2692
2693 #[test]
2694 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2695 let base = OpenRouterRoutingConfig {
2697 models: vec!["openai/gpt-5-mini".to_string()],
2698 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2699 provider: Some(OpenRouterProviderRouting {
2700 allow_fallbacks: Some(false),
2701 ..Default::default()
2702 }),
2703 ..Default::default()
2704 };
2705 let result = base.apply_capacity_strategy().unwrap();
2706 let provider = result.provider.as_ref().unwrap();
2707 assert_eq!(provider.allow_fallbacks, Some(false));
2708 }
2709
2710 #[test]
2711 fn test_capacity_strategy_byok_only_requires_provider_only() {
2712 let base = OpenRouterRoutingConfig {
2713 models: vec!["openai/gpt-5-mini".to_string()],
2714 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2715 ..Default::default()
2716 };
2717 let err = base.apply_capacity_strategy().unwrap_err();
2718 assert!(
2719 err.contains("provider.only"),
2720 "error should mention provider.only: {err}"
2721 );
2722 }
2723
2724 #[test]
2725 fn test_capacity_strategy_byok_only_disables_fallbacks() {
2726 let base = OpenRouterRoutingConfig {
2727 models: vec!["openai/gpt-5-mini".to_string()],
2728 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2729 provider: Some(OpenRouterProviderRouting {
2730 only: vec!["my-byok-provider".to_string()],
2731 ..Default::default()
2732 }),
2733 ..Default::default()
2734 };
2735 let result = base.apply_capacity_strategy().unwrap();
2736 let provider = result.provider.as_ref().unwrap();
2737 assert_eq!(provider.allow_fallbacks, Some(false));
2738 assert_eq!(provider.only, vec!["my-byok-provider"]);
2739 }
2740
2741 #[test]
2742 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
2743 let with_strategy = OpenRouterRoutingConfig {
2744 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2745 ..Default::default()
2746 };
2747 assert!(!with_strategy.is_empty());
2748
2749 let byok_first = OpenRouterRoutingConfig {
2750 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2751 ..Default::default()
2752 };
2753 assert!(!byok_first.is_empty());
2754
2755 let shared = OpenRouterRoutingConfig {
2756 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2757 ..Default::default()
2758 };
2759 assert!(shared.is_empty());
2760 }
2761
2762 #[test]
2767 fn test_preset_no_presets_is_noop() {
2768 let base = OpenRouterRoutingConfig {
2769 models: vec!["openai/gpt-5-mini".to_string()],
2770 ..Default::default()
2771 };
2772 let result = base.apply_presets().unwrap();
2773 assert_eq!(result, base);
2774 }
2775
2776 #[test]
2777 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
2778 let base = OpenRouterRoutingConfig {
2779 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2780 ..Default::default()
2781 };
2782 let result = base.apply_presets().unwrap();
2783 assert!(result.presets.is_empty(), "presets cleared after apply");
2784 let provider = result.provider.expect("provider set by preset");
2785 assert_eq!(provider.require_parameters, Some(true));
2786 assert_eq!(
2787 provider.sort,
2788 Some(OpenRouterProviderSort::Simple(
2789 OpenRouterProviderSortBy::Price
2790 ))
2791 );
2792 }
2793
2794 #[test]
2795 fn test_preset_lowest_latency_review_sets_sort_throughput() {
2796 let base = OpenRouterRoutingConfig {
2797 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
2798 ..Default::default()
2799 };
2800 let result = base.apply_presets().unwrap();
2801 let provider = result.provider.expect("provider set by preset");
2802 assert_eq!(
2803 provider.sort,
2804 Some(OpenRouterProviderSort::Simple(
2805 OpenRouterProviderSortBy::Throughput
2806 ))
2807 );
2808 }
2809
2810 #[test]
2811 fn test_preset_zdr_only_sets_zdr() {
2812 let base = OpenRouterRoutingConfig {
2813 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2814 ..Default::default()
2815 };
2816 let result = base.apply_presets().unwrap();
2817 let provider = result.provider.expect("provider set");
2818 assert_eq!(provider.zdr, Some(true));
2819 }
2820
2821 #[test]
2822 fn test_preset_byok_first_sets_allow_fallbacks() {
2823 let base = OpenRouterRoutingConfig {
2824 presets: vec![OpenRouterRoutingPreset::ByokFirst],
2825 ..Default::default()
2826 };
2827 let result = base.apply_presets().unwrap();
2828 let provider = result.provider.expect("provider set");
2829 assert_eq!(provider.allow_fallbacks, Some(true));
2830 }
2831
2832 #[test]
2833 fn test_preset_no_data_collection_sets_data_collection_deny() {
2834 let base = OpenRouterRoutingConfig {
2835 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
2836 ..Default::default()
2837 };
2838 let result = base.apply_presets().unwrap();
2839 let provider = result.provider.expect("provider set");
2840 assert_eq!(
2841 provider.data_collection,
2842 Some(OpenRouterDataCollection::Deny)
2843 );
2844 }
2845
2846 #[test]
2847 fn test_preset_strict_json_sets_require_parameters() {
2848 let base = OpenRouterRoutingConfig {
2849 presets: vec![OpenRouterRoutingPreset::StrictJson],
2850 ..Default::default()
2851 };
2852 let result = base.apply_presets().unwrap();
2853 let provider = result.provider.expect("provider set");
2854 assert_eq!(provider.require_parameters, Some(true));
2855 }
2856
2857 #[test]
2858 fn test_preset_reasoning_required_sets_require_parameters() {
2859 let base = OpenRouterRoutingConfig {
2860 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
2861 ..Default::default()
2862 };
2863 let result = base.apply_presets().unwrap();
2864 let provider = result.provider.expect("provider set");
2865 assert_eq!(provider.require_parameters, Some(true));
2866 }
2867
2868 #[test]
2869 fn test_preset_max_price_converts_usd_per_million() {
2870 let base = OpenRouterRoutingConfig {
2871 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2872 prompt_usd_per_million: Some(5.0),
2873 completion_usd_per_million: Some(15.0),
2874 }],
2875 ..Default::default()
2876 };
2877 let result = base.apply_presets().unwrap();
2878 let provider = result.provider.expect("provider set");
2879 let max_price = provider.max_price.expect("max_price set");
2880 let prompt = max_price.prompt.expect("prompt set");
2882 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
2883 let completion = max_price.completion.expect("completion set");
2884 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
2885 }
2886
2887 #[test]
2888 fn test_preset_max_price_rejects_negative_values() {
2889 let base = OpenRouterRoutingConfig {
2890 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2891 prompt_usd_per_million: Some(-1.0),
2892 completion_usd_per_million: None,
2893 }],
2894 ..Default::default()
2895 };
2896 let err = base.apply_presets().unwrap_err();
2897 assert!(
2898 err.contains("non-negative"),
2899 "error should mention non-negative: {err}"
2900 );
2901 }
2902
2903 #[test]
2904 fn test_preset_max_price_both_none_no_provider_field() {
2905 let base = OpenRouterRoutingConfig {
2906 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2907 prompt_usd_per_million: None,
2908 completion_usd_per_million: None,
2909 }],
2910 ..Default::default()
2911 };
2912 let result = base.apply_presets().unwrap();
2913 assert!(
2914 result.provider.is_none(),
2915 "MaxPrice with no dimensions should not produce a provider field"
2916 );
2917 }
2918
2919 #[test]
2920 fn test_preset_explicit_provider_overrides_preset() {
2921 let base = OpenRouterRoutingConfig {
2922 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2923 provider: Some(OpenRouterProviderRouting {
2924 sort: Some(OpenRouterProviderSort::Simple(
2926 OpenRouterProviderSortBy::Throughput,
2927 )),
2928 ..Default::default()
2929 }),
2930 ..Default::default()
2931 };
2932 let result = base.apply_presets().unwrap();
2933 let provider = result.provider.expect("provider set");
2934 assert_eq!(
2936 provider.sort,
2937 Some(OpenRouterProviderSort::Simple(
2938 OpenRouterProviderSortBy::Throughput
2939 ))
2940 );
2941 assert_eq!(provider.require_parameters, Some(true));
2943 }
2944
2945 #[test]
2946 fn test_preset_multiple_presets_combined() {
2947 let base = OpenRouterRoutingConfig {
2948 presets: vec![
2949 OpenRouterRoutingPreset::ZdrOnly,
2950 OpenRouterRoutingPreset::NoDataCollection,
2951 OpenRouterRoutingPreset::LowestLatencyReview,
2952 ],
2953 ..Default::default()
2954 };
2955 let result = base.apply_presets().unwrap();
2956 let provider = result.provider.expect("provider set");
2957 assert_eq!(provider.zdr, Some(true));
2958 assert_eq!(
2959 provider.data_collection,
2960 Some(OpenRouterDataCollection::Deny)
2961 );
2962 assert_eq!(
2963 provider.sort,
2964 Some(OpenRouterProviderSort::Simple(
2965 OpenRouterProviderSortBy::Throughput
2966 ))
2967 );
2968 }
2969
2970 #[test]
2971 fn test_preset_later_preset_overrides_sort() {
2972 let base = OpenRouterRoutingConfig {
2973 presets: vec![
2974 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
2977 ..Default::default()
2978 };
2979 let result = base.apply_presets().unwrap();
2980 let provider = result.provider.expect("provider set");
2981 assert_eq!(
2983 provider.sort,
2984 Some(OpenRouterProviderSort::Simple(
2985 OpenRouterProviderSortBy::Throughput
2986 ))
2987 );
2988 assert_eq!(provider.require_parameters, Some(true));
2990 }
2991
2992 #[test]
2993 fn test_preset_non_empty_in_is_empty() {
2994 let with_preset = OpenRouterRoutingConfig {
2995 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2996 ..Default::default()
2997 };
2998 assert!(!with_preset.is_empty());
2999
3000 let without = OpenRouterRoutingConfig::default();
3001 assert!(without.is_empty());
3002 }
3003}