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
383pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
394 let mut system: Option<String> = None;
395 for msg in messages {
396 if msg.role == LlmMessageRole::System {
397 let text = msg.content.to_text();
398 system = Some(match system.take() {
399 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
400 _ => text,
401 });
402 }
403 }
404 system
405}
406
407#[derive(Debug, Clone)]
409pub enum LlmMessageContent {
410 Text(String),
412 Parts(Vec<LlmContentPart>),
414}
415
416impl LlmMessageContent {
417 pub fn to_text(&self) -> String {
419 match self {
420 LlmMessageContent::Text(s) => s.clone(),
421 LlmMessageContent::Parts(parts) => parts
422 .iter()
423 .filter_map(|p| match p {
424 LlmContentPart::Text { text } => Some(text.clone()),
425 _ => None,
426 })
427 .collect::<Vec<_>>()
428 .join(""),
429 }
430 }
431
432 pub fn is_text(&self) -> bool {
434 matches!(self, LlmMessageContent::Text(_))
435 }
436
437 pub fn is_parts(&self) -> bool {
439 matches!(self, LlmMessageContent::Parts(_))
440 }
441}
442
443impl From<String> for LlmMessageContent {
444 fn from(s: String) -> Self {
445 LlmMessageContent::Text(s)
446 }
447}
448
449impl From<&str> for LlmMessageContent {
450 fn from(s: &str) -> Self {
451 LlmMessageContent::Text(s.to_string())
452 }
453}
454
455#[derive(Debug, Clone)]
457pub enum LlmContentPart {
458 Text { text: String },
460 Image { url: String },
462 Audio { url: String },
464}
465
466impl LlmContentPart {
467 pub fn text(text: impl Into<String>) -> Self {
469 LlmContentPart::Text { text: text.into() }
470 }
471
472 pub fn image(url: impl Into<String>) -> Self {
474 LlmContentPart::Image { url: url.into() }
475 }
476
477 pub fn audio(url: impl Into<String>) -> Self {
479 LlmContentPart::Audio { url: url.into() }
480 }
481}
482
483#[derive(Debug, Clone, PartialEq, Eq)]
485pub enum LlmMessageRole {
486 System,
487 User,
488 Assistant,
489 Tool,
490}
491
492#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
502pub struct ToolSearchConfig {
503 pub enabled: bool,
505 pub threshold: usize,
508}
509
510#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
512#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
513#[serde(rename_all = "snake_case")]
514pub enum PromptCacheStrategy {
515 #[default]
517 Auto,
518}
519
520#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
525#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
526pub struct PromptCacheConfig {
527 pub enabled: bool,
529 #[serde(default)]
531 pub strategy: PromptCacheStrategy,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub gemini_cached_content: Option<String>,
540}
541
542#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
552#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
553#[serde(tag = "kind", rename_all = "snake_case")]
554pub enum OpenRouterRoutingPreset {
555 CheapestWithTools,
557 LowestLatencyReview,
559 ZdrOnly,
561 ByokFirst,
563 NoDataCollection,
565 StrictJson,
567 ReasoningRequired,
569 MaxPrice {
572 #[serde(default, skip_serializing_if = "Option::is_none")]
574 prompt_usd_per_million: Option<f64>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
577 completion_usd_per_million: Option<f64>,
578 },
579}
580
581#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
589#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
590#[serde(rename_all = "snake_case")]
591pub enum OpenRouterCapacityStrategy {
592 #[default]
594 SharedCapacity,
595 ByokFirst,
599 ByokOnly,
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
614#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
615#[serde(rename_all = "snake_case")]
616pub enum OpenRouterServerToolKind {
617 WebSearch,
618 WebFetch,
619 Datetime,
620 ImageGeneration,
621 ApplyPatch,
622 Fusion,
623 Advisor,
624 Subagent,
625}
626
627impl OpenRouterServerToolKind {
628 pub const ALL: [OpenRouterServerToolKind; 8] = [
630 Self::WebSearch,
631 Self::WebFetch,
632 Self::Datetime,
633 Self::ImageGeneration,
634 Self::ApplyPatch,
635 Self::Fusion,
636 Self::Advisor,
637 Self::Subagent,
638 ];
639
640 pub fn name(&self) -> &'static str {
642 match self {
643 Self::WebSearch => "web_search",
644 Self::WebFetch => "web_fetch",
645 Self::Datetime => "datetime",
646 Self::ImageGeneration => "image_generation",
647 Self::ApplyPatch => "apply_patch",
648 Self::Fusion => "fusion",
649 Self::Advisor => "advisor",
650 Self::Subagent => "subagent",
651 }
652 }
653
654 pub fn display_name(&self) -> &'static str {
656 match self {
657 Self::WebSearch => "Web Search",
658 Self::WebFetch => "Web Fetch",
659 Self::Datetime => "Date & Time",
660 Self::ImageGeneration => "Image Generation",
661 Self::ApplyPatch => "Apply Patch",
662 Self::Fusion => "Fusion",
663 Self::Advisor => "Advisor",
664 Self::Subagent => "Subagent",
665 }
666 }
667
668 pub fn wire_type(&self) -> String {
671 format!("openrouter:{}", self.name())
672 }
673
674 pub fn from_name(name: &str) -> Option<Self> {
676 Self::ALL.into_iter().find(|kind| kind.name() == name)
677 }
678}
679
680#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
684#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
685pub struct OpenRouterServerTool {
686 pub kind: OpenRouterServerToolKind,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
688 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
689 pub parameters: Option<serde_json::Value>,
690}
691
692impl OpenRouterServerTool {
693 pub fn new(kind: OpenRouterServerToolKind) -> Self {
695 Self {
696 kind,
697 parameters: None,
698 }
699 }
700
701 pub fn with_parameters(kind: OpenRouterServerToolKind, parameters: serde_json::Value) -> Self {
703 Self {
704 kind,
705 parameters: Some(parameters),
706 }
707 }
708}
709
710#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
713#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
714pub struct OpenRouterRoutingConfig {
715 #[serde(default, skip_serializing_if = "Vec::is_empty")]
717 pub models: Vec<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub route: Option<OpenRouterRoute>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub provider: Option<OpenRouterProviderRouting>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub plugins: Option<OpenRouterPluginConfig>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
733 #[serde(default, skip_serializing_if = "Vec::is_empty")]
737 pub presets: Vec<OpenRouterRoutingPreset>,
738 #[serde(default, skip_serializing_if = "Vec::is_empty")]
741 pub server_tools: Vec<OpenRouterServerTool>,
742}
743
744impl OpenRouterRoutingConfig {
745 pub fn is_empty(&self) -> bool {
746 self.models.is_empty()
747 && self.route.is_none()
748 && self.provider.is_none()
749 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
750 && matches!(
751 self.capacity_strategy,
752 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
753 )
754 && self.presets.is_empty()
755 && self.server_tools.is_empty()
756 }
757
758 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
760 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
761 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
762 Self {
763 models,
764 route,
765 provider: None,
766 plugins: None,
767 capacity_strategy: None,
768 presets: vec![],
769 server_tools: vec![],
770 }
771 }
772
773 pub fn validate_for_primary_model(
774 &self,
775 primary_model: &str,
776 ) -> std::result::Result<(), String> {
777 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
778 return Err(
779 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
780 );
781 }
782
783 if let Some(first_model) = self.models.first()
784 && first_model != primary_model
785 {
786 return Err(format!(
787 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
788 ));
789 }
790
791 Ok(())
792 }
793
794 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
804 match self.capacity_strategy {
805 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
806 Some(OpenRouterCapacityStrategy::ByokFirst) => {
807 let mut result = self.clone();
808 let provider = result.provider.get_or_insert_with(Default::default);
809 if provider.allow_fallbacks.is_none() {
810 provider.allow_fallbacks = Some(true);
811 }
812 Ok(result)
813 }
814 Some(OpenRouterCapacityStrategy::ByokOnly) => {
815 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
816 if only_is_empty {
817 return Err(
818 "OpenRouter BYOK-only strategy requires provider.only to list at least \
819 one upstream provider slug. Configure the provider list to match the \
820 BYOK providers registered in your OpenRouter workspace."
821 .to_string(),
822 );
823 }
824 let mut result = self.clone();
825 let provider = result.provider.get_or_insert_with(Default::default);
826 provider.allow_fallbacks = Some(false);
827 Ok(result)
828 }
829 }
830 }
831
832 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
842 if self.presets.is_empty() {
843 return Ok(self.clone());
844 }
845
846 let mut derived = OpenRouterProviderRouting::default();
847
848 for preset in &self.presets {
849 match preset {
850 OpenRouterRoutingPreset::CheapestWithTools => {
851 derived.require_parameters = Some(true);
852 derived.sort = Some(OpenRouterProviderSort::Simple(
853 OpenRouterProviderSortBy::Price,
854 ));
855 }
856 OpenRouterRoutingPreset::LowestLatencyReview => {
857 derived.sort = Some(OpenRouterProviderSort::Simple(
858 OpenRouterProviderSortBy::Throughput,
859 ));
860 }
861 OpenRouterRoutingPreset::ZdrOnly => {
862 derived.zdr = Some(true);
863 }
864 OpenRouterRoutingPreset::ByokFirst => {
865 if derived.allow_fallbacks.is_none() {
866 derived.allow_fallbacks = Some(true);
867 }
868 }
869 OpenRouterRoutingPreset::NoDataCollection => {
870 derived.data_collection = Some(OpenRouterDataCollection::Deny);
871 }
872 OpenRouterRoutingPreset::StrictJson
873 | OpenRouterRoutingPreset::ReasoningRequired => {
874 derived.require_parameters = Some(true);
875 }
876 OpenRouterRoutingPreset::MaxPrice {
877 prompt_usd_per_million,
878 completion_usd_per_million,
879 } => {
880 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
881 || completion_usd_per_million.is_some_and(|v| v < 0.0)
882 {
883 return Err(
884 "MaxPrice preset values must be non-negative USD per million tokens"
885 .to_string(),
886 );
887 }
888 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
889 let mp = derived.max_price.get_or_insert_with(Default::default);
890 if let Some(p) = prompt_usd_per_million {
891 mp.prompt = Some(p / 1_000_000.0);
892 }
893 if let Some(c) = completion_usd_per_million {
894 mp.completion = Some(c / 1_000_000.0);
895 }
896 }
897 }
898 }
899 }
900
901 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
903
904 let mut result = self.clone();
905 result.presets = vec![];
906 result.provider = if merged.is_empty() {
907 None
908 } else {
909 Some(merged)
910 };
911 Ok(result)
912 }
913}
914
915fn merge_provider_routing(
919 derived: OpenRouterProviderRouting,
920 explicit: OpenRouterProviderRouting,
921) -> OpenRouterProviderRouting {
922 OpenRouterProviderRouting {
923 order: if !explicit.order.is_empty() {
924 explicit.order
925 } else {
926 derived.order
927 },
928 only: if !explicit.only.is_empty() {
929 explicit.only
930 } else {
931 derived.only
932 },
933 ignore: if !explicit.ignore.is_empty() {
934 explicit.ignore
935 } else {
936 derived.ignore
937 },
938 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
939 require_parameters: explicit.require_parameters.or(derived.require_parameters),
940 data_collection: explicit.data_collection.or(derived.data_collection),
941 zdr: explicit.zdr.or(derived.zdr),
942 enforce_distillable_text: explicit
943 .enforce_distillable_text
944 .or(derived.enforce_distillable_text),
945 quantizations: if !explicit.quantizations.is_empty() {
946 explicit.quantizations
947 } else {
948 derived.quantizations
949 },
950 sort: explicit.sort.or(derived.sort),
951 max_price: explicit.max_price.or(derived.max_price),
952 }
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
957#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
958#[serde(rename_all = "snake_case")]
959pub enum OpenRouterRoute {
960 Fallback,
961}
962
963#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
965#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
966pub struct OpenRouterProviderRouting {
967 #[serde(default, skip_serializing_if = "Vec::is_empty")]
969 pub order: Vec<String>,
970 #[serde(default, skip_serializing_if = "Vec::is_empty")]
972 pub only: Vec<String>,
973 #[serde(default, skip_serializing_if = "Vec::is_empty")]
975 pub ignore: Vec<String>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub allow_fallbacks: Option<bool>,
979 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub require_parameters: Option<bool>,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub data_collection: Option<OpenRouterDataCollection>,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub zdr: Option<bool>,
988 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub enforce_distillable_text: Option<bool>,
991 #[serde(default, skip_serializing_if = "Vec::is_empty")]
993 pub quantizations: Vec<String>,
994 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub sort: Option<OpenRouterProviderSort>,
997 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub max_price: Option<OpenRouterMaxPrice>,
1000}
1001
1002impl OpenRouterProviderRouting {
1003 pub fn is_empty(&self) -> bool {
1004 self.order.is_empty()
1005 && self.only.is_empty()
1006 && self.ignore.is_empty()
1007 && self.allow_fallbacks.is_none()
1008 && self.require_parameters.is_none()
1009 && self.data_collection.is_none()
1010 && self.zdr.is_none()
1011 && self.enforce_distillable_text.is_none()
1012 && self.quantizations.is_empty()
1013 && self.sort.is_none()
1014 && self.max_price.is_none()
1015 }
1016}
1017
1018#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1020#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1021#[serde(rename_all = "snake_case")]
1022pub enum OpenRouterDataCollection {
1023 Allow,
1024 Deny,
1025}
1026
1027#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1029#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1030#[serde(untagged)]
1031pub enum OpenRouterProviderSort {
1032 Simple(OpenRouterProviderSortBy),
1033 Advanced(OpenRouterProviderSortOptions),
1034}
1035
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1038#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1039#[serde(rename_all = "snake_case")]
1040pub enum OpenRouterProviderSortBy {
1041 Price,
1042 Throughput,
1043 Latency,
1044}
1045
1046#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1048#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1049pub struct OpenRouterProviderSortOptions {
1050 pub by: OpenRouterProviderSortBy,
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub partition: Option<OpenRouterSortPartition>,
1053}
1054
1055#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1057#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1058#[serde(rename_all = "snake_case")]
1059pub enum OpenRouterSortPartition {
1060 Model,
1061 None,
1062}
1063
1064#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1067#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1068pub struct OpenRouterMaxPrice {
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub prompt: Option<f64>,
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1072 pub completion: Option<f64>,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub request: Option<f64>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub image: Option<f64>,
1077}
1078
1079#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1085#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1086pub struct OpenRouterWebSearchPlugin {
1087 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub max_results: Option<u32>,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1092 pub search_prompt: Option<String>,
1093}
1094
1095#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1101pub struct OpenRouterFilePlugin {}
1102
1103#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1108#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1109pub struct OpenRouterPluginConfig {
1110 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub web: Option<OpenRouterWebSearchPlugin>,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub file: Option<OpenRouterFilePlugin>,
1116}
1117
1118impl OpenRouterPluginConfig {
1119 pub fn is_empty(&self) -> bool {
1120 self.web.is_none() && self.file.is_none()
1121 }
1122}
1123
1124pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
1126pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
1128
1129#[derive(Debug, Clone)]
1131pub struct LlmCallConfig {
1132 pub model: String,
1133 pub temperature: Option<f32>,
1134 pub max_tokens: Option<u32>,
1135 pub tools: Vec<ToolDefinition>,
1136 pub reasoning_effort: Option<String>,
1138 pub metadata: HashMap<String, String>,
1142 pub previous_response_id: Option<String>,
1145 pub tool_search: Option<ToolSearchConfig>,
1147 pub prompt_cache: Option<PromptCacheConfig>,
1149 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1151 pub parallel_tool_calls: Option<bool>,
1158}
1159
1160impl From<&RuntimeAgent> for LlmCallConfig {
1161 fn from(runtime_agent: &RuntimeAgent) -> Self {
1162 Self {
1163 model: runtime_agent.model.clone(),
1164 temperature: runtime_agent.temperature,
1165 max_tokens: runtime_agent.max_tokens,
1166 tools: runtime_agent.tools.clone(),
1167 reasoning_effort: None, metadata: HashMap::new(), previous_response_id: None,
1170 tool_search: runtime_agent.tool_search.clone(),
1171 prompt_cache: runtime_agent.prompt_cache.clone(),
1172 openrouter_routing: runtime_agent.openrouter_routing.clone(),
1173 parallel_tool_calls: runtime_agent.parallel_tool_calls,
1174 }
1175 }
1176}
1177
1178#[derive(Debug, Clone)]
1180pub struct LlmResponse {
1181 pub text: String,
1182 pub thinking: Option<String>,
1184 pub thinking_signature: Option<String>,
1186 pub tool_calls: Option<Vec<ToolCall>>,
1187 pub metadata: LlmCompletionMetadata,
1188}
1189
1190pub struct LlmCallConfigBuilder {
1209 config: LlmCallConfig,
1210}
1211
1212impl LlmCallConfigBuilder {
1213 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1215 Self {
1216 config: LlmCallConfig::from(runtime_agent),
1217 }
1218 }
1219
1220 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1222 self.config.reasoning_effort = Some(effort.into());
1223 self
1224 }
1225
1226 pub fn model(mut self, model: impl Into<String>) -> Self {
1228 self.config.model = model.into();
1229 self
1230 }
1231
1232 pub fn temperature(mut self, temp: f32) -> Self {
1234 self.config.temperature = Some(temp);
1235 self
1236 }
1237
1238 pub fn max_tokens(mut self, tokens: u32) -> Self {
1240 self.config.max_tokens = Some(tokens);
1241 self
1242 }
1243
1244 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1246 self.config.tools = tools;
1247 self
1248 }
1249
1250 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1255 self.config.metadata = metadata;
1256 self
1257 }
1258
1259 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1261 self.config.metadata.insert(key.into(), value.into());
1262 self
1263 }
1264
1265 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1267 self.config.previous_response_id = id;
1268 self
1269 }
1270
1271 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1273 self.config.tool_search = Some(config);
1274 self
1275 }
1276
1277 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1279 self.config.prompt_cache = Some(config);
1280 self
1281 }
1282
1283 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1285 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1286 self
1287 }
1288
1289 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1291 self.config.parallel_tool_calls = parallel_tool_calls;
1292 self
1293 }
1294
1295 pub fn build(self) -> LlmCallConfig {
1297 self.config
1298 }
1299}
1300
1301impl From<&crate::message::Message> for LlmMessage {
1306 fn from(msg: &crate::message::Message) -> Self {
1312 let role = match msg.role {
1313 crate::message::MessageRole::System => LlmMessageRole::System,
1314 crate::message::MessageRole::User => LlmMessageRole::User,
1315 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1316 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1317 };
1318
1319 let tool_calls: Vec<ToolCall> = msg
1321 .tool_calls()
1322 .into_iter()
1323 .map(|tc| ToolCall {
1324 id: tc.id.clone(),
1325 name: tc.name.clone(),
1326 arguments: tc.arguments.clone(),
1327 })
1328 .collect();
1329
1330 LlmMessage {
1331 role,
1332 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1333 tool_calls: if tool_calls.is_empty() {
1334 None
1335 } else {
1336 Some(tool_calls)
1337 },
1338 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1339 phase: msg.phase,
1340 thinking: msg.thinking.clone(),
1341 thinking_signature: msg.thinking_signature.clone(),
1342 }
1343 }
1344}
1345
1346use crate::traits::ResolvedImage;
1351use uuid::Uuid;
1352
1353impl LlmMessage {
1354 pub fn from_message_with_images(
1374 msg: &crate::message::Message,
1375 resolved_images: &HashMap<Uuid, ResolvedImage>,
1376 ) -> Self {
1377 use crate::message::{ContentPart, MessageRole};
1378
1379 let role = match msg.role {
1380 MessageRole::System => LlmMessageRole::System,
1381 MessageRole::User => LlmMessageRole::User,
1382 MessageRole::Agent => LlmMessageRole::Assistant,
1383 MessageRole::ToolResult => LlmMessageRole::Tool,
1384 };
1385
1386 let mut parts: Vec<LlmContentPart> = Vec::new();
1388 let mut tool_calls: Vec<ToolCall> = Vec::new();
1389
1390 for part in &msg.content {
1391 match part {
1392 ContentPart::Text(t) => {
1393 parts.push(LlmContentPart::Text {
1394 text: t.text.clone(),
1395 });
1396 }
1397 ContentPart::Image(img) => {
1398 if let Some(url) = &img.url {
1400 parts.push(LlmContentPart::Image { url: url.clone() });
1401 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1402 {
1403 let data_url = format!("data:{};base64,{}", media_type, base64);
1404 parts.push(LlmContentPart::Image { url: data_url });
1405 }
1406 }
1407 ContentPart::ImageFile(img_file) => {
1408 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1410 parts.push(LlmContentPart::Image {
1411 url: resolved.to_data_url(),
1412 });
1413 } else {
1414 parts.push(LlmContentPart::Text {
1416 text: format!("[Image not found: {}]", img_file.image_id),
1417 });
1418 }
1419 }
1420 ContentPart::ToolCall(tc) => {
1421 tool_calls.push(ToolCall {
1423 id: tc.id.clone(),
1424 name: tc.name.clone(),
1425 arguments: tc.arguments.clone(),
1426 });
1427 }
1428 ContentPart::ToolResult(tr) => {
1429 let text = if let Some(err) = &tr.error {
1431 format!("Tool error: {}", err)
1432 } else if let Some(res) = &tr.result {
1433 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1434 } else {
1435 "{}".to_string()
1436 };
1437 let text = truncate_tool_result(text);
1441 parts.push(LlmContentPart::Text { text });
1442 }
1443 }
1444 }
1445
1446 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1448 if let LlmContentPart::Text { text } = &parts[0] {
1450 LlmMessageContent::Text(text.clone())
1451 } else {
1452 LlmMessageContent::Parts(parts)
1453 }
1454 } else if parts.is_empty() {
1455 LlmMessageContent::Text(String::new())
1457 } else {
1458 LlmMessageContent::Parts(parts)
1460 };
1461
1462 LlmMessage {
1463 role,
1464 content,
1465 tool_calls: if tool_calls.is_empty() {
1466 None
1467 } else {
1468 Some(tool_calls)
1469 },
1470 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1471 phase: msg.phase,
1472 thinking: msg.thinking.clone(),
1473 thinking_signature: msg.thinking_signature.clone(),
1474 }
1475 }
1476
1477 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1479 msg.content.iter().any(|p| p.is_image_file())
1480 }
1481
1482 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1484 msg.content
1485 .iter()
1486 .filter_map(|p| match p {
1487 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1488 _ => None,
1489 })
1490 .collect()
1491 }
1492}
1493
1494pub use crate::provider::DriverId;
1499
1500#[derive(Debug, Clone, Default, PartialEq, Eq)]
1506pub struct ProviderMetadata {
1507 pub refresh_token: Option<String>,
1509 pub account_id: Option<String>,
1511 pub extra: Option<serde_json::Value>,
1513}
1514
1515#[derive(Debug, Clone)]
1517pub struct ProviderConfig {
1518 pub provider_type: DriverId,
1520 pub api_key: Option<String>,
1522 pub base_url: Option<String>,
1524 pub metadata: ProviderMetadata,
1526}
1527
1528impl ProviderConfig {
1529 pub fn new(provider_type: DriverId) -> Self {
1531 Self {
1532 provider_type,
1533 api_key: None,
1534 base_url: None,
1535 metadata: ProviderMetadata::default(),
1536 }
1537 }
1538
1539 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1541 self.api_key = Some(api_key.into());
1542 self
1543 }
1544
1545 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1547 self.base_url = Some(base_url.into());
1548 self
1549 }
1550
1551 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1553 self.metadata = metadata;
1554 self
1555 }
1556}
1557
1558#[derive(Debug, Clone)]
1564pub struct DriverConfig {
1565 pub provider_type: DriverId,
1567 pub api_key: Option<String>,
1570 pub base_url: Option<String>,
1572 pub metadata: ProviderMetadata,
1574}
1575
1576impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1577 fn from(model: &crate::traits::ResolvedModel) -> Self {
1578 Self {
1579 provider_type: model.provider_type.clone(),
1580 api_key: model.api_key.clone(),
1581 base_url: model.base_url.clone(),
1582 metadata: model.provider_metadata.clone().unwrap_or_default(),
1583 }
1584 }
1585}
1586
1587pub type BoxedChatDriver = Box<dyn ChatDriver>;
1589
1590#[derive(Debug, Clone)]
1596pub struct EmbedRequest {
1597 pub texts: Vec<String>,
1599 pub model: String,
1601}
1602
1603#[derive(Debug, Clone)]
1605pub struct EmbedResponse {
1606 pub embeddings: Vec<Vec<f32>>,
1608 pub usage_tokens: Option<u32>,
1611}
1612
1613#[derive(Debug, thiserror::Error)]
1615pub enum EmbeddingsDriverError {
1616 #[error("embeddings provider returned an error: {0}")]
1617 Provider(String),
1618 #[error("embeddings request failed: {0}")]
1619 Transport(String),
1620}
1621
1622#[async_trait]
1628pub trait EmbeddingsDriver: Send + Sync {
1629 async fn embed(
1631 &self,
1632 request: EmbedRequest,
1633 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1634}
1635
1636pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1638
1639pub type EmbeddingsDriverFactory =
1641 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1642
1643pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1652
1653#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1659#[serde(rename_all = "snake_case")]
1660pub enum ServiceKind {
1661 Chat,
1663 Embeddings,
1665 Realtime,
1667 Images,
1669 Rerank,
1671}
1672
1673impl std::fmt::Display for ServiceKind {
1674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1675 let s = match self {
1676 ServiceKind::Chat => "chat",
1677 ServiceKind::Embeddings => "embeddings",
1678 ServiceKind::Realtime => "realtime",
1679 ServiceKind::Images => "images",
1680 ServiceKind::Rerank => "rerank",
1681 };
1682 f.write_str(s)
1683 }
1684}
1685
1686#[derive(Clone)]
1693pub struct DriverDescriptor {
1694 pub id: DriverId,
1696 pub display_name: String,
1698 pub services: Vec<ServiceKind>,
1700 pub credential_schema: CredentialFormSchema,
1702 pub chat: Option<DriverFactory>,
1704 pub embeddings: Option<EmbeddingsDriverFactory>,
1706}
1707
1708impl DriverDescriptor {
1709 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1714 where
1715 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1716 {
1717 let id = id.into();
1718 Self {
1719 display_name: default_display_name(&id),
1720 credential_schema: default_credential_schema(&id),
1721 services: vec![ServiceKind::Chat],
1722 chat: Some(Arc::new(factory)),
1723 embeddings: None,
1724 id,
1725 }
1726 }
1727
1728 pub fn supports(&self, service: ServiceKind) -> bool {
1730 self.services.contains(&service)
1731 }
1732}
1733
1734impl std::fmt::Debug for DriverDescriptor {
1735 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1736 f.debug_struct("DriverDescriptor")
1737 .field("id", &self.id)
1738 .field("display_name", &self.display_name)
1739 .field("services", &self.services)
1740 .field("chat", &self.chat.is_some())
1741 .field("embeddings", &self.embeddings.is_some())
1742 .finish()
1743 }
1744}
1745
1746fn default_display_name(id: &DriverId) -> String {
1747 match id {
1748 DriverId::OpenAI => "OpenAI".to_string(),
1749 DriverId::OpenRouter => "OpenRouter".to_string(),
1750 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1751 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1752 DriverId::Anthropic => "Anthropic".to_string(),
1753 DriverId::Gemini => "Google Gemini".to_string(),
1754 DriverId::Bedrock => "AWS Bedrock".to_string(),
1755 DriverId::Mai => "Microsoft MAI".to_string(),
1756 DriverId::LlmSim => "LLM Simulator".to_string(),
1757 DriverId::External(id) => id.to_string(),
1758 }
1759}
1760
1761fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1762 match id {
1763 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1765 _ => CredentialFormSchema::api_key(String::new()),
1766 }
1767}
1768
1769#[derive(Clone, Default)]
1789pub struct DriverRegistry {
1790 descriptors: HashMap<DriverId, DriverDescriptor>,
1791}
1792
1793impl DriverRegistry {
1794 pub fn new() -> Self {
1796 Self {
1797 descriptors: HashMap::new(),
1798 }
1799 }
1800
1801 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1807 if self.descriptors.contains_key(&descriptor.id) {
1808 panic!(
1809 "driver already registered for provider '{}'; \
1810 use register_descriptor_or_replace to overwrite intentionally",
1811 descriptor.id
1812 );
1813 }
1814 self.descriptors.insert(descriptor.id.clone(), descriptor);
1815 }
1816
1817 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1819 self.descriptors.insert(descriptor.id.clone(), descriptor);
1820 }
1821
1822 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1828 where
1829 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1830 {
1831 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1832 }
1833
1834 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1839 where
1840 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1841 {
1842 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1843 }
1844
1845 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1850 where
1851 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1852 {
1853 self.register(DriverId::external(id), factory);
1854 }
1855
1856 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1865 let requires_api_key = !matches!(
1869 config.provider_type,
1870 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
1871 );
1872 if requires_api_key && config.api_key.is_none() {
1873 return Err(AgentLoopError::llm(
1874 "API key is required. Configure the API key in provider settings.",
1875 ));
1876 }
1877
1878 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1880 AgentLoopError::driver_not_registered(config.provider_type.to_string())
1881 })?;
1882 let factory = descriptor.chat.as_ref().ok_or_else(|| {
1883 AgentLoopError::llm(format!(
1884 "Provider driver '{}' does not implement the chat service.",
1885 config.provider_type
1886 ))
1887 })?;
1888
1889 let driver_config = DriverConfig {
1891 provider_type: config.provider_type.clone(),
1892 api_key: config.api_key.clone(),
1893 base_url: config.base_url.clone(),
1894 metadata: config.metadata.clone(),
1895 };
1896 Ok(factory(&driver_config))
1897 }
1898
1899 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1901 self.descriptors.contains_key(provider_type)
1902 }
1903
1904 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1906 self.descriptors.get(provider_type)
1907 }
1908
1909 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1911 self.descriptors
1912 .get(provider_type)
1913 .is_some_and(|d| d.supports(service))
1914 }
1915
1916 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1918 self.descriptors
1919 .values()
1920 .filter(|d| d.supports(service))
1921 .map(|d| d.id.clone())
1922 .collect()
1923 }
1924
1925 pub fn registered_providers(&self) -> Vec<DriverId> {
1927 self.descriptors.keys().cloned().collect()
1928 }
1929
1930 pub fn create_embeddings_driver(
1938 &self,
1939 config: &ProviderConfig,
1940 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
1941 let requires_api_key = !matches!(
1942 config.provider_type,
1943 DriverId::LlmSim | DriverId::External(_)
1944 );
1945 if requires_api_key && config.api_key.is_none() {
1946 return Err(EmbeddingsDriverError::Provider(
1947 "API key is required. Configure the API key in provider settings.".to_string(),
1948 ));
1949 }
1950 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1951 EmbeddingsDriverError::Provider(format!(
1952 "No driver registered for provider '{}'",
1953 config.provider_type
1954 ))
1955 })?;
1956 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
1957 EmbeddingsDriverError::Provider(format!(
1958 "Provider driver '{}' does not implement the embeddings service.",
1959 config.provider_type
1960 ))
1961 })?;
1962 let driver_config = DriverConfig {
1963 provider_type: config.provider_type.clone(),
1964 api_key: config.api_key.clone(),
1965 base_url: config.base_url.clone(),
1966 metadata: config.metadata.clone(),
1967 };
1968 Ok(factory(&driver_config))
1969 }
1970}
1971
1972const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
1977
1978const TRUNCATION_SUFFIX: &str =
1979 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
1980
1981fn truncate_tool_result(text: String) -> String {
1982 if text.len() <= MAX_TOOL_RESULT_BYTES {
1983 return text;
1984 }
1985 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
1986 let mut end = content_budget;
1987 while end > 0 && !text.is_char_boundary(end) {
1988 end -= 1;
1989 }
1990 let mut truncated = text[..end].to_string();
1991 truncated.push_str(TRUNCATION_SUFFIX);
1992 truncated
1993}
1994
1995#[cfg(test)]
2000mod tests {
2001 use super::*;
2002
2003 #[test]
2004 fn test_fold_system_messages_none_when_absent() {
2005 let messages = vec![
2006 LlmMessage::text(LlmMessageRole::User, "hi"),
2007 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2008 ];
2009 assert_eq!(fold_system_messages(&messages), None);
2010 }
2011
2012 #[test]
2013 fn test_fold_system_messages_single() {
2014 let messages = vec![
2015 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2016 LlmMessage::text(LlmMessageRole::User, "hi"),
2017 ];
2018 assert_eq!(
2019 fold_system_messages(&messages),
2020 Some("AGENT-PROMPT".to_string())
2021 );
2022 }
2023
2024 #[test]
2025 fn test_fold_system_messages_accumulates_in_order() {
2026 let messages = vec![
2030 LlmMessage::text(LlmMessageRole::System, "A"),
2031 LlmMessage::text(LlmMessageRole::User, "hi"),
2032 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2033 LlmMessage::text(LlmMessageRole::System, "B"),
2034 ];
2035 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2036 }
2037
2038 #[test]
2039 fn test_fold_system_messages_concatenates_parts() {
2040 let messages = vec![LlmMessage::parts(
2041 LlmMessageRole::System,
2042 vec![
2043 LlmContentPart::text("foo"),
2044 LlmContentPart::image("data:image/png;base64,xxx"),
2045 LlmContentPart::text("bar"),
2046 ],
2047 )];
2048 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2049 }
2050
2051 #[test]
2052 fn test_llm_call_config_builder_from_runtime_agent() {
2053 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2054 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2055
2056 assert_eq!(llm_config.model, "gpt-4o");
2057 assert!(llm_config.reasoning_effort.is_none());
2058 assert!(llm_config.temperature.is_none());
2059 assert!(llm_config.max_tokens.is_none());
2060 assert!(llm_config.tools.is_empty());
2061 assert!(llm_config.metadata.is_empty());
2062 assert!(llm_config.openrouter_routing.is_none());
2064 }
2065
2066 #[test]
2067 fn runtime_agent_openrouter_routing_flows_into_call_config() {
2068 let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2072 runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2073 server_tools: vec![OpenRouterServerTool::new(
2074 OpenRouterServerToolKind::WebSearch,
2075 )],
2076 ..Default::default()
2077 });
2078
2079 let llm_config = LlmCallConfig::from(&runtime_agent);
2080 let routing = llm_config
2081 .openrouter_routing
2082 .expect("server-tool routing survives into the call config");
2083 assert_eq!(routing.server_tools.len(), 1);
2084 assert_eq!(
2085 routing.server_tools[0].kind.wire_type(),
2086 "openrouter:web_search"
2087 );
2088 }
2089
2090 #[test]
2091 fn test_llm_call_config_builder_with_metadata() {
2092 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2093 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2094 .with_metadata("session_id", "session_abc123")
2095 .with_metadata("agent_id", "agent_xyz789")
2096 .build();
2097
2098 assert_eq!(
2099 llm_config.metadata.get("session_id"),
2100 Some(&"session_abc123".to_string())
2101 );
2102 assert_eq!(
2103 llm_config.metadata.get("agent_id"),
2104 Some(&"agent_xyz789".to_string())
2105 );
2106 }
2107
2108 #[test]
2109 fn test_llm_call_config_builder_with_metadata_hashmap() {
2110 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2111 let mut metadata = HashMap::new();
2112 metadata.insert("key1".to_string(), "value1".to_string());
2113 metadata.insert("key2".to_string(), "value2".to_string());
2114
2115 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2116 .metadata(metadata)
2117 .build();
2118
2119 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2120 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2121 }
2122
2123 #[test]
2124 fn test_llm_call_config_builder_with_reasoning_effort() {
2125 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2126 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2127 .reasoning_effort("high")
2128 .build();
2129
2130 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2131 }
2132
2133 #[test]
2134 fn test_llm_call_config_builder_with_all_options() {
2135 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2136 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2137 .model("claude-3-opus")
2138 .reasoning_effort("medium")
2139 .temperature(0.7)
2140 .max_tokens(1000)
2141 .build();
2142
2143 assert_eq!(llm_config.model, "claude-3-opus");
2144 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2145 assert_eq!(llm_config.temperature, Some(0.7));
2146 assert_eq!(llm_config.max_tokens, Some(1000));
2147 }
2148
2149 #[test]
2150 fn test_llm_call_config_builder_with_openrouter_routing() {
2151 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2152 let routing = OpenRouterRoutingConfig::fallback_models([
2153 "openai/gpt-5-mini",
2154 "anthropic/claude-sonnet-4.5",
2155 ]);
2156
2157 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2158 .openrouter_routing(routing.clone())
2159 .build();
2160
2161 assert_eq!(llm_config.openrouter_routing, Some(routing));
2162 }
2163
2164 #[test]
2165 fn test_openrouter_fallback_models_empty_is_empty() {
2166 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2167
2168 assert!(routing.is_empty());
2169 assert_eq!(routing.route, None);
2170 }
2171
2172 #[test]
2173 fn test_openrouter_routing_validates_primary_model() {
2174 let routing = OpenRouterRoutingConfig::fallback_models([
2175 "openai/gpt-5-mini",
2176 "anthropic/claude-sonnet-4.5",
2177 ]);
2178
2179 assert!(
2180 routing
2181 .validate_for_primary_model("openai/gpt-5-mini")
2182 .is_ok()
2183 );
2184 let err = routing
2185 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2186 .unwrap_err();
2187 assert!(err.contains("models[0]"));
2188 }
2189
2190 #[test]
2191 fn test_openrouter_routing_rejects_fallback_without_models() {
2192 let routing = OpenRouterRoutingConfig {
2193 route: Some(OpenRouterRoute::Fallback),
2194 ..Default::default()
2195 };
2196
2197 let err = routing
2198 .validate_for_primary_model("openai/gpt-5-mini")
2199 .unwrap_err();
2200 assert!(err.contains("requires at least one model"));
2201 }
2202
2203 #[test]
2204 fn test_openrouter_routing_serializes_request_fields() {
2205 let routing = OpenRouterRoutingConfig {
2206 models: vec![
2207 "openai/gpt-5-mini".to_string(),
2208 "anthropic/claude-sonnet-4.5".to_string(),
2209 ],
2210 route: Some(OpenRouterRoute::Fallback),
2211 provider: Some(OpenRouterProviderRouting {
2212 order: vec!["anthropic".to_string(), "openai".to_string()],
2213 allow_fallbacks: Some(false),
2214 require_parameters: Some(true),
2215 data_collection: Some(OpenRouterDataCollection::Deny),
2216 zdr: Some(true),
2217 sort: Some(OpenRouterProviderSort::Advanced(
2218 OpenRouterProviderSortOptions {
2219 by: OpenRouterProviderSortBy::Throughput,
2220 partition: Some(OpenRouterSortPartition::None),
2221 },
2222 )),
2223 max_price: Some(OpenRouterMaxPrice {
2224 prompt: Some(1.0),
2225 completion: Some(2.0),
2226 ..Default::default()
2227 }),
2228 ..Default::default()
2229 }),
2230 ..Default::default()
2231 };
2232
2233 let json = serde_json::to_value(routing).unwrap();
2234
2235 assert_eq!(
2236 json,
2237 serde_json::json!({
2238 "models": [
2239 "openai/gpt-5-mini",
2240 "anthropic/claude-sonnet-4.5"
2241 ],
2242 "route": "fallback",
2243 "provider": {
2244 "order": ["anthropic", "openai"],
2245 "allow_fallbacks": false,
2246 "require_parameters": true,
2247 "data_collection": "deny",
2248 "zdr": true,
2249 "sort": {
2250 "by": "throughput",
2251 "partition": "none"
2252 },
2253 "max_price": {
2254 "prompt": 1.0,
2255 "completion": 2.0
2256 }
2257 }
2258 })
2259 );
2260 }
2261
2262 #[test]
2263 fn test_provider_type_parsing() {
2264 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2265 assert_eq!(
2266 "openrouter".parse::<DriverId>().unwrap(),
2267 DriverId::OpenRouter
2268 );
2269 assert_eq!(
2270 "openai_completions".parse::<DriverId>().unwrap(),
2271 DriverId::OpenAICompletions
2272 );
2273 assert_eq!(
2274 "azure_openai".parse::<DriverId>().unwrap(),
2275 DriverId::AzureOpenAI
2276 );
2277 assert_eq!(
2278 "anthropic".parse::<DriverId>().unwrap(),
2279 DriverId::Anthropic
2280 );
2281 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2282 assert_eq!(
2284 "ollama".parse::<DriverId>().unwrap(),
2285 DriverId::external("ollama")
2286 );
2287 assert_eq!(
2288 "custom".parse::<DriverId>().unwrap(),
2289 DriverId::external("custom")
2290 );
2291 }
2292
2293 #[test]
2294 fn test_external_provider_id_is_case_insensitive() {
2295 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2298 assert_eq!(
2299 "Ollama".parse::<DriverId>().unwrap(),
2300 "ollama".parse::<DriverId>().unwrap()
2301 );
2302 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2303 assert_eq!(
2305 DriverId::external("MyProvider"),
2306 "myprovider".parse::<DriverId>().unwrap()
2307 );
2308 }
2309
2310 #[test]
2311 fn test_provider_type_display() {
2312 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2313 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2314 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2315 assert_eq!(
2316 DriverId::OpenAICompletions.to_string(),
2317 "openai_completions"
2318 );
2319 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2320 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2321 }
2322
2323 #[test]
2324 fn test_provider_config_builder() {
2325 let config = ProviderConfig::new(DriverId::Anthropic)
2326 .with_api_key("test-key")
2327 .with_base_url("https://custom.api.com");
2328
2329 assert_eq!(config.provider_type, DriverId::Anthropic);
2330 assert_eq!(config.api_key, Some("test-key".to_string()));
2331 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2332 }
2333
2334 #[test]
2335 fn test_driver_registry_requires_api_key() {
2336 let mut registry = DriverRegistry::new();
2338 registry.register(DriverId::OpenAI, |_config| {
2339 struct MockDriver;
2341 #[async_trait]
2342 impl ChatDriver for MockDriver {
2343 async fn chat_completion_stream(
2344 &self,
2345 _messages: Vec<LlmMessage>,
2346 _config: &LlmCallConfig,
2347 ) -> Result<LlmResponseStream> {
2348 unimplemented!()
2349 }
2350 }
2351 Box::new(MockDriver)
2352 });
2353
2354 let config = ProviderConfig::new(DriverId::OpenAI);
2356 let result = registry.create_chat_driver(&config);
2357 assert!(result.is_err());
2358
2359 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2361 let result = registry.create_chat_driver(&config_with_key);
2362 assert!(result.is_ok());
2363 }
2364
2365 #[test]
2366 fn test_driver_registry_returns_error_for_unregistered_provider() {
2367 let registry = DriverRegistry::new();
2368 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2369
2370 let result = registry.create_chat_driver(&config);
2371
2372 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2374 assert_eq!(provider, "anthropic");
2375 } else {
2376 panic!("Expected DriverNotRegistered error");
2377 }
2378 }
2379
2380 #[test]
2381 fn test_driver_registry_registration() {
2382 let mut registry = DriverRegistry::new();
2383
2384 assert!(!registry.has_driver(&DriverId::OpenAI));
2385 assert!(!registry.has_driver(&DriverId::Anthropic));
2386
2387 registry.register(DriverId::OpenAI, |_config| {
2388 struct MockDriver;
2389 #[async_trait]
2390 impl ChatDriver for MockDriver {
2391 async fn chat_completion_stream(
2392 &self,
2393 _messages: Vec<LlmMessage>,
2394 _config: &LlmCallConfig,
2395 ) -> Result<LlmResponseStream> {
2396 unimplemented!()
2397 }
2398 }
2399 Box::new(MockDriver)
2400 });
2401
2402 assert!(registry.has_driver(&DriverId::OpenAI));
2403 assert!(!registry.has_driver(&DriverId::Anthropic));
2404 }
2405
2406 #[test]
2407 fn test_register_external_and_create_driver_without_api_key() {
2408 struct MockDriver;
2409 #[async_trait]
2410 impl ChatDriver for MockDriver {
2411 async fn chat_completion_stream(
2412 &self,
2413 _messages: Vec<LlmMessage>,
2414 _config: &LlmCallConfig,
2415 ) -> Result<LlmResponseStream> {
2416 unimplemented!()
2417 }
2418 }
2419
2420 let mut registry = DriverRegistry::new();
2421 registry.register_external("openai-codex", |config| {
2422 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2424 Box::new(MockDriver)
2425 });
2426
2427 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2428
2429 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2431 ProviderMetadata {
2432 refresh_token: Some("rt".into()),
2433 ..Default::default()
2434 },
2435 );
2436 assert!(registry.create_chat_driver(&config).is_ok());
2437 }
2438
2439 #[test]
2440 fn test_register_defaults_to_chat_only_descriptor() {
2441 struct MockDriver;
2442 #[async_trait]
2443 impl ChatDriver for MockDriver {
2444 async fn chat_completion_stream(
2445 &self,
2446 _messages: Vec<LlmMessage>,
2447 _config: &LlmCallConfig,
2448 ) -> Result<LlmResponseStream> {
2449 unimplemented!()
2450 }
2451 }
2452
2453 let mut registry = DriverRegistry::new();
2454 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2455
2456 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2457 assert_eq!(descriptor.display_name, "Anthropic");
2458 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2459 assert!(descriptor.chat.is_some());
2460 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2462 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2463 assert!(descriptor.credential_schema.fields[0].required);
2464
2465 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2467 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2468 assert!(sim.credential_schema.fields.is_empty());
2469 }
2470
2471 #[test]
2472 fn test_descriptor_services_and_lookup() {
2473 struct MockDriver;
2474 #[async_trait]
2475 impl ChatDriver for MockDriver {
2476 async fn chat_completion_stream(
2477 &self,
2478 _messages: Vec<LlmMessage>,
2479 _config: &LlmCallConfig,
2480 ) -> Result<LlmResponseStream> {
2481 unimplemented!()
2482 }
2483 }
2484
2485 let mut registry = DriverRegistry::new();
2486 registry.register_descriptor(DriverDescriptor {
2487 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2488 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2489 });
2490 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2491
2492 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2493 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2494 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2495 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2496
2497 let realtime = registry.providers_for(ServiceKind::Realtime);
2498 assert_eq!(realtime, vec![DriverId::OpenAI]);
2499 let mut chat = registry.providers_for(ServiceKind::Chat);
2500 chat.sort_by_key(|p| p.to_string());
2501 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2502 }
2503
2504 #[test]
2505 fn test_create_chat_driver_fails_without_chat_factory() {
2506 let mut registry = DriverRegistry::new();
2507 registry.register_descriptor(DriverDescriptor {
2508 id: DriverId::external("embeddings-only"),
2509 display_name: "Embeddings Only".to_string(),
2510 services: vec![ServiceKind::Embeddings],
2511 credential_schema: CredentialFormSchema::empty(),
2512 chat: None,
2513 embeddings: None,
2514 });
2515
2516 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2517 let err = match registry.create_chat_driver(&config) {
2518 Ok(_) => panic!("expected error for missing chat factory"),
2519 Err(err) => err,
2520 };
2521 assert!(
2522 err.to_string()
2523 .contains("does not implement the chat service"),
2524 "unexpected error: {err}"
2525 );
2526 }
2527
2528 #[test]
2529 #[should_panic(expected = "already registered")]
2530 fn test_register_duplicate_panics() {
2531 struct MockDriver;
2532 #[async_trait]
2533 impl ChatDriver for MockDriver {
2534 async fn chat_completion_stream(
2535 &self,
2536 _messages: Vec<LlmMessage>,
2537 _config: &LlmCallConfig,
2538 ) -> Result<LlmResponseStream> {
2539 unimplemented!()
2540 }
2541 }
2542
2543 let mut registry = DriverRegistry::new();
2544 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2545 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2547 }
2548
2549 #[test]
2550 fn test_register_or_replace_overwrites() {
2551 struct MockDriver;
2552 #[async_trait]
2553 impl ChatDriver for MockDriver {
2554 async fn chat_completion_stream(
2555 &self,
2556 _messages: Vec<LlmMessage>,
2557 _config: &LlmCallConfig,
2558 ) -> Result<LlmResponseStream> {
2559 unimplemented!()
2560 }
2561 }
2562
2563 let mut registry = DriverRegistry::new();
2564 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2565 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2567 assert!(registry.has_driver(&DriverId::LlmSim));
2568 }
2569
2570 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2575
2576 #[test]
2577 fn test_message_has_image_files_with_image_file() {
2578 let message = Message {
2579 id: uuid::Uuid::new_v4().into(),
2580 role: MessageRole::User,
2581 content: vec![
2582 ContentPart::Text(TextContentPart {
2583 text: "Look at this image".to_string(),
2584 }),
2585 ContentPart::ImageFile(ImageFileContentPart {
2586 image_id: uuid::Uuid::new_v4().into(),
2587 filename: Some("test.png".to_string()),
2588 }),
2589 ],
2590 phase: None,
2591 thinking: None,
2592 thinking_signature: None,
2593 controls: None,
2594 metadata: None,
2595 external_actor: None,
2596 created_at: chrono::Utc::now(),
2597 };
2598
2599 assert!(LlmMessage::message_has_image_files(&message));
2600 }
2601
2602 #[test]
2603 fn test_message_has_image_files_without_image_file() {
2604 let message = Message {
2605 id: uuid::Uuid::new_v4().into(),
2606 role: MessageRole::User,
2607 content: vec![ContentPart::Text(TextContentPart {
2608 text: "Just text".to_string(),
2609 })],
2610 phase: None,
2611 thinking: None,
2612 thinking_signature: None,
2613 controls: None,
2614 metadata: None,
2615 external_actor: None,
2616 created_at: chrono::Utc::now(),
2617 };
2618
2619 assert!(!LlmMessage::message_has_image_files(&message));
2620 }
2621
2622 #[test]
2623 fn test_extract_image_file_ids() {
2624 let id1 = uuid::Uuid::new_v4();
2625 let id2 = uuid::Uuid::new_v4();
2626
2627 let message = Message {
2628 id: uuid::Uuid::new_v4().into(),
2629 role: MessageRole::User,
2630 content: vec![
2631 ContentPart::Text(TextContentPart {
2632 text: "Look at these images".to_string(),
2633 }),
2634 ContentPart::ImageFile(ImageFileContentPart {
2635 image_id: id1.into(),
2636 filename: Some("test1.png".to_string()),
2637 }),
2638 ContentPart::ImageFile(ImageFileContentPart {
2639 image_id: id2.into(),
2640 filename: Some("test2.png".to_string()),
2641 }),
2642 ],
2643 phase: None,
2644 thinking: None,
2645 thinking_signature: None,
2646 controls: None,
2647 metadata: None,
2648 external_actor: None,
2649 created_at: chrono::Utc::now(),
2650 };
2651
2652 let ids = LlmMessage::extract_image_file_ids(&message);
2653 assert_eq!(ids.len(), 2);
2654 assert!(ids.contains(&id1));
2655 assert!(ids.contains(&id2));
2656 }
2657
2658 #[test]
2659 fn test_from_message_with_images_text_only() {
2660 let message = Message {
2661 id: uuid::Uuid::new_v4().into(),
2662 role: MessageRole::User,
2663 content: vec![ContentPart::Text(TextContentPart {
2664 text: "Hello".to_string(),
2665 })],
2666 phase: None,
2667 thinking: None,
2668 thinking_signature: None,
2669 controls: None,
2670 metadata: None,
2671 external_actor: None,
2672 created_at: chrono::Utc::now(),
2673 };
2674
2675 let resolved = std::collections::HashMap::new();
2676 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2677
2678 assert_eq!(llm_message.role, LlmMessageRole::User);
2679 match llm_message.content {
2680 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2681 _ => panic!("Expected text content"),
2682 }
2683 }
2684
2685 #[test]
2686 fn test_from_message_with_images_resolved_image() {
2687 let image_id = uuid::Uuid::new_v4();
2688 let message = Message {
2689 id: uuid::Uuid::new_v4().into(),
2690 role: MessageRole::User,
2691 content: vec![
2692 ContentPart::Text(TextContentPart {
2693 text: "Look at this".to_string(),
2694 }),
2695 ContentPart::ImageFile(ImageFileContentPart {
2696 image_id: image_id.into(),
2697 filename: Some("test.png".to_string()),
2698 }),
2699 ],
2700 phase: None,
2701 thinking: None,
2702 thinking_signature: None,
2703 controls: None,
2704 metadata: None,
2705 external_actor: None,
2706 created_at: chrono::Utc::now(),
2707 };
2708
2709 let mut resolved = std::collections::HashMap::new();
2710 resolved.insert(
2711 image_id,
2712 crate::ResolvedImage::new("base64data", "image/png"),
2713 );
2714
2715 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2716
2717 match &llm_message.content {
2718 LlmMessageContent::Parts(parts) => {
2719 assert_eq!(parts.len(), 2);
2720 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2722 if let LlmContentPart::Image { url } = &parts[1] {
2724 assert!(url.starts_with("data:image/png;base64,"));
2725 } else {
2726 panic!("Expected image content part");
2727 }
2728 }
2729 _ => panic!("Expected parts content"),
2730 }
2731 }
2732
2733 #[test]
2734 fn test_from_message_with_images_unresolved_image() {
2735 let image_id = uuid::Uuid::new_v4();
2736 let message = Message {
2737 id: uuid::Uuid::new_v4().into(),
2738 role: MessageRole::User,
2739 content: vec![ContentPart::ImageFile(ImageFileContentPart {
2740 image_id: image_id.into(),
2741 filename: Some("missing.png".to_string()),
2742 })],
2743 phase: None,
2744 thinking: None,
2745 thinking_signature: None,
2746 controls: None,
2747 metadata: None,
2748 external_actor: None,
2749 created_at: chrono::Utc::now(),
2750 };
2751
2752 let resolved = std::collections::HashMap::new();
2754 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2755
2756 match &llm_message.content {
2759 LlmMessageContent::Text(text) => {
2760 assert!(text.contains("Image not found"));
2761 }
2762 LlmMessageContent::Parts(parts) => {
2763 assert_eq!(parts.len(), 1);
2764 if let LlmContentPart::Text { text } = &parts[0] {
2765 assert!(text.contains("Image not found"));
2766 } else {
2767 panic!("Expected text placeholder for missing image");
2768 }
2769 }
2770 }
2771 }
2772
2773 #[test]
2774 fn test_prepend_text_prefix_simple_text() {
2775 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2776 msg.prepend_text_prefix("[Alice] ");
2777 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2778 }
2779
2780 #[test]
2781 fn test_prepend_text_prefix_parts() {
2782 let mut msg = LlmMessage::parts(
2783 LlmMessageRole::User,
2784 vec![
2785 LlmContentPart::Text {
2786 text: "Hello".to_string(),
2787 },
2788 LlmContentPart::Image {
2789 url: "data:image/png;base64,abc".to_string(),
2790 },
2791 ],
2792 );
2793 msg.prepend_text_prefix("[Bob] ");
2794 match &msg.content {
2795 LlmMessageContent::Parts(parts) => {
2796 if let LlmContentPart::Text { text } = &parts[0] {
2797 assert_eq!(text, "[Bob] Hello");
2798 } else {
2799 panic!("Expected text part");
2800 }
2801 }
2802 _ => panic!("Expected parts content"),
2803 }
2804 }
2805
2806 #[test]
2807 fn test_prepend_text_prefix_parts_no_text() {
2808 let mut msg = LlmMessage::parts(
2809 LlmMessageRole::User,
2810 vec![LlmContentPart::Image {
2811 url: "data:image/png;base64,abc".to_string(),
2812 }],
2813 );
2814 msg.prepend_text_prefix("[Eve] ");
2815 match &msg.content {
2816 LlmMessageContent::Parts(parts) => {
2817 assert_eq!(parts.len(), 2);
2818 if let LlmContentPart::Text { text } = &parts[0] {
2819 assert_eq!(text, "[Eve] ");
2820 } else {
2821 panic!("Expected prepended text part");
2822 }
2823 }
2824 _ => panic!("Expected parts content"),
2825 }
2826 }
2827
2828 #[test]
2829 fn test_openrouter_plugin_config_is_empty() {
2830 assert!(OpenRouterPluginConfig::default().is_empty());
2831 assert!(
2832 !OpenRouterPluginConfig {
2833 web: Some(OpenRouterWebSearchPlugin::default()),
2834 file: None,
2835 }
2836 .is_empty()
2837 );
2838 assert!(
2839 !OpenRouterPluginConfig {
2840 web: None,
2841 file: Some(OpenRouterFilePlugin {}),
2842 }
2843 .is_empty()
2844 );
2845 }
2846
2847 #[test]
2848 fn test_openrouter_routing_is_empty_with_plugins() {
2849 let with_plugins = OpenRouterRoutingConfig {
2850 plugins: Some(OpenRouterPluginConfig {
2851 web: Some(OpenRouterWebSearchPlugin::default()),
2852 file: None,
2853 }),
2854 ..Default::default()
2855 };
2856 assert!(!with_plugins.is_empty());
2857
2858 let empty_plugins = OpenRouterRoutingConfig {
2859 plugins: Some(OpenRouterPluginConfig::default()),
2860 ..Default::default()
2861 };
2862 assert!(empty_plugins.is_empty());
2863 }
2864
2865 #[test]
2866 fn test_openrouter_web_search_plugin_serialization() {
2867 let plugin = OpenRouterWebSearchPlugin {
2868 max_results: Some(10),
2869 search_prompt: Some("search for Rust crates".to_string()),
2870 };
2871 let json = serde_json::to_value(&plugin).unwrap();
2872 assert_eq!(json["max_results"], 10);
2873 assert_eq!(json["search_prompt"], "search for Rust crates");
2874 }
2875
2876 #[test]
2877 fn test_openrouter_web_search_plugin_omits_none_fields() {
2878 let plugin = OpenRouterWebSearchPlugin::default();
2879 let json = serde_json::to_value(&plugin).unwrap();
2880 assert!(json.get("max_results").is_none());
2881 assert!(json.get("search_prompt").is_none());
2882 }
2883
2884 #[test]
2885 fn test_capacity_strategy_shared_capacity_is_noop() {
2886 let base = OpenRouterRoutingConfig {
2887 models: vec!["openai/gpt-5-mini".to_string()],
2888 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2889 ..Default::default()
2890 };
2891 let result = base.apply_capacity_strategy().unwrap();
2892 assert_eq!(
2893 result.capacity_strategy,
2894 Some(OpenRouterCapacityStrategy::SharedCapacity)
2895 );
2896 assert!(result.provider.is_none());
2897 }
2898
2899 #[test]
2900 fn test_capacity_strategy_none_is_noop() {
2901 let base = OpenRouterRoutingConfig {
2902 models: vec!["openai/gpt-5-mini".to_string()],
2903 capacity_strategy: None,
2904 ..Default::default()
2905 };
2906 let result = base.apply_capacity_strategy().unwrap();
2907 assert!(result.provider.is_none());
2908 }
2909
2910 #[test]
2911 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2912 let base = OpenRouterRoutingConfig {
2913 models: vec!["openai/gpt-5-mini".to_string()],
2914 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2915 ..Default::default()
2916 };
2917 let result = base.apply_capacity_strategy().unwrap();
2918 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2919 assert_eq!(provider.allow_fallbacks, Some(true));
2920 }
2921
2922 #[test]
2923 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2924 let base = OpenRouterRoutingConfig {
2926 models: vec!["openai/gpt-5-mini".to_string()],
2927 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2928 provider: Some(OpenRouterProviderRouting {
2929 allow_fallbacks: Some(false),
2930 ..Default::default()
2931 }),
2932 ..Default::default()
2933 };
2934 let result = base.apply_capacity_strategy().unwrap();
2935 let provider = result.provider.as_ref().unwrap();
2936 assert_eq!(provider.allow_fallbacks, Some(false));
2937 }
2938
2939 #[test]
2940 fn test_capacity_strategy_byok_only_requires_provider_only() {
2941 let base = OpenRouterRoutingConfig {
2942 models: vec!["openai/gpt-5-mini".to_string()],
2943 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2944 ..Default::default()
2945 };
2946 let err = base.apply_capacity_strategy().unwrap_err();
2947 assert!(
2948 err.contains("provider.only"),
2949 "error should mention provider.only: {err}"
2950 );
2951 }
2952
2953 #[test]
2954 fn test_capacity_strategy_byok_only_disables_fallbacks() {
2955 let base = OpenRouterRoutingConfig {
2956 models: vec!["openai/gpt-5-mini".to_string()],
2957 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2958 provider: Some(OpenRouterProviderRouting {
2959 only: vec!["my-byok-provider".to_string()],
2960 ..Default::default()
2961 }),
2962 ..Default::default()
2963 };
2964 let result = base.apply_capacity_strategy().unwrap();
2965 let provider = result.provider.as_ref().unwrap();
2966 assert_eq!(provider.allow_fallbacks, Some(false));
2967 assert_eq!(provider.only, vec!["my-byok-provider"]);
2968 }
2969
2970 #[test]
2971 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
2972 let with_strategy = OpenRouterRoutingConfig {
2973 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2974 ..Default::default()
2975 };
2976 assert!(!with_strategy.is_empty());
2977
2978 let byok_first = OpenRouterRoutingConfig {
2979 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2980 ..Default::default()
2981 };
2982 assert!(!byok_first.is_empty());
2983
2984 let shared = OpenRouterRoutingConfig {
2985 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2986 ..Default::default()
2987 };
2988 assert!(shared.is_empty());
2989 }
2990
2991 #[test]
2996 fn test_preset_no_presets_is_noop() {
2997 let base = OpenRouterRoutingConfig {
2998 models: vec!["openai/gpt-5-mini".to_string()],
2999 ..Default::default()
3000 };
3001 let result = base.apply_presets().unwrap();
3002 assert_eq!(result, base);
3003 }
3004
3005 #[test]
3006 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3007 let base = OpenRouterRoutingConfig {
3008 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3009 ..Default::default()
3010 };
3011 let result = base.apply_presets().unwrap();
3012 assert!(result.presets.is_empty(), "presets cleared after apply");
3013 let provider = result.provider.expect("provider set by preset");
3014 assert_eq!(provider.require_parameters, Some(true));
3015 assert_eq!(
3016 provider.sort,
3017 Some(OpenRouterProviderSort::Simple(
3018 OpenRouterProviderSortBy::Price
3019 ))
3020 );
3021 }
3022
3023 #[test]
3024 fn test_preset_lowest_latency_review_sets_sort_throughput() {
3025 let base = OpenRouterRoutingConfig {
3026 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3027 ..Default::default()
3028 };
3029 let result = base.apply_presets().unwrap();
3030 let provider = result.provider.expect("provider set by preset");
3031 assert_eq!(
3032 provider.sort,
3033 Some(OpenRouterProviderSort::Simple(
3034 OpenRouterProviderSortBy::Throughput
3035 ))
3036 );
3037 }
3038
3039 #[test]
3040 fn test_preset_zdr_only_sets_zdr() {
3041 let base = OpenRouterRoutingConfig {
3042 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3043 ..Default::default()
3044 };
3045 let result = base.apply_presets().unwrap();
3046 let provider = result.provider.expect("provider set");
3047 assert_eq!(provider.zdr, Some(true));
3048 }
3049
3050 #[test]
3051 fn test_preset_byok_first_sets_allow_fallbacks() {
3052 let base = OpenRouterRoutingConfig {
3053 presets: vec![OpenRouterRoutingPreset::ByokFirst],
3054 ..Default::default()
3055 };
3056 let result = base.apply_presets().unwrap();
3057 let provider = result.provider.expect("provider set");
3058 assert_eq!(provider.allow_fallbacks, Some(true));
3059 }
3060
3061 #[test]
3062 fn test_preset_no_data_collection_sets_data_collection_deny() {
3063 let base = OpenRouterRoutingConfig {
3064 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3065 ..Default::default()
3066 };
3067 let result = base.apply_presets().unwrap();
3068 let provider = result.provider.expect("provider set");
3069 assert_eq!(
3070 provider.data_collection,
3071 Some(OpenRouterDataCollection::Deny)
3072 );
3073 }
3074
3075 #[test]
3076 fn test_preset_strict_json_sets_require_parameters() {
3077 let base = OpenRouterRoutingConfig {
3078 presets: vec![OpenRouterRoutingPreset::StrictJson],
3079 ..Default::default()
3080 };
3081 let result = base.apply_presets().unwrap();
3082 let provider = result.provider.expect("provider set");
3083 assert_eq!(provider.require_parameters, Some(true));
3084 }
3085
3086 #[test]
3087 fn test_preset_reasoning_required_sets_require_parameters() {
3088 let base = OpenRouterRoutingConfig {
3089 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3090 ..Default::default()
3091 };
3092 let result = base.apply_presets().unwrap();
3093 let provider = result.provider.expect("provider set");
3094 assert_eq!(provider.require_parameters, Some(true));
3095 }
3096
3097 #[test]
3098 fn test_preset_max_price_converts_usd_per_million() {
3099 let base = OpenRouterRoutingConfig {
3100 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3101 prompt_usd_per_million: Some(5.0),
3102 completion_usd_per_million: Some(15.0),
3103 }],
3104 ..Default::default()
3105 };
3106 let result = base.apply_presets().unwrap();
3107 let provider = result.provider.expect("provider set");
3108 let max_price = provider.max_price.expect("max_price set");
3109 let prompt = max_price.prompt.expect("prompt set");
3111 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3112 let completion = max_price.completion.expect("completion set");
3113 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3114 }
3115
3116 #[test]
3117 fn test_preset_max_price_rejects_negative_values() {
3118 let base = OpenRouterRoutingConfig {
3119 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3120 prompt_usd_per_million: Some(-1.0),
3121 completion_usd_per_million: None,
3122 }],
3123 ..Default::default()
3124 };
3125 let err = base.apply_presets().unwrap_err();
3126 assert!(
3127 err.contains("non-negative"),
3128 "error should mention non-negative: {err}"
3129 );
3130 }
3131
3132 #[test]
3133 fn test_preset_max_price_both_none_no_provider_field() {
3134 let base = OpenRouterRoutingConfig {
3135 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3136 prompt_usd_per_million: None,
3137 completion_usd_per_million: None,
3138 }],
3139 ..Default::default()
3140 };
3141 let result = base.apply_presets().unwrap();
3142 assert!(
3143 result.provider.is_none(),
3144 "MaxPrice with no dimensions should not produce a provider field"
3145 );
3146 }
3147
3148 #[test]
3149 fn test_preset_explicit_provider_overrides_preset() {
3150 let base = OpenRouterRoutingConfig {
3151 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3152 provider: Some(OpenRouterProviderRouting {
3153 sort: Some(OpenRouterProviderSort::Simple(
3155 OpenRouterProviderSortBy::Throughput,
3156 )),
3157 ..Default::default()
3158 }),
3159 ..Default::default()
3160 };
3161 let result = base.apply_presets().unwrap();
3162 let provider = result.provider.expect("provider set");
3163 assert_eq!(
3165 provider.sort,
3166 Some(OpenRouterProviderSort::Simple(
3167 OpenRouterProviderSortBy::Throughput
3168 ))
3169 );
3170 assert_eq!(provider.require_parameters, Some(true));
3172 }
3173
3174 #[test]
3175 fn test_preset_multiple_presets_combined() {
3176 let base = OpenRouterRoutingConfig {
3177 presets: vec![
3178 OpenRouterRoutingPreset::ZdrOnly,
3179 OpenRouterRoutingPreset::NoDataCollection,
3180 OpenRouterRoutingPreset::LowestLatencyReview,
3181 ],
3182 ..Default::default()
3183 };
3184 let result = base.apply_presets().unwrap();
3185 let provider = result.provider.expect("provider set");
3186 assert_eq!(provider.zdr, Some(true));
3187 assert_eq!(
3188 provider.data_collection,
3189 Some(OpenRouterDataCollection::Deny)
3190 );
3191 assert_eq!(
3192 provider.sort,
3193 Some(OpenRouterProviderSort::Simple(
3194 OpenRouterProviderSortBy::Throughput
3195 ))
3196 );
3197 }
3198
3199 #[test]
3200 fn test_preset_later_preset_overrides_sort() {
3201 let base = OpenRouterRoutingConfig {
3202 presets: vec![
3203 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3206 ..Default::default()
3207 };
3208 let result = base.apply_presets().unwrap();
3209 let provider = result.provider.expect("provider set");
3210 assert_eq!(
3212 provider.sort,
3213 Some(OpenRouterProviderSort::Simple(
3214 OpenRouterProviderSortBy::Throughput
3215 ))
3216 );
3217 assert_eq!(provider.require_parameters, Some(true));
3219 }
3220
3221 #[test]
3222 fn test_preset_non_empty_in_is_empty() {
3223 let with_preset = OpenRouterRoutingConfig {
3224 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3225 ..Default::default()
3226 };
3227 assert!(!with_preset.is_empty());
3228
3229 let without = OpenRouterRoutingConfig::default();
3230 assert!(without.is_empty());
3231 }
3232}