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(Debug, Clone, Copy, PartialEq, Eq)]
1699pub enum DriverOAuthFlow {
1700 OpenRouterPkce,
1708}
1709
1710#[derive(Debug, Clone)]
1715pub struct DriverOAuthConfig {
1716 pub authorize_url: String,
1718 pub token_url: String,
1720 pub flow: DriverOAuthFlow,
1722}
1723
1724impl DriverOAuthConfig {
1725 pub fn openrouter() -> Self {
1727 Self {
1728 authorize_url: "https://openrouter.ai/auth".to_string(),
1729 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1730 flow: DriverOAuthFlow::OpenRouterPkce,
1731 }
1732 }
1733}
1734
1735#[derive(Clone)]
1742pub struct DriverDescriptor {
1743 pub id: DriverId,
1745 pub display_name: String,
1747 pub services: Vec<ServiceKind>,
1749 pub credential_schema: CredentialFormSchema,
1751 pub oauth: Option<DriverOAuthConfig>,
1754 pub chat: Option<DriverFactory>,
1756 pub embeddings: Option<EmbeddingsDriverFactory>,
1758}
1759
1760impl DriverDescriptor {
1761 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1766 where
1767 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1768 {
1769 let id = id.into();
1770 Self {
1771 display_name: default_display_name(&id),
1772 credential_schema: default_credential_schema(&id),
1773 services: vec![ServiceKind::Chat],
1774 oauth: None,
1775 chat: Some(Arc::new(factory)),
1776 embeddings: None,
1777 id,
1778 }
1779 }
1780
1781 pub fn supports(&self, service: ServiceKind) -> bool {
1783 self.services.contains(&service)
1784 }
1785}
1786
1787impl std::fmt::Debug for DriverDescriptor {
1788 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1789 f.debug_struct("DriverDescriptor")
1790 .field("id", &self.id)
1791 .field("display_name", &self.display_name)
1792 .field("services", &self.services)
1793 .field("oauth", &self.oauth.is_some())
1794 .field("chat", &self.chat.is_some())
1795 .field("embeddings", &self.embeddings.is_some())
1796 .finish()
1797 }
1798}
1799
1800fn default_display_name(id: &DriverId) -> String {
1801 match id {
1802 DriverId::OpenAI => "OpenAI".to_string(),
1803 DriverId::OpenRouter => "OpenRouter".to_string(),
1804 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1805 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1806 DriverId::Anthropic => "Anthropic".to_string(),
1807 DriverId::Gemini => "Google Gemini".to_string(),
1808 DriverId::Bedrock => "AWS Bedrock".to_string(),
1809 DriverId::Mai => "Microsoft MAI".to_string(),
1810 DriverId::Fireworks => "Fireworks AI".to_string(),
1811 DriverId::LlmSim => "LLM Simulator".to_string(),
1812 DriverId::External(id) => id.to_string(),
1813 }
1814}
1815
1816fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1817 match id {
1818 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1820 _ => CredentialFormSchema::api_key(String::new()),
1821 }
1822}
1823
1824#[derive(Clone, Default)]
1844pub struct DriverRegistry {
1845 descriptors: HashMap<DriverId, DriverDescriptor>,
1846}
1847
1848impl DriverRegistry {
1849 pub fn new() -> Self {
1851 Self {
1852 descriptors: HashMap::new(),
1853 }
1854 }
1855
1856 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1862 if self.descriptors.contains_key(&descriptor.id) {
1863 panic!(
1864 "driver already registered for provider '{}'; \
1865 use register_descriptor_or_replace to overwrite intentionally",
1866 descriptor.id
1867 );
1868 }
1869 self.descriptors.insert(descriptor.id.clone(), descriptor);
1870 }
1871
1872 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1874 self.descriptors.insert(descriptor.id.clone(), descriptor);
1875 }
1876
1877 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1883 where
1884 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1885 {
1886 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1887 }
1888
1889 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1894 where
1895 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1896 {
1897 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1898 }
1899
1900 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1905 where
1906 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1907 {
1908 self.register(DriverId::external(id), factory);
1909 }
1910
1911 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1920 let requires_api_key = !matches!(
1924 config.provider_type,
1925 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
1926 );
1927 if requires_api_key && config.api_key.is_none() {
1928 return Err(AgentLoopError::llm(
1929 "API key is required. Configure the API key in provider settings.",
1930 ));
1931 }
1932
1933 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1935 AgentLoopError::driver_not_registered(config.provider_type.to_string())
1936 })?;
1937 let factory = descriptor.chat.as_ref().ok_or_else(|| {
1938 AgentLoopError::llm(format!(
1939 "Provider driver '{}' does not implement the chat service.",
1940 config.provider_type
1941 ))
1942 })?;
1943
1944 let driver_config = DriverConfig {
1946 provider_type: config.provider_type.clone(),
1947 api_key: config.api_key.clone(),
1948 base_url: config.base_url.clone(),
1949 metadata: config.metadata.clone(),
1950 };
1951 Ok(factory(&driver_config))
1952 }
1953
1954 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1956 self.descriptors.contains_key(provider_type)
1957 }
1958
1959 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1961 self.descriptors.get(provider_type)
1962 }
1963
1964 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1966 self.descriptors
1967 .get(provider_type)
1968 .is_some_and(|d| d.supports(service))
1969 }
1970
1971 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1973 self.descriptors
1974 .values()
1975 .filter(|d| d.supports(service))
1976 .map(|d| d.id.clone())
1977 .collect()
1978 }
1979
1980 pub fn registered_providers(&self) -> Vec<DriverId> {
1982 self.descriptors.keys().cloned().collect()
1983 }
1984
1985 pub fn create_embeddings_driver(
1993 &self,
1994 config: &ProviderConfig,
1995 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
1996 let requires_api_key = !matches!(
1997 config.provider_type,
1998 DriverId::LlmSim | DriverId::External(_)
1999 );
2000 if requires_api_key && config.api_key.is_none() {
2001 return Err(EmbeddingsDriverError::Provider(
2002 "API key is required. Configure the API key in provider settings.".to_string(),
2003 ));
2004 }
2005 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2006 EmbeddingsDriverError::Provider(format!(
2007 "No driver registered for provider '{}'",
2008 config.provider_type
2009 ))
2010 })?;
2011 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2012 EmbeddingsDriverError::Provider(format!(
2013 "Provider driver '{}' does not implement the embeddings service.",
2014 config.provider_type
2015 ))
2016 })?;
2017 let driver_config = DriverConfig {
2018 provider_type: config.provider_type.clone(),
2019 api_key: config.api_key.clone(),
2020 base_url: config.base_url.clone(),
2021 metadata: config.metadata.clone(),
2022 };
2023 Ok(factory(&driver_config))
2024 }
2025}
2026
2027const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2032
2033const TRUNCATION_SUFFIX: &str =
2034 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2035
2036fn truncate_tool_result(text: String) -> String {
2037 if text.len() <= MAX_TOOL_RESULT_BYTES {
2038 return text;
2039 }
2040 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2041 let mut end = content_budget;
2042 while end > 0 && !text.is_char_boundary(end) {
2043 end -= 1;
2044 }
2045 let mut truncated = text[..end].to_string();
2046 truncated.push_str(TRUNCATION_SUFFIX);
2047 truncated
2048}
2049
2050#[cfg(test)]
2055mod tests {
2056 use super::*;
2057
2058 #[test]
2059 fn test_fold_system_messages_none_when_absent() {
2060 let messages = vec![
2061 LlmMessage::text(LlmMessageRole::User, "hi"),
2062 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2063 ];
2064 assert_eq!(fold_system_messages(&messages), None);
2065 }
2066
2067 #[test]
2068 fn test_fold_system_messages_single() {
2069 let messages = vec![
2070 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2071 LlmMessage::text(LlmMessageRole::User, "hi"),
2072 ];
2073 assert_eq!(
2074 fold_system_messages(&messages),
2075 Some("AGENT-PROMPT".to_string())
2076 );
2077 }
2078
2079 #[test]
2080 fn test_fold_system_messages_accumulates_in_order() {
2081 let messages = vec![
2085 LlmMessage::text(LlmMessageRole::System, "A"),
2086 LlmMessage::text(LlmMessageRole::User, "hi"),
2087 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2088 LlmMessage::text(LlmMessageRole::System, "B"),
2089 ];
2090 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2091 }
2092
2093 #[test]
2094 fn test_fold_system_messages_concatenates_parts() {
2095 let messages = vec![LlmMessage::parts(
2096 LlmMessageRole::System,
2097 vec![
2098 LlmContentPart::text("foo"),
2099 LlmContentPart::image("data:image/png;base64,xxx"),
2100 LlmContentPart::text("bar"),
2101 ],
2102 )];
2103 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2104 }
2105
2106 #[test]
2107 fn test_llm_call_config_builder_from_runtime_agent() {
2108 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2109 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2110
2111 assert_eq!(llm_config.model, "gpt-4o");
2112 assert!(llm_config.reasoning_effort.is_none());
2113 assert!(llm_config.temperature.is_none());
2114 assert!(llm_config.max_tokens.is_none());
2115 assert!(llm_config.tools.is_empty());
2116 assert!(llm_config.metadata.is_empty());
2117 assert!(llm_config.openrouter_routing.is_none());
2119 }
2120
2121 #[test]
2122 fn runtime_agent_openrouter_routing_flows_into_call_config() {
2123 let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2127 runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2128 server_tools: vec![OpenRouterServerTool::new(
2129 OpenRouterServerToolKind::WebSearch,
2130 )],
2131 ..Default::default()
2132 });
2133
2134 let llm_config = LlmCallConfig::from(&runtime_agent);
2135 let routing = llm_config
2136 .openrouter_routing
2137 .expect("server-tool routing survives into the call config");
2138 assert_eq!(routing.server_tools.len(), 1);
2139 assert_eq!(
2140 routing.server_tools[0].kind.wire_type(),
2141 "openrouter:web_search"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_llm_call_config_builder_with_metadata() {
2147 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2148 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2149 .with_metadata("session_id", "session_abc123")
2150 .with_metadata("agent_id", "agent_xyz789")
2151 .build();
2152
2153 assert_eq!(
2154 llm_config.metadata.get("session_id"),
2155 Some(&"session_abc123".to_string())
2156 );
2157 assert_eq!(
2158 llm_config.metadata.get("agent_id"),
2159 Some(&"agent_xyz789".to_string())
2160 );
2161 }
2162
2163 #[test]
2164 fn test_llm_call_config_builder_with_metadata_hashmap() {
2165 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2166 let mut metadata = HashMap::new();
2167 metadata.insert("key1".to_string(), "value1".to_string());
2168 metadata.insert("key2".to_string(), "value2".to_string());
2169
2170 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2171 .metadata(metadata)
2172 .build();
2173
2174 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2175 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2176 }
2177
2178 #[test]
2179 fn test_llm_call_config_builder_with_reasoning_effort() {
2180 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2181 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2182 .reasoning_effort("high")
2183 .build();
2184
2185 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2186 }
2187
2188 #[test]
2189 fn test_llm_call_config_builder_with_all_options() {
2190 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2191 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2192 .model("claude-3-opus")
2193 .reasoning_effort("medium")
2194 .temperature(0.7)
2195 .max_tokens(1000)
2196 .build();
2197
2198 assert_eq!(llm_config.model, "claude-3-opus");
2199 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2200 assert_eq!(llm_config.temperature, Some(0.7));
2201 assert_eq!(llm_config.max_tokens, Some(1000));
2202 }
2203
2204 #[test]
2205 fn test_llm_call_config_builder_with_openrouter_routing() {
2206 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2207 let routing = OpenRouterRoutingConfig::fallback_models([
2208 "openai/gpt-5-mini",
2209 "anthropic/claude-sonnet-4.5",
2210 ]);
2211
2212 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2213 .openrouter_routing(routing.clone())
2214 .build();
2215
2216 assert_eq!(llm_config.openrouter_routing, Some(routing));
2217 }
2218
2219 #[test]
2220 fn test_openrouter_fallback_models_empty_is_empty() {
2221 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2222
2223 assert!(routing.is_empty());
2224 assert_eq!(routing.route, None);
2225 }
2226
2227 #[test]
2228 fn test_openrouter_routing_validates_primary_model() {
2229 let routing = OpenRouterRoutingConfig::fallback_models([
2230 "openai/gpt-5-mini",
2231 "anthropic/claude-sonnet-4.5",
2232 ]);
2233
2234 assert!(
2235 routing
2236 .validate_for_primary_model("openai/gpt-5-mini")
2237 .is_ok()
2238 );
2239 let err = routing
2240 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2241 .unwrap_err();
2242 assert!(err.contains("models[0]"));
2243 }
2244
2245 #[test]
2246 fn test_openrouter_routing_rejects_fallback_without_models() {
2247 let routing = OpenRouterRoutingConfig {
2248 route: Some(OpenRouterRoute::Fallback),
2249 ..Default::default()
2250 };
2251
2252 let err = routing
2253 .validate_for_primary_model("openai/gpt-5-mini")
2254 .unwrap_err();
2255 assert!(err.contains("requires at least one model"));
2256 }
2257
2258 #[test]
2259 fn test_openrouter_routing_serializes_request_fields() {
2260 let routing = OpenRouterRoutingConfig {
2261 models: vec![
2262 "openai/gpt-5-mini".to_string(),
2263 "anthropic/claude-sonnet-4.5".to_string(),
2264 ],
2265 route: Some(OpenRouterRoute::Fallback),
2266 provider: Some(OpenRouterProviderRouting {
2267 order: vec!["anthropic".to_string(), "openai".to_string()],
2268 allow_fallbacks: Some(false),
2269 require_parameters: Some(true),
2270 data_collection: Some(OpenRouterDataCollection::Deny),
2271 zdr: Some(true),
2272 sort: Some(OpenRouterProviderSort::Advanced(
2273 OpenRouterProviderSortOptions {
2274 by: OpenRouterProviderSortBy::Throughput,
2275 partition: Some(OpenRouterSortPartition::None),
2276 },
2277 )),
2278 max_price: Some(OpenRouterMaxPrice {
2279 prompt: Some(1.0),
2280 completion: Some(2.0),
2281 ..Default::default()
2282 }),
2283 ..Default::default()
2284 }),
2285 ..Default::default()
2286 };
2287
2288 let json = serde_json::to_value(routing).unwrap();
2289
2290 assert_eq!(
2291 json,
2292 serde_json::json!({
2293 "models": [
2294 "openai/gpt-5-mini",
2295 "anthropic/claude-sonnet-4.5"
2296 ],
2297 "route": "fallback",
2298 "provider": {
2299 "order": ["anthropic", "openai"],
2300 "allow_fallbacks": false,
2301 "require_parameters": true,
2302 "data_collection": "deny",
2303 "zdr": true,
2304 "sort": {
2305 "by": "throughput",
2306 "partition": "none"
2307 },
2308 "max_price": {
2309 "prompt": 1.0,
2310 "completion": 2.0
2311 }
2312 }
2313 })
2314 );
2315 }
2316
2317 #[test]
2318 fn test_provider_type_parsing() {
2319 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2320 assert_eq!(
2321 "openrouter".parse::<DriverId>().unwrap(),
2322 DriverId::OpenRouter
2323 );
2324 assert_eq!(
2325 "openai_completions".parse::<DriverId>().unwrap(),
2326 DriverId::OpenAICompletions
2327 );
2328 assert_eq!(
2329 "azure_openai".parse::<DriverId>().unwrap(),
2330 DriverId::AzureOpenAI
2331 );
2332 assert_eq!(
2333 "anthropic".parse::<DriverId>().unwrap(),
2334 DriverId::Anthropic
2335 );
2336 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2337 assert_eq!(
2339 "ollama".parse::<DriverId>().unwrap(),
2340 DriverId::external("ollama")
2341 );
2342 assert_eq!(
2343 "custom".parse::<DriverId>().unwrap(),
2344 DriverId::external("custom")
2345 );
2346 }
2347
2348 #[test]
2349 fn test_external_provider_id_is_case_insensitive() {
2350 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2353 assert_eq!(
2354 "Ollama".parse::<DriverId>().unwrap(),
2355 "ollama".parse::<DriverId>().unwrap()
2356 );
2357 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2358 assert_eq!(
2360 DriverId::external("MyProvider"),
2361 "myprovider".parse::<DriverId>().unwrap()
2362 );
2363 }
2364
2365 #[test]
2366 fn test_provider_type_display() {
2367 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2368 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2369 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2370 assert_eq!(
2371 DriverId::OpenAICompletions.to_string(),
2372 "openai_completions"
2373 );
2374 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2375 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2376 }
2377
2378 #[test]
2379 fn test_provider_config_builder() {
2380 let config = ProviderConfig::new(DriverId::Anthropic)
2381 .with_api_key("test-key")
2382 .with_base_url("https://custom.api.com");
2383
2384 assert_eq!(config.provider_type, DriverId::Anthropic);
2385 assert_eq!(config.api_key, Some("test-key".to_string()));
2386 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2387 }
2388
2389 #[test]
2390 fn test_driver_registry_requires_api_key() {
2391 let mut registry = DriverRegistry::new();
2393 registry.register(DriverId::OpenAI, |_config| {
2394 struct MockDriver;
2396 #[async_trait]
2397 impl ChatDriver for MockDriver {
2398 async fn chat_completion_stream(
2399 &self,
2400 _messages: Vec<LlmMessage>,
2401 _config: &LlmCallConfig,
2402 ) -> Result<LlmResponseStream> {
2403 unimplemented!()
2404 }
2405 }
2406 Box::new(MockDriver)
2407 });
2408
2409 let config = ProviderConfig::new(DriverId::OpenAI);
2411 let result = registry.create_chat_driver(&config);
2412 assert!(result.is_err());
2413
2414 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2416 let result = registry.create_chat_driver(&config_with_key);
2417 assert!(result.is_ok());
2418 }
2419
2420 #[test]
2421 fn test_driver_registry_returns_error_for_unregistered_provider() {
2422 let registry = DriverRegistry::new();
2423 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2424
2425 let result = registry.create_chat_driver(&config);
2426
2427 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2429 assert_eq!(provider, "anthropic");
2430 } else {
2431 panic!("Expected DriverNotRegistered error");
2432 }
2433 }
2434
2435 #[test]
2436 fn test_driver_registry_registration() {
2437 let mut registry = DriverRegistry::new();
2438
2439 assert!(!registry.has_driver(&DriverId::OpenAI));
2440 assert!(!registry.has_driver(&DriverId::Anthropic));
2441
2442 registry.register(DriverId::OpenAI, |_config| {
2443 struct MockDriver;
2444 #[async_trait]
2445 impl ChatDriver for MockDriver {
2446 async fn chat_completion_stream(
2447 &self,
2448 _messages: Vec<LlmMessage>,
2449 _config: &LlmCallConfig,
2450 ) -> Result<LlmResponseStream> {
2451 unimplemented!()
2452 }
2453 }
2454 Box::new(MockDriver)
2455 });
2456
2457 assert!(registry.has_driver(&DriverId::OpenAI));
2458 assert!(!registry.has_driver(&DriverId::Anthropic));
2459 }
2460
2461 #[test]
2462 fn test_register_external_and_create_driver_without_api_key() {
2463 struct MockDriver;
2464 #[async_trait]
2465 impl ChatDriver for MockDriver {
2466 async fn chat_completion_stream(
2467 &self,
2468 _messages: Vec<LlmMessage>,
2469 _config: &LlmCallConfig,
2470 ) -> Result<LlmResponseStream> {
2471 unimplemented!()
2472 }
2473 }
2474
2475 let mut registry = DriverRegistry::new();
2476 registry.register_external("openai-codex", |config| {
2477 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2479 Box::new(MockDriver)
2480 });
2481
2482 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2483
2484 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2486 ProviderMetadata {
2487 refresh_token: Some("rt".into()),
2488 ..Default::default()
2489 },
2490 );
2491 assert!(registry.create_chat_driver(&config).is_ok());
2492 }
2493
2494 #[test]
2495 fn test_register_defaults_to_chat_only_descriptor() {
2496 struct MockDriver;
2497 #[async_trait]
2498 impl ChatDriver for MockDriver {
2499 async fn chat_completion_stream(
2500 &self,
2501 _messages: Vec<LlmMessage>,
2502 _config: &LlmCallConfig,
2503 ) -> Result<LlmResponseStream> {
2504 unimplemented!()
2505 }
2506 }
2507
2508 let mut registry = DriverRegistry::new();
2509 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2510
2511 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2512 assert_eq!(descriptor.display_name, "Anthropic");
2513 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2514 assert!(descriptor.chat.is_some());
2515 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2517 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2518 assert!(descriptor.credential_schema.fields[0].required);
2519
2520 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2522 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2523 assert!(sim.credential_schema.fields.is_empty());
2524 }
2525
2526 #[test]
2527 fn test_descriptor_services_and_lookup() {
2528 struct MockDriver;
2529 #[async_trait]
2530 impl ChatDriver for MockDriver {
2531 async fn chat_completion_stream(
2532 &self,
2533 _messages: Vec<LlmMessage>,
2534 _config: &LlmCallConfig,
2535 ) -> Result<LlmResponseStream> {
2536 unimplemented!()
2537 }
2538 }
2539
2540 let mut registry = DriverRegistry::new();
2541 registry.register_descriptor(DriverDescriptor {
2542 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2543 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2544 });
2545 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2546
2547 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2548 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2549 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2550 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2551
2552 let realtime = registry.providers_for(ServiceKind::Realtime);
2553 assert_eq!(realtime, vec![DriverId::OpenAI]);
2554 let mut chat = registry.providers_for(ServiceKind::Chat);
2555 chat.sort_by_key(|p| p.to_string());
2556 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2557 }
2558
2559 #[test]
2560 fn test_create_chat_driver_fails_without_chat_factory() {
2561 let mut registry = DriverRegistry::new();
2562 registry.register_descriptor(DriverDescriptor {
2563 id: DriverId::external("embeddings-only"),
2564 display_name: "Embeddings Only".to_string(),
2565 services: vec![ServiceKind::Embeddings],
2566 credential_schema: CredentialFormSchema::empty(),
2567 oauth: None,
2568 chat: None,
2569 embeddings: None,
2570 });
2571
2572 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2573 let err = match registry.create_chat_driver(&config) {
2574 Ok(_) => panic!("expected error for missing chat factory"),
2575 Err(err) => err,
2576 };
2577 assert!(
2578 err.to_string()
2579 .contains("does not implement the chat service"),
2580 "unexpected error: {err}"
2581 );
2582 }
2583
2584 #[test]
2585 #[should_panic(expected = "already registered")]
2586 fn test_register_duplicate_panics() {
2587 struct MockDriver;
2588 #[async_trait]
2589 impl ChatDriver for MockDriver {
2590 async fn chat_completion_stream(
2591 &self,
2592 _messages: Vec<LlmMessage>,
2593 _config: &LlmCallConfig,
2594 ) -> Result<LlmResponseStream> {
2595 unimplemented!()
2596 }
2597 }
2598
2599 let mut registry = DriverRegistry::new();
2600 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2601 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2603 }
2604
2605 #[test]
2606 fn test_register_or_replace_overwrites() {
2607 struct MockDriver;
2608 #[async_trait]
2609 impl ChatDriver for MockDriver {
2610 async fn chat_completion_stream(
2611 &self,
2612 _messages: Vec<LlmMessage>,
2613 _config: &LlmCallConfig,
2614 ) -> Result<LlmResponseStream> {
2615 unimplemented!()
2616 }
2617 }
2618
2619 let mut registry = DriverRegistry::new();
2620 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2621 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2623 assert!(registry.has_driver(&DriverId::LlmSim));
2624 }
2625
2626 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2631
2632 #[test]
2633 fn test_message_has_image_files_with_image_file() {
2634 let message = Message {
2635 id: uuid::Uuid::new_v4().into(),
2636 role: MessageRole::User,
2637 content: vec![
2638 ContentPart::Text(TextContentPart {
2639 text: "Look at this image".to_string(),
2640 }),
2641 ContentPart::ImageFile(ImageFileContentPart {
2642 image_id: uuid::Uuid::new_v4().into(),
2643 filename: Some("test.png".to_string()),
2644 }),
2645 ],
2646 phase: None,
2647 thinking: None,
2648 thinking_signature: None,
2649 controls: None,
2650 metadata: None,
2651 external_actor: None,
2652 created_at: chrono::Utc::now(),
2653 };
2654
2655 assert!(LlmMessage::message_has_image_files(&message));
2656 }
2657
2658 #[test]
2659 fn test_message_has_image_files_without_image_file() {
2660 let message = Message {
2661 id: uuid::Uuid::new_v4().into(),
2662 role: MessageRole::User,
2663 content: vec![ContentPart::Text(TextContentPart {
2664 text: "Just text".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 assert!(!LlmMessage::message_has_image_files(&message));
2676 }
2677
2678 #[test]
2679 fn test_extract_image_file_ids() {
2680 let id1 = uuid::Uuid::new_v4();
2681 let id2 = uuid::Uuid::new_v4();
2682
2683 let message = Message {
2684 id: uuid::Uuid::new_v4().into(),
2685 role: MessageRole::User,
2686 content: vec![
2687 ContentPart::Text(TextContentPart {
2688 text: "Look at these images".to_string(),
2689 }),
2690 ContentPart::ImageFile(ImageFileContentPart {
2691 image_id: id1.into(),
2692 filename: Some("test1.png".to_string()),
2693 }),
2694 ContentPart::ImageFile(ImageFileContentPart {
2695 image_id: id2.into(),
2696 filename: Some("test2.png".to_string()),
2697 }),
2698 ],
2699 phase: None,
2700 thinking: None,
2701 thinking_signature: None,
2702 controls: None,
2703 metadata: None,
2704 external_actor: None,
2705 created_at: chrono::Utc::now(),
2706 };
2707
2708 let ids = LlmMessage::extract_image_file_ids(&message);
2709 assert_eq!(ids.len(), 2);
2710 assert!(ids.contains(&id1));
2711 assert!(ids.contains(&id2));
2712 }
2713
2714 #[test]
2715 fn test_from_message_with_images_text_only() {
2716 let message = Message {
2717 id: uuid::Uuid::new_v4().into(),
2718 role: MessageRole::User,
2719 content: vec![ContentPart::Text(TextContentPart {
2720 text: "Hello".to_string(),
2721 })],
2722 phase: None,
2723 thinking: None,
2724 thinking_signature: None,
2725 controls: None,
2726 metadata: None,
2727 external_actor: None,
2728 created_at: chrono::Utc::now(),
2729 };
2730
2731 let resolved = std::collections::HashMap::new();
2732 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2733
2734 assert_eq!(llm_message.role, LlmMessageRole::User);
2735 match llm_message.content {
2736 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2737 _ => panic!("Expected text content"),
2738 }
2739 }
2740
2741 #[test]
2742 fn test_from_message_with_images_resolved_image() {
2743 let image_id = uuid::Uuid::new_v4();
2744 let message = Message {
2745 id: uuid::Uuid::new_v4().into(),
2746 role: MessageRole::User,
2747 content: vec![
2748 ContentPart::Text(TextContentPart {
2749 text: "Look at this".to_string(),
2750 }),
2751 ContentPart::ImageFile(ImageFileContentPart {
2752 image_id: image_id.into(),
2753 filename: Some("test.png".to_string()),
2754 }),
2755 ],
2756 phase: None,
2757 thinking: None,
2758 thinking_signature: None,
2759 controls: None,
2760 metadata: None,
2761 external_actor: None,
2762 created_at: chrono::Utc::now(),
2763 };
2764
2765 let mut resolved = std::collections::HashMap::new();
2766 resolved.insert(
2767 image_id,
2768 crate::ResolvedImage::new("base64data", "image/png"),
2769 );
2770
2771 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2772
2773 match &llm_message.content {
2774 LlmMessageContent::Parts(parts) => {
2775 assert_eq!(parts.len(), 2);
2776 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2778 if let LlmContentPart::Image { url } = &parts[1] {
2780 assert!(url.starts_with("data:image/png;base64,"));
2781 } else {
2782 panic!("Expected image content part");
2783 }
2784 }
2785 _ => panic!("Expected parts content"),
2786 }
2787 }
2788
2789 #[test]
2790 fn test_from_message_with_images_unresolved_image() {
2791 let image_id = uuid::Uuid::new_v4();
2792 let message = Message {
2793 id: uuid::Uuid::new_v4().into(),
2794 role: MessageRole::User,
2795 content: vec![ContentPart::ImageFile(ImageFileContentPart {
2796 image_id: image_id.into(),
2797 filename: Some("missing.png".to_string()),
2798 })],
2799 phase: None,
2800 thinking: None,
2801 thinking_signature: None,
2802 controls: None,
2803 metadata: None,
2804 external_actor: None,
2805 created_at: chrono::Utc::now(),
2806 };
2807
2808 let resolved = std::collections::HashMap::new();
2810 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2811
2812 match &llm_message.content {
2815 LlmMessageContent::Text(text) => {
2816 assert!(text.contains("Image not found"));
2817 }
2818 LlmMessageContent::Parts(parts) => {
2819 assert_eq!(parts.len(), 1);
2820 if let LlmContentPart::Text { text } = &parts[0] {
2821 assert!(text.contains("Image not found"));
2822 } else {
2823 panic!("Expected text placeholder for missing image");
2824 }
2825 }
2826 }
2827 }
2828
2829 #[test]
2830 fn test_prepend_text_prefix_simple_text() {
2831 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2832 msg.prepend_text_prefix("[Alice] ");
2833 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2834 }
2835
2836 #[test]
2837 fn test_prepend_text_prefix_parts() {
2838 let mut msg = LlmMessage::parts(
2839 LlmMessageRole::User,
2840 vec![
2841 LlmContentPart::Text {
2842 text: "Hello".to_string(),
2843 },
2844 LlmContentPart::Image {
2845 url: "data:image/png;base64,abc".to_string(),
2846 },
2847 ],
2848 );
2849 msg.prepend_text_prefix("[Bob] ");
2850 match &msg.content {
2851 LlmMessageContent::Parts(parts) => {
2852 if let LlmContentPart::Text { text } = &parts[0] {
2853 assert_eq!(text, "[Bob] Hello");
2854 } else {
2855 panic!("Expected text part");
2856 }
2857 }
2858 _ => panic!("Expected parts content"),
2859 }
2860 }
2861
2862 #[test]
2863 fn test_prepend_text_prefix_parts_no_text() {
2864 let mut msg = LlmMessage::parts(
2865 LlmMessageRole::User,
2866 vec![LlmContentPart::Image {
2867 url: "data:image/png;base64,abc".to_string(),
2868 }],
2869 );
2870 msg.prepend_text_prefix("[Eve] ");
2871 match &msg.content {
2872 LlmMessageContent::Parts(parts) => {
2873 assert_eq!(parts.len(), 2);
2874 if let LlmContentPart::Text { text } = &parts[0] {
2875 assert_eq!(text, "[Eve] ");
2876 } else {
2877 panic!("Expected prepended text part");
2878 }
2879 }
2880 _ => panic!("Expected parts content"),
2881 }
2882 }
2883
2884 #[test]
2885 fn test_openrouter_plugin_config_is_empty() {
2886 assert!(OpenRouterPluginConfig::default().is_empty());
2887 assert!(
2888 !OpenRouterPluginConfig {
2889 web: Some(OpenRouterWebSearchPlugin::default()),
2890 file: None,
2891 }
2892 .is_empty()
2893 );
2894 assert!(
2895 !OpenRouterPluginConfig {
2896 web: None,
2897 file: Some(OpenRouterFilePlugin {}),
2898 }
2899 .is_empty()
2900 );
2901 }
2902
2903 #[test]
2904 fn test_openrouter_routing_is_empty_with_plugins() {
2905 let with_plugins = OpenRouterRoutingConfig {
2906 plugins: Some(OpenRouterPluginConfig {
2907 web: Some(OpenRouterWebSearchPlugin::default()),
2908 file: None,
2909 }),
2910 ..Default::default()
2911 };
2912 assert!(!with_plugins.is_empty());
2913
2914 let empty_plugins = OpenRouterRoutingConfig {
2915 plugins: Some(OpenRouterPluginConfig::default()),
2916 ..Default::default()
2917 };
2918 assert!(empty_plugins.is_empty());
2919 }
2920
2921 #[test]
2922 fn test_openrouter_web_search_plugin_serialization() {
2923 let plugin = OpenRouterWebSearchPlugin {
2924 max_results: Some(10),
2925 search_prompt: Some("search for Rust crates".to_string()),
2926 };
2927 let json = serde_json::to_value(&plugin).unwrap();
2928 assert_eq!(json["max_results"], 10);
2929 assert_eq!(json["search_prompt"], "search for Rust crates");
2930 }
2931
2932 #[test]
2933 fn test_openrouter_web_search_plugin_omits_none_fields() {
2934 let plugin = OpenRouterWebSearchPlugin::default();
2935 let json = serde_json::to_value(&plugin).unwrap();
2936 assert!(json.get("max_results").is_none());
2937 assert!(json.get("search_prompt").is_none());
2938 }
2939
2940 #[test]
2941 fn test_capacity_strategy_shared_capacity_is_noop() {
2942 let base = OpenRouterRoutingConfig {
2943 models: vec!["openai/gpt-5-mini".to_string()],
2944 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2945 ..Default::default()
2946 };
2947 let result = base.apply_capacity_strategy().unwrap();
2948 assert_eq!(
2949 result.capacity_strategy,
2950 Some(OpenRouterCapacityStrategy::SharedCapacity)
2951 );
2952 assert!(result.provider.is_none());
2953 }
2954
2955 #[test]
2956 fn test_capacity_strategy_none_is_noop() {
2957 let base = OpenRouterRoutingConfig {
2958 models: vec!["openai/gpt-5-mini".to_string()],
2959 capacity_strategy: None,
2960 ..Default::default()
2961 };
2962 let result = base.apply_capacity_strategy().unwrap();
2963 assert!(result.provider.is_none());
2964 }
2965
2966 #[test]
2967 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2968 let base = OpenRouterRoutingConfig {
2969 models: vec!["openai/gpt-5-mini".to_string()],
2970 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2971 ..Default::default()
2972 };
2973 let result = base.apply_capacity_strategy().unwrap();
2974 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2975 assert_eq!(provider.allow_fallbacks, Some(true));
2976 }
2977
2978 #[test]
2979 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2980 let base = OpenRouterRoutingConfig {
2982 models: vec!["openai/gpt-5-mini".to_string()],
2983 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2984 provider: Some(OpenRouterProviderRouting {
2985 allow_fallbacks: Some(false),
2986 ..Default::default()
2987 }),
2988 ..Default::default()
2989 };
2990 let result = base.apply_capacity_strategy().unwrap();
2991 let provider = result.provider.as_ref().unwrap();
2992 assert_eq!(provider.allow_fallbacks, Some(false));
2993 }
2994
2995 #[test]
2996 fn test_capacity_strategy_byok_only_requires_provider_only() {
2997 let base = OpenRouterRoutingConfig {
2998 models: vec!["openai/gpt-5-mini".to_string()],
2999 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3000 ..Default::default()
3001 };
3002 let err = base.apply_capacity_strategy().unwrap_err();
3003 assert!(
3004 err.contains("provider.only"),
3005 "error should mention provider.only: {err}"
3006 );
3007 }
3008
3009 #[test]
3010 fn test_capacity_strategy_byok_only_disables_fallbacks() {
3011 let base = OpenRouterRoutingConfig {
3012 models: vec!["openai/gpt-5-mini".to_string()],
3013 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3014 provider: Some(OpenRouterProviderRouting {
3015 only: vec!["my-byok-provider".to_string()],
3016 ..Default::default()
3017 }),
3018 ..Default::default()
3019 };
3020 let result = base.apply_capacity_strategy().unwrap();
3021 let provider = result.provider.as_ref().unwrap();
3022 assert_eq!(provider.allow_fallbacks, Some(false));
3023 assert_eq!(provider.only, vec!["my-byok-provider"]);
3024 }
3025
3026 #[test]
3027 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3028 let with_strategy = OpenRouterRoutingConfig {
3029 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3030 ..Default::default()
3031 };
3032 assert!(!with_strategy.is_empty());
3033
3034 let byok_first = OpenRouterRoutingConfig {
3035 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3036 ..Default::default()
3037 };
3038 assert!(!byok_first.is_empty());
3039
3040 let shared = OpenRouterRoutingConfig {
3041 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3042 ..Default::default()
3043 };
3044 assert!(shared.is_empty());
3045 }
3046
3047 #[test]
3052 fn test_preset_no_presets_is_noop() {
3053 let base = OpenRouterRoutingConfig {
3054 models: vec!["openai/gpt-5-mini".to_string()],
3055 ..Default::default()
3056 };
3057 let result = base.apply_presets().unwrap();
3058 assert_eq!(result, base);
3059 }
3060
3061 #[test]
3062 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3063 let base = OpenRouterRoutingConfig {
3064 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3065 ..Default::default()
3066 };
3067 let result = base.apply_presets().unwrap();
3068 assert!(result.presets.is_empty(), "presets cleared after apply");
3069 let provider = result.provider.expect("provider set by preset");
3070 assert_eq!(provider.require_parameters, Some(true));
3071 assert_eq!(
3072 provider.sort,
3073 Some(OpenRouterProviderSort::Simple(
3074 OpenRouterProviderSortBy::Price
3075 ))
3076 );
3077 }
3078
3079 #[test]
3080 fn test_preset_lowest_latency_review_sets_sort_throughput() {
3081 let base = OpenRouterRoutingConfig {
3082 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3083 ..Default::default()
3084 };
3085 let result = base.apply_presets().unwrap();
3086 let provider = result.provider.expect("provider set by preset");
3087 assert_eq!(
3088 provider.sort,
3089 Some(OpenRouterProviderSort::Simple(
3090 OpenRouterProviderSortBy::Throughput
3091 ))
3092 );
3093 }
3094
3095 #[test]
3096 fn test_preset_zdr_only_sets_zdr() {
3097 let base = OpenRouterRoutingConfig {
3098 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3099 ..Default::default()
3100 };
3101 let result = base.apply_presets().unwrap();
3102 let provider = result.provider.expect("provider set");
3103 assert_eq!(provider.zdr, Some(true));
3104 }
3105
3106 #[test]
3107 fn test_preset_byok_first_sets_allow_fallbacks() {
3108 let base = OpenRouterRoutingConfig {
3109 presets: vec![OpenRouterRoutingPreset::ByokFirst],
3110 ..Default::default()
3111 };
3112 let result = base.apply_presets().unwrap();
3113 let provider = result.provider.expect("provider set");
3114 assert_eq!(provider.allow_fallbacks, Some(true));
3115 }
3116
3117 #[test]
3118 fn test_preset_no_data_collection_sets_data_collection_deny() {
3119 let base = OpenRouterRoutingConfig {
3120 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3121 ..Default::default()
3122 };
3123 let result = base.apply_presets().unwrap();
3124 let provider = result.provider.expect("provider set");
3125 assert_eq!(
3126 provider.data_collection,
3127 Some(OpenRouterDataCollection::Deny)
3128 );
3129 }
3130
3131 #[test]
3132 fn test_preset_strict_json_sets_require_parameters() {
3133 let base = OpenRouterRoutingConfig {
3134 presets: vec![OpenRouterRoutingPreset::StrictJson],
3135 ..Default::default()
3136 };
3137 let result = base.apply_presets().unwrap();
3138 let provider = result.provider.expect("provider set");
3139 assert_eq!(provider.require_parameters, Some(true));
3140 }
3141
3142 #[test]
3143 fn test_preset_reasoning_required_sets_require_parameters() {
3144 let base = OpenRouterRoutingConfig {
3145 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3146 ..Default::default()
3147 };
3148 let result = base.apply_presets().unwrap();
3149 let provider = result.provider.expect("provider set");
3150 assert_eq!(provider.require_parameters, Some(true));
3151 }
3152
3153 #[test]
3154 fn test_preset_max_price_converts_usd_per_million() {
3155 let base = OpenRouterRoutingConfig {
3156 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3157 prompt_usd_per_million: Some(5.0),
3158 completion_usd_per_million: Some(15.0),
3159 }],
3160 ..Default::default()
3161 };
3162 let result = base.apply_presets().unwrap();
3163 let provider = result.provider.expect("provider set");
3164 let max_price = provider.max_price.expect("max_price set");
3165 let prompt = max_price.prompt.expect("prompt set");
3167 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3168 let completion = max_price.completion.expect("completion set");
3169 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3170 }
3171
3172 #[test]
3173 fn test_preset_max_price_rejects_negative_values() {
3174 let base = OpenRouterRoutingConfig {
3175 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3176 prompt_usd_per_million: Some(-1.0),
3177 completion_usd_per_million: None,
3178 }],
3179 ..Default::default()
3180 };
3181 let err = base.apply_presets().unwrap_err();
3182 assert!(
3183 err.contains("non-negative"),
3184 "error should mention non-negative: {err}"
3185 );
3186 }
3187
3188 #[test]
3189 fn test_preset_max_price_both_none_no_provider_field() {
3190 let base = OpenRouterRoutingConfig {
3191 presets: vec![OpenRouterRoutingPreset::MaxPrice {
3192 prompt_usd_per_million: None,
3193 completion_usd_per_million: None,
3194 }],
3195 ..Default::default()
3196 };
3197 let result = base.apply_presets().unwrap();
3198 assert!(
3199 result.provider.is_none(),
3200 "MaxPrice with no dimensions should not produce a provider field"
3201 );
3202 }
3203
3204 #[test]
3205 fn test_preset_explicit_provider_overrides_preset() {
3206 let base = OpenRouterRoutingConfig {
3207 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3208 provider: Some(OpenRouterProviderRouting {
3209 sort: Some(OpenRouterProviderSort::Simple(
3211 OpenRouterProviderSortBy::Throughput,
3212 )),
3213 ..Default::default()
3214 }),
3215 ..Default::default()
3216 };
3217 let result = base.apply_presets().unwrap();
3218 let provider = result.provider.expect("provider set");
3219 assert_eq!(
3221 provider.sort,
3222 Some(OpenRouterProviderSort::Simple(
3223 OpenRouterProviderSortBy::Throughput
3224 ))
3225 );
3226 assert_eq!(provider.require_parameters, Some(true));
3228 }
3229
3230 #[test]
3231 fn test_preset_multiple_presets_combined() {
3232 let base = OpenRouterRoutingConfig {
3233 presets: vec![
3234 OpenRouterRoutingPreset::ZdrOnly,
3235 OpenRouterRoutingPreset::NoDataCollection,
3236 OpenRouterRoutingPreset::LowestLatencyReview,
3237 ],
3238 ..Default::default()
3239 };
3240 let result = base.apply_presets().unwrap();
3241 let provider = result.provider.expect("provider set");
3242 assert_eq!(provider.zdr, Some(true));
3243 assert_eq!(
3244 provider.data_collection,
3245 Some(OpenRouterDataCollection::Deny)
3246 );
3247 assert_eq!(
3248 provider.sort,
3249 Some(OpenRouterProviderSort::Simple(
3250 OpenRouterProviderSortBy::Throughput
3251 ))
3252 );
3253 }
3254
3255 #[test]
3256 fn test_preset_later_preset_overrides_sort() {
3257 let base = OpenRouterRoutingConfig {
3258 presets: vec![
3259 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3262 ..Default::default()
3263 };
3264 let result = base.apply_presets().unwrap();
3265 let provider = result.provider.expect("provider set");
3266 assert_eq!(
3268 provider.sort,
3269 Some(OpenRouterProviderSort::Simple(
3270 OpenRouterProviderSortBy::Throughput
3271 ))
3272 );
3273 assert_eq!(provider.require_parameters, Some(true));
3275 }
3276
3277 #[test]
3278 fn test_preset_non_empty_in_is_empty() {
3279 let with_preset = OpenRouterRoutingConfig {
3280 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3281 ..Default::default()
3282 };
3283 assert!(!with_preset.is_empty());
3284
3285 let without = OpenRouterRoutingConfig::default();
3286 assert!(without.is_empty());
3287 }
3288}