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, Default, PartialEq, serde::Serialize, serde::Deserialize)]
609#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
610pub struct OpenRouterRoutingConfig {
611 #[serde(default, skip_serializing_if = "Vec::is_empty")]
613 pub models: Vec<String>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub route: Option<OpenRouterRoute>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub provider: Option<OpenRouterProviderRouting>,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub plugins: Option<OpenRouterPluginConfig>,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
629 #[serde(default, skip_serializing_if = "Vec::is_empty")]
633 pub presets: Vec<OpenRouterRoutingPreset>,
634}
635
636impl OpenRouterRoutingConfig {
637 pub fn is_empty(&self) -> bool {
638 self.models.is_empty()
639 && self.route.is_none()
640 && self.provider.is_none()
641 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
642 && matches!(
643 self.capacity_strategy,
644 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
645 )
646 && self.presets.is_empty()
647 }
648
649 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
651 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
652 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
653 Self {
654 models,
655 route,
656 provider: None,
657 plugins: None,
658 capacity_strategy: None,
659 presets: vec![],
660 }
661 }
662
663 pub fn validate_for_primary_model(
664 &self,
665 primary_model: &str,
666 ) -> std::result::Result<(), String> {
667 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
668 return Err(
669 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
670 );
671 }
672
673 if let Some(first_model) = self.models.first()
674 && first_model != primary_model
675 {
676 return Err(format!(
677 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
678 ));
679 }
680
681 Ok(())
682 }
683
684 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
694 match self.capacity_strategy {
695 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
696 Some(OpenRouterCapacityStrategy::ByokFirst) => {
697 let mut result = self.clone();
698 let provider = result.provider.get_or_insert_with(Default::default);
699 if provider.allow_fallbacks.is_none() {
700 provider.allow_fallbacks = Some(true);
701 }
702 Ok(result)
703 }
704 Some(OpenRouterCapacityStrategy::ByokOnly) => {
705 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
706 if only_is_empty {
707 return Err(
708 "OpenRouter BYOK-only strategy requires provider.only to list at least \
709 one upstream provider slug. Configure the provider list to match the \
710 BYOK providers registered in your OpenRouter workspace."
711 .to_string(),
712 );
713 }
714 let mut result = self.clone();
715 let provider = result.provider.get_or_insert_with(Default::default);
716 provider.allow_fallbacks = Some(false);
717 Ok(result)
718 }
719 }
720 }
721
722 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
732 if self.presets.is_empty() {
733 return Ok(self.clone());
734 }
735
736 let mut derived = OpenRouterProviderRouting::default();
737
738 for preset in &self.presets {
739 match preset {
740 OpenRouterRoutingPreset::CheapestWithTools => {
741 derived.require_parameters = Some(true);
742 derived.sort = Some(OpenRouterProviderSort::Simple(
743 OpenRouterProviderSortBy::Price,
744 ));
745 }
746 OpenRouterRoutingPreset::LowestLatencyReview => {
747 derived.sort = Some(OpenRouterProviderSort::Simple(
748 OpenRouterProviderSortBy::Throughput,
749 ));
750 }
751 OpenRouterRoutingPreset::ZdrOnly => {
752 derived.zdr = Some(true);
753 }
754 OpenRouterRoutingPreset::ByokFirst => {
755 if derived.allow_fallbacks.is_none() {
756 derived.allow_fallbacks = Some(true);
757 }
758 }
759 OpenRouterRoutingPreset::NoDataCollection => {
760 derived.data_collection = Some(OpenRouterDataCollection::Deny);
761 }
762 OpenRouterRoutingPreset::StrictJson
763 | OpenRouterRoutingPreset::ReasoningRequired => {
764 derived.require_parameters = Some(true);
765 }
766 OpenRouterRoutingPreset::MaxPrice {
767 prompt_usd_per_million,
768 completion_usd_per_million,
769 } => {
770 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
771 || completion_usd_per_million.is_some_and(|v| v < 0.0)
772 {
773 return Err(
774 "MaxPrice preset values must be non-negative USD per million tokens"
775 .to_string(),
776 );
777 }
778 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
779 let mp = derived.max_price.get_or_insert_with(Default::default);
780 if let Some(p) = prompt_usd_per_million {
781 mp.prompt = Some(p / 1_000_000.0);
782 }
783 if let Some(c) = completion_usd_per_million {
784 mp.completion = Some(c / 1_000_000.0);
785 }
786 }
787 }
788 }
789 }
790
791 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
793
794 let mut result = self.clone();
795 result.presets = vec![];
796 result.provider = if merged.is_empty() {
797 None
798 } else {
799 Some(merged)
800 };
801 Ok(result)
802 }
803}
804
805fn merge_provider_routing(
809 derived: OpenRouterProviderRouting,
810 explicit: OpenRouterProviderRouting,
811) -> OpenRouterProviderRouting {
812 OpenRouterProviderRouting {
813 order: if !explicit.order.is_empty() {
814 explicit.order
815 } else {
816 derived.order
817 },
818 only: if !explicit.only.is_empty() {
819 explicit.only
820 } else {
821 derived.only
822 },
823 ignore: if !explicit.ignore.is_empty() {
824 explicit.ignore
825 } else {
826 derived.ignore
827 },
828 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
829 require_parameters: explicit.require_parameters.or(derived.require_parameters),
830 data_collection: explicit.data_collection.or(derived.data_collection),
831 zdr: explicit.zdr.or(derived.zdr),
832 enforce_distillable_text: explicit
833 .enforce_distillable_text
834 .or(derived.enforce_distillable_text),
835 quantizations: if !explicit.quantizations.is_empty() {
836 explicit.quantizations
837 } else {
838 derived.quantizations
839 },
840 sort: explicit.sort.or(derived.sort),
841 max_price: explicit.max_price.or(derived.max_price),
842 }
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
847#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
848#[serde(rename_all = "snake_case")]
849pub enum OpenRouterRoute {
850 Fallback,
851}
852
853#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
855#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
856pub struct OpenRouterProviderRouting {
857 #[serde(default, skip_serializing_if = "Vec::is_empty")]
859 pub order: Vec<String>,
860 #[serde(default, skip_serializing_if = "Vec::is_empty")]
862 pub only: Vec<String>,
863 #[serde(default, skip_serializing_if = "Vec::is_empty")]
865 pub ignore: Vec<String>,
866 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub allow_fallbacks: Option<bool>,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub require_parameters: Option<bool>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub data_collection: Option<OpenRouterDataCollection>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
877 pub zdr: Option<bool>,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
880 pub enforce_distillable_text: Option<bool>,
881 #[serde(default, skip_serializing_if = "Vec::is_empty")]
883 pub quantizations: Vec<String>,
884 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub sort: Option<OpenRouterProviderSort>,
887 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub max_price: Option<OpenRouterMaxPrice>,
890}
891
892impl OpenRouterProviderRouting {
893 pub fn is_empty(&self) -> bool {
894 self.order.is_empty()
895 && self.only.is_empty()
896 && self.ignore.is_empty()
897 && self.allow_fallbacks.is_none()
898 && self.require_parameters.is_none()
899 && self.data_collection.is_none()
900 && self.zdr.is_none()
901 && self.enforce_distillable_text.is_none()
902 && self.quantizations.is_empty()
903 && self.sort.is_none()
904 && self.max_price.is_none()
905 }
906}
907
908#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
910#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
911#[serde(rename_all = "snake_case")]
912pub enum OpenRouterDataCollection {
913 Allow,
914 Deny,
915}
916
917#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
919#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
920#[serde(untagged)]
921pub enum OpenRouterProviderSort {
922 Simple(OpenRouterProviderSortBy),
923 Advanced(OpenRouterProviderSortOptions),
924}
925
926#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
928#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
929#[serde(rename_all = "snake_case")]
930pub enum OpenRouterProviderSortBy {
931 Price,
932 Throughput,
933 Latency,
934}
935
936#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
938#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
939pub struct OpenRouterProviderSortOptions {
940 pub by: OpenRouterProviderSortBy,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
942 pub partition: Option<OpenRouterSortPartition>,
943}
944
945#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
947#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
948#[serde(rename_all = "snake_case")]
949pub enum OpenRouterSortPartition {
950 Model,
951 None,
952}
953
954#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
957#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
958pub struct OpenRouterMaxPrice {
959 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub prompt: Option<f64>,
961 #[serde(default, skip_serializing_if = "Option::is_none")]
962 pub completion: Option<f64>,
963 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub request: Option<f64>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
966 pub image: Option<f64>,
967}
968
969#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
975#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
976pub struct OpenRouterWebSearchPlugin {
977 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub max_results: Option<u32>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub search_prompt: Option<String>,
983}
984
985#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
990#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
991pub struct OpenRouterFilePlugin {}
992
993#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
998#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
999pub struct OpenRouterPluginConfig {
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub web: Option<OpenRouterWebSearchPlugin>,
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub file: Option<OpenRouterFilePlugin>,
1006}
1007
1008impl OpenRouterPluginConfig {
1009 pub fn is_empty(&self) -> bool {
1010 self.web.is_none() && self.file.is_none()
1011 }
1012}
1013
1014pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
1016pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
1018
1019#[derive(Debug, Clone)]
1021pub struct LlmCallConfig {
1022 pub model: String,
1023 pub temperature: Option<f32>,
1024 pub max_tokens: Option<u32>,
1025 pub tools: Vec<ToolDefinition>,
1026 pub reasoning_effort: Option<String>,
1028 pub metadata: HashMap<String, String>,
1032 pub previous_response_id: Option<String>,
1035 pub tool_search: Option<ToolSearchConfig>,
1037 pub prompt_cache: Option<PromptCacheConfig>,
1039 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1041}
1042
1043impl From<&RuntimeAgent> for LlmCallConfig {
1044 fn from(runtime_agent: &RuntimeAgent) -> Self {
1045 Self {
1046 model: runtime_agent.model.clone(),
1047 temperature: runtime_agent.temperature,
1048 max_tokens: runtime_agent.max_tokens,
1049 tools: runtime_agent.tools.clone(),
1050 reasoning_effort: None, metadata: HashMap::new(), previous_response_id: None,
1053 tool_search: runtime_agent.tool_search.clone(),
1054 prompt_cache: runtime_agent.prompt_cache.clone(),
1055 openrouter_routing: None,
1056 }
1057 }
1058}
1059
1060#[derive(Debug, Clone)]
1062pub struct LlmResponse {
1063 pub text: String,
1064 pub thinking: Option<String>,
1066 pub thinking_signature: Option<String>,
1068 pub tool_calls: Option<Vec<ToolCall>>,
1069 pub metadata: LlmCompletionMetadata,
1070}
1071
1072pub struct LlmCallConfigBuilder {
1091 config: LlmCallConfig,
1092}
1093
1094impl LlmCallConfigBuilder {
1095 pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1097 Self {
1098 config: LlmCallConfig::from(runtime_agent),
1099 }
1100 }
1101
1102 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1104 self.config.reasoning_effort = Some(effort.into());
1105 self
1106 }
1107
1108 pub fn model(mut self, model: impl Into<String>) -> Self {
1110 self.config.model = model.into();
1111 self
1112 }
1113
1114 pub fn temperature(mut self, temp: f32) -> Self {
1116 self.config.temperature = Some(temp);
1117 self
1118 }
1119
1120 pub fn max_tokens(mut self, tokens: u32) -> Self {
1122 self.config.max_tokens = Some(tokens);
1123 self
1124 }
1125
1126 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1128 self.config.tools = tools;
1129 self
1130 }
1131
1132 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1137 self.config.metadata = metadata;
1138 self
1139 }
1140
1141 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1143 self.config.metadata.insert(key.into(), value.into());
1144 self
1145 }
1146
1147 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1149 self.config.previous_response_id = id;
1150 self
1151 }
1152
1153 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1155 self.config.tool_search = Some(config);
1156 self
1157 }
1158
1159 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1161 self.config.prompt_cache = Some(config);
1162 self
1163 }
1164
1165 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1167 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1168 self
1169 }
1170
1171 pub fn build(self) -> LlmCallConfig {
1173 self.config
1174 }
1175}
1176
1177impl From<&crate::message::Message> for LlmMessage {
1182 fn from(msg: &crate::message::Message) -> Self {
1188 let role = match msg.role {
1189 crate::message::MessageRole::System => LlmMessageRole::System,
1190 crate::message::MessageRole::User => LlmMessageRole::User,
1191 crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1192 crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1193 };
1194
1195 let tool_calls: Vec<ToolCall> = msg
1197 .tool_calls()
1198 .into_iter()
1199 .map(|tc| ToolCall {
1200 id: tc.id.clone(),
1201 name: tc.name.clone(),
1202 arguments: tc.arguments.clone(),
1203 })
1204 .collect();
1205
1206 LlmMessage {
1207 role,
1208 content: LlmMessageContent::Text(msg.content_to_llm_string()),
1209 tool_calls: if tool_calls.is_empty() {
1210 None
1211 } else {
1212 Some(tool_calls)
1213 },
1214 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1215 phase: msg.phase,
1216 thinking: msg.thinking.clone(),
1217 thinking_signature: msg.thinking_signature.clone(),
1218 }
1219 }
1220}
1221
1222use crate::traits::ResolvedImage;
1227use uuid::Uuid;
1228
1229impl LlmMessage {
1230 pub fn from_message_with_images(
1250 msg: &crate::message::Message,
1251 resolved_images: &HashMap<Uuid, ResolvedImage>,
1252 ) -> Self {
1253 use crate::message::{ContentPart, MessageRole};
1254
1255 let role = match msg.role {
1256 MessageRole::System => LlmMessageRole::System,
1257 MessageRole::User => LlmMessageRole::User,
1258 MessageRole::Agent => LlmMessageRole::Assistant,
1259 MessageRole::ToolResult => LlmMessageRole::Tool,
1260 };
1261
1262 let mut parts: Vec<LlmContentPart> = Vec::new();
1264 let mut tool_calls: Vec<ToolCall> = Vec::new();
1265
1266 for part in &msg.content {
1267 match part {
1268 ContentPart::Text(t) => {
1269 parts.push(LlmContentPart::Text {
1270 text: t.text.clone(),
1271 });
1272 }
1273 ContentPart::Image(img) => {
1274 if let Some(url) = &img.url {
1276 parts.push(LlmContentPart::Image { url: url.clone() });
1277 } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1278 {
1279 let data_url = format!("data:{};base64,{}", media_type, base64);
1280 parts.push(LlmContentPart::Image { url: data_url });
1281 }
1282 }
1283 ContentPart::ImageFile(img_file) => {
1284 if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1286 parts.push(LlmContentPart::Image {
1287 url: resolved.to_data_url(),
1288 });
1289 } else {
1290 parts.push(LlmContentPart::Text {
1292 text: format!("[Image not found: {}]", img_file.image_id),
1293 });
1294 }
1295 }
1296 ContentPart::ToolCall(tc) => {
1297 tool_calls.push(ToolCall {
1299 id: tc.id.clone(),
1300 name: tc.name.clone(),
1301 arguments: tc.arguments.clone(),
1302 });
1303 }
1304 ContentPart::ToolResult(tr) => {
1305 let text = if let Some(err) = &tr.error {
1307 format!("Tool error: {}", err)
1308 } else if let Some(res) = &tr.result {
1309 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1310 } else {
1311 "{}".to_string()
1312 };
1313 let text = truncate_tool_result(text);
1317 parts.push(LlmContentPart::Text { text });
1318 }
1319 }
1320 }
1321
1322 let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1324 if let LlmContentPart::Text { text } = &parts[0] {
1326 LlmMessageContent::Text(text.clone())
1327 } else {
1328 LlmMessageContent::Parts(parts)
1329 }
1330 } else if parts.is_empty() {
1331 LlmMessageContent::Text(String::new())
1333 } else {
1334 LlmMessageContent::Parts(parts)
1336 };
1337
1338 LlmMessage {
1339 role,
1340 content,
1341 tool_calls: if tool_calls.is_empty() {
1342 None
1343 } else {
1344 Some(tool_calls)
1345 },
1346 tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1347 phase: msg.phase,
1348 thinking: msg.thinking.clone(),
1349 thinking_signature: msg.thinking_signature.clone(),
1350 }
1351 }
1352
1353 pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1355 msg.content.iter().any(|p| p.is_image_file())
1356 }
1357
1358 pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1360 msg.content
1361 .iter()
1362 .filter_map(|p| match p {
1363 crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1364 _ => None,
1365 })
1366 .collect()
1367 }
1368}
1369
1370pub use crate::provider::DriverId;
1375
1376#[derive(Debug, Clone, Default, PartialEq, Eq)]
1382pub struct ProviderMetadata {
1383 pub refresh_token: Option<String>,
1385 pub account_id: Option<String>,
1387 pub extra: Option<serde_json::Value>,
1389}
1390
1391#[derive(Debug, Clone)]
1393pub struct ProviderConfig {
1394 pub provider_type: DriverId,
1396 pub api_key: Option<String>,
1398 pub base_url: Option<String>,
1400 pub metadata: ProviderMetadata,
1402}
1403
1404impl ProviderConfig {
1405 pub fn new(provider_type: DriverId) -> Self {
1407 Self {
1408 provider_type,
1409 api_key: None,
1410 base_url: None,
1411 metadata: ProviderMetadata::default(),
1412 }
1413 }
1414
1415 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1417 self.api_key = Some(api_key.into());
1418 self
1419 }
1420
1421 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1423 self.base_url = Some(base_url.into());
1424 self
1425 }
1426
1427 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1429 self.metadata = metadata;
1430 self
1431 }
1432}
1433
1434#[derive(Debug, Clone)]
1440pub struct DriverConfig {
1441 pub provider_type: DriverId,
1443 pub api_key: Option<String>,
1446 pub base_url: Option<String>,
1448 pub metadata: ProviderMetadata,
1450}
1451
1452impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1453 fn from(model: &crate::traits::ResolvedModel) -> Self {
1454 Self {
1455 provider_type: model.provider_type.clone(),
1456 api_key: model.api_key.clone(),
1457 base_url: model.base_url.clone(),
1458 metadata: model.provider_metadata.clone().unwrap_or_default(),
1459 }
1460 }
1461}
1462
1463pub type BoxedChatDriver = Box<dyn ChatDriver>;
1465
1466#[derive(Debug, Clone)]
1472pub struct EmbedRequest {
1473 pub texts: Vec<String>,
1475 pub model: String,
1477}
1478
1479#[derive(Debug, Clone)]
1481pub struct EmbedResponse {
1482 pub embeddings: Vec<Vec<f32>>,
1484 pub usage_tokens: Option<u32>,
1487}
1488
1489#[derive(Debug, thiserror::Error)]
1491pub enum EmbeddingsDriverError {
1492 #[error("embeddings provider returned an error: {0}")]
1493 Provider(String),
1494 #[error("embeddings request failed: {0}")]
1495 Transport(String),
1496}
1497
1498#[async_trait]
1504pub trait EmbeddingsDriver: Send + Sync {
1505 async fn embed(
1507 &self,
1508 request: EmbedRequest,
1509 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1510}
1511
1512pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1514
1515pub type EmbeddingsDriverFactory =
1517 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1518
1519pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1528
1529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1535#[serde(rename_all = "snake_case")]
1536pub enum ServiceKind {
1537 Chat,
1539 Embeddings,
1541 Realtime,
1543 Images,
1545 Rerank,
1547}
1548
1549impl std::fmt::Display for ServiceKind {
1550 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1551 let s = match self {
1552 ServiceKind::Chat => "chat",
1553 ServiceKind::Embeddings => "embeddings",
1554 ServiceKind::Realtime => "realtime",
1555 ServiceKind::Images => "images",
1556 ServiceKind::Rerank => "rerank",
1557 };
1558 f.write_str(s)
1559 }
1560}
1561
1562#[derive(Clone)]
1569pub struct DriverDescriptor {
1570 pub id: DriverId,
1572 pub display_name: String,
1574 pub services: Vec<ServiceKind>,
1576 pub credential_schema: CredentialFormSchema,
1578 pub chat: Option<DriverFactory>,
1580 pub embeddings: Option<EmbeddingsDriverFactory>,
1582}
1583
1584impl DriverDescriptor {
1585 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1590 where
1591 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1592 {
1593 let id = id.into();
1594 Self {
1595 display_name: default_display_name(&id),
1596 credential_schema: default_credential_schema(&id),
1597 services: vec![ServiceKind::Chat],
1598 chat: Some(Arc::new(factory)),
1599 embeddings: None,
1600 id,
1601 }
1602 }
1603
1604 pub fn supports(&self, service: ServiceKind) -> bool {
1606 self.services.contains(&service)
1607 }
1608}
1609
1610impl std::fmt::Debug for DriverDescriptor {
1611 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1612 f.debug_struct("DriverDescriptor")
1613 .field("id", &self.id)
1614 .field("display_name", &self.display_name)
1615 .field("services", &self.services)
1616 .field("chat", &self.chat.is_some())
1617 .field("embeddings", &self.embeddings.is_some())
1618 .finish()
1619 }
1620}
1621
1622fn default_display_name(id: &DriverId) -> String {
1623 match id {
1624 DriverId::OpenAI => "OpenAI".to_string(),
1625 DriverId::OpenRouter => "OpenRouter".to_string(),
1626 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1627 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1628 DriverId::Anthropic => "Anthropic".to_string(),
1629 DriverId::Gemini => "Google Gemini".to_string(),
1630 DriverId::Bedrock => "AWS Bedrock".to_string(),
1631 DriverId::Mai => "Microsoft MAI".to_string(),
1632 DriverId::LlmSim => "LLM Simulator".to_string(),
1633 DriverId::External(id) => id.to_string(),
1634 }
1635}
1636
1637fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1638 match id {
1639 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1641 _ => CredentialFormSchema::api_key(String::new()),
1642 }
1643}
1644
1645#[derive(Clone, Default)]
1665pub struct DriverRegistry {
1666 descriptors: HashMap<DriverId, DriverDescriptor>,
1667}
1668
1669impl DriverRegistry {
1670 pub fn new() -> Self {
1672 Self {
1673 descriptors: HashMap::new(),
1674 }
1675 }
1676
1677 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1683 if self.descriptors.contains_key(&descriptor.id) {
1684 panic!(
1685 "driver already registered for provider '{}'; \
1686 use register_descriptor_or_replace to overwrite intentionally",
1687 descriptor.id
1688 );
1689 }
1690 self.descriptors.insert(descriptor.id.clone(), descriptor);
1691 }
1692
1693 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1695 self.descriptors.insert(descriptor.id.clone(), descriptor);
1696 }
1697
1698 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1704 where
1705 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1706 {
1707 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1708 }
1709
1710 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1715 where
1716 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1717 {
1718 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1719 }
1720
1721 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1726 where
1727 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1728 {
1729 self.register(DriverId::external(id), factory);
1730 }
1731
1732 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1741 let requires_api_key = !matches!(
1745 config.provider_type,
1746 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
1747 );
1748 if requires_api_key && config.api_key.is_none() {
1749 return Err(AgentLoopError::llm(
1750 "API key is required. Configure the API key in provider settings.",
1751 ));
1752 }
1753
1754 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1756 AgentLoopError::driver_not_registered(config.provider_type.to_string())
1757 })?;
1758 let factory = descriptor.chat.as_ref().ok_or_else(|| {
1759 AgentLoopError::llm(format!(
1760 "Provider driver '{}' does not implement the chat service.",
1761 config.provider_type
1762 ))
1763 })?;
1764
1765 let driver_config = DriverConfig {
1767 provider_type: config.provider_type.clone(),
1768 api_key: config.api_key.clone(),
1769 base_url: config.base_url.clone(),
1770 metadata: config.metadata.clone(),
1771 };
1772 Ok(factory(&driver_config))
1773 }
1774
1775 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1777 self.descriptors.contains_key(provider_type)
1778 }
1779
1780 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1782 self.descriptors.get(provider_type)
1783 }
1784
1785 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1787 self.descriptors
1788 .get(provider_type)
1789 .is_some_and(|d| d.supports(service))
1790 }
1791
1792 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1794 self.descriptors
1795 .values()
1796 .filter(|d| d.supports(service))
1797 .map(|d| d.id.clone())
1798 .collect()
1799 }
1800
1801 pub fn registered_providers(&self) -> Vec<DriverId> {
1803 self.descriptors.keys().cloned().collect()
1804 }
1805
1806 pub fn create_embeddings_driver(
1814 &self,
1815 config: &ProviderConfig,
1816 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
1817 let requires_api_key = !matches!(
1818 config.provider_type,
1819 DriverId::LlmSim | DriverId::External(_)
1820 );
1821 if requires_api_key && config.api_key.is_none() {
1822 return Err(EmbeddingsDriverError::Provider(
1823 "API key is required. Configure the API key in provider settings.".to_string(),
1824 ));
1825 }
1826 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1827 EmbeddingsDriverError::Provider(format!(
1828 "No driver registered for provider '{}'",
1829 config.provider_type
1830 ))
1831 })?;
1832 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
1833 EmbeddingsDriverError::Provider(format!(
1834 "Provider driver '{}' does not implement the embeddings service.",
1835 config.provider_type
1836 ))
1837 })?;
1838 let driver_config = DriverConfig {
1839 provider_type: config.provider_type.clone(),
1840 api_key: config.api_key.clone(),
1841 base_url: config.base_url.clone(),
1842 metadata: config.metadata.clone(),
1843 };
1844 Ok(factory(&driver_config))
1845 }
1846}
1847
1848const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
1853
1854const TRUNCATION_SUFFIX: &str =
1855 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
1856
1857fn truncate_tool_result(text: String) -> String {
1858 if text.len() <= MAX_TOOL_RESULT_BYTES {
1859 return text;
1860 }
1861 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
1862 let mut end = content_budget;
1863 while end > 0 && !text.is_char_boundary(end) {
1864 end -= 1;
1865 }
1866 let mut truncated = text[..end].to_string();
1867 truncated.push_str(TRUNCATION_SUFFIX);
1868 truncated
1869}
1870
1871#[cfg(test)]
1876mod tests {
1877 use super::*;
1878
1879 #[test]
1880 fn test_fold_system_messages_none_when_absent() {
1881 let messages = vec![
1882 LlmMessage::text(LlmMessageRole::User, "hi"),
1883 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
1884 ];
1885 assert_eq!(fold_system_messages(&messages), None);
1886 }
1887
1888 #[test]
1889 fn test_fold_system_messages_single() {
1890 let messages = vec![
1891 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
1892 LlmMessage::text(LlmMessageRole::User, "hi"),
1893 ];
1894 assert_eq!(
1895 fold_system_messages(&messages),
1896 Some("AGENT-PROMPT".to_string())
1897 );
1898 }
1899
1900 #[test]
1901 fn test_fold_system_messages_accumulates_in_order() {
1902 let messages = vec![
1906 LlmMessage::text(LlmMessageRole::System, "A"),
1907 LlmMessage::text(LlmMessageRole::User, "hi"),
1908 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
1909 LlmMessage::text(LlmMessageRole::System, "B"),
1910 ];
1911 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
1912 }
1913
1914 #[test]
1915 fn test_fold_system_messages_concatenates_parts() {
1916 let messages = vec![LlmMessage::parts(
1917 LlmMessageRole::System,
1918 vec![
1919 LlmContentPart::text("foo"),
1920 LlmContentPart::image("data:image/png;base64,xxx"),
1921 LlmContentPart::text("bar"),
1922 ],
1923 )];
1924 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
1925 }
1926
1927 #[test]
1928 fn test_llm_call_config_builder_from_runtime_agent() {
1929 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1930 let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
1931
1932 assert_eq!(llm_config.model, "gpt-4o");
1933 assert!(llm_config.reasoning_effort.is_none());
1934 assert!(llm_config.temperature.is_none());
1935 assert!(llm_config.max_tokens.is_none());
1936 assert!(llm_config.tools.is_empty());
1937 assert!(llm_config.metadata.is_empty());
1938 }
1939
1940 #[test]
1941 fn test_llm_call_config_builder_with_metadata() {
1942 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1943 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1944 .with_metadata("session_id", "session_abc123")
1945 .with_metadata("agent_id", "agent_xyz789")
1946 .build();
1947
1948 assert_eq!(
1949 llm_config.metadata.get("session_id"),
1950 Some(&"session_abc123".to_string())
1951 );
1952 assert_eq!(
1953 llm_config.metadata.get("agent_id"),
1954 Some(&"agent_xyz789".to_string())
1955 );
1956 }
1957
1958 #[test]
1959 fn test_llm_call_config_builder_with_metadata_hashmap() {
1960 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1961 let mut metadata = HashMap::new();
1962 metadata.insert("key1".to_string(), "value1".to_string());
1963 metadata.insert("key2".to_string(), "value2".to_string());
1964
1965 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1966 .metadata(metadata)
1967 .build();
1968
1969 assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
1970 assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
1971 }
1972
1973 #[test]
1974 fn test_llm_call_config_builder_with_reasoning_effort() {
1975 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1976 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1977 .reasoning_effort("high")
1978 .build();
1979
1980 assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
1981 }
1982
1983 #[test]
1984 fn test_llm_call_config_builder_with_all_options() {
1985 let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1986 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1987 .model("claude-3-opus")
1988 .reasoning_effort("medium")
1989 .temperature(0.7)
1990 .max_tokens(1000)
1991 .build();
1992
1993 assert_eq!(llm_config.model, "claude-3-opus");
1994 assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
1995 assert_eq!(llm_config.temperature, Some(0.7));
1996 assert_eq!(llm_config.max_tokens, Some(1000));
1997 }
1998
1999 #[test]
2000 fn test_llm_call_config_builder_with_openrouter_routing() {
2001 let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2002 let routing = OpenRouterRoutingConfig::fallback_models([
2003 "openai/gpt-5-mini",
2004 "anthropic/claude-sonnet-4.5",
2005 ]);
2006
2007 let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2008 .openrouter_routing(routing.clone())
2009 .build();
2010
2011 assert_eq!(llm_config.openrouter_routing, Some(routing));
2012 }
2013
2014 #[test]
2015 fn test_openrouter_fallback_models_empty_is_empty() {
2016 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2017
2018 assert!(routing.is_empty());
2019 assert_eq!(routing.route, None);
2020 }
2021
2022 #[test]
2023 fn test_openrouter_routing_validates_primary_model() {
2024 let routing = OpenRouterRoutingConfig::fallback_models([
2025 "openai/gpt-5-mini",
2026 "anthropic/claude-sonnet-4.5",
2027 ]);
2028
2029 assert!(
2030 routing
2031 .validate_for_primary_model("openai/gpt-5-mini")
2032 .is_ok()
2033 );
2034 let err = routing
2035 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2036 .unwrap_err();
2037 assert!(err.contains("models[0]"));
2038 }
2039
2040 #[test]
2041 fn test_openrouter_routing_rejects_fallback_without_models() {
2042 let routing = OpenRouterRoutingConfig {
2043 route: Some(OpenRouterRoute::Fallback),
2044 ..Default::default()
2045 };
2046
2047 let err = routing
2048 .validate_for_primary_model("openai/gpt-5-mini")
2049 .unwrap_err();
2050 assert!(err.contains("requires at least one model"));
2051 }
2052
2053 #[test]
2054 fn test_openrouter_routing_serializes_request_fields() {
2055 let routing = OpenRouterRoutingConfig {
2056 models: vec![
2057 "openai/gpt-5-mini".to_string(),
2058 "anthropic/claude-sonnet-4.5".to_string(),
2059 ],
2060 route: Some(OpenRouterRoute::Fallback),
2061 provider: Some(OpenRouterProviderRouting {
2062 order: vec!["anthropic".to_string(), "openai".to_string()],
2063 allow_fallbacks: Some(false),
2064 require_parameters: Some(true),
2065 data_collection: Some(OpenRouterDataCollection::Deny),
2066 zdr: Some(true),
2067 sort: Some(OpenRouterProviderSort::Advanced(
2068 OpenRouterProviderSortOptions {
2069 by: OpenRouterProviderSortBy::Throughput,
2070 partition: Some(OpenRouterSortPartition::None),
2071 },
2072 )),
2073 max_price: Some(OpenRouterMaxPrice {
2074 prompt: Some(1.0),
2075 completion: Some(2.0),
2076 ..Default::default()
2077 }),
2078 ..Default::default()
2079 }),
2080 ..Default::default()
2081 };
2082
2083 let json = serde_json::to_value(routing).unwrap();
2084
2085 assert_eq!(
2086 json,
2087 serde_json::json!({
2088 "models": [
2089 "openai/gpt-5-mini",
2090 "anthropic/claude-sonnet-4.5"
2091 ],
2092 "route": "fallback",
2093 "provider": {
2094 "order": ["anthropic", "openai"],
2095 "allow_fallbacks": false,
2096 "require_parameters": true,
2097 "data_collection": "deny",
2098 "zdr": true,
2099 "sort": {
2100 "by": "throughput",
2101 "partition": "none"
2102 },
2103 "max_price": {
2104 "prompt": 1.0,
2105 "completion": 2.0
2106 }
2107 }
2108 })
2109 );
2110 }
2111
2112 #[test]
2113 fn test_provider_type_parsing() {
2114 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2115 assert_eq!(
2116 "openrouter".parse::<DriverId>().unwrap(),
2117 DriverId::OpenRouter
2118 );
2119 assert_eq!(
2120 "openai_completions".parse::<DriverId>().unwrap(),
2121 DriverId::OpenAICompletions
2122 );
2123 assert_eq!(
2124 "azure_openai".parse::<DriverId>().unwrap(),
2125 DriverId::AzureOpenAI
2126 );
2127 assert_eq!(
2128 "anthropic".parse::<DriverId>().unwrap(),
2129 DriverId::Anthropic
2130 );
2131 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2132 assert_eq!(
2134 "ollama".parse::<DriverId>().unwrap(),
2135 DriverId::external("ollama")
2136 );
2137 assert_eq!(
2138 "custom".parse::<DriverId>().unwrap(),
2139 DriverId::external("custom")
2140 );
2141 }
2142
2143 #[test]
2144 fn test_external_provider_id_is_case_insensitive() {
2145 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2148 assert_eq!(
2149 "Ollama".parse::<DriverId>().unwrap(),
2150 "ollama".parse::<DriverId>().unwrap()
2151 );
2152 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2153 assert_eq!(
2155 DriverId::external("MyProvider"),
2156 "myprovider".parse::<DriverId>().unwrap()
2157 );
2158 }
2159
2160 #[test]
2161 fn test_provider_type_display() {
2162 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2163 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2164 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2165 assert_eq!(
2166 DriverId::OpenAICompletions.to_string(),
2167 "openai_completions"
2168 );
2169 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2170 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2171 }
2172
2173 #[test]
2174 fn test_provider_config_builder() {
2175 let config = ProviderConfig::new(DriverId::Anthropic)
2176 .with_api_key("test-key")
2177 .with_base_url("https://custom.api.com");
2178
2179 assert_eq!(config.provider_type, DriverId::Anthropic);
2180 assert_eq!(config.api_key, Some("test-key".to_string()));
2181 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2182 }
2183
2184 #[test]
2185 fn test_driver_registry_requires_api_key() {
2186 let mut registry = DriverRegistry::new();
2188 registry.register(DriverId::OpenAI, |_config| {
2189 struct MockDriver;
2191 #[async_trait]
2192 impl ChatDriver for MockDriver {
2193 async fn chat_completion_stream(
2194 &self,
2195 _messages: Vec<LlmMessage>,
2196 _config: &LlmCallConfig,
2197 ) -> Result<LlmResponseStream> {
2198 unimplemented!()
2199 }
2200 }
2201 Box::new(MockDriver)
2202 });
2203
2204 let config = ProviderConfig::new(DriverId::OpenAI);
2206 let result = registry.create_chat_driver(&config);
2207 assert!(result.is_err());
2208
2209 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2211 let result = registry.create_chat_driver(&config_with_key);
2212 assert!(result.is_ok());
2213 }
2214
2215 #[test]
2216 fn test_driver_registry_returns_error_for_unregistered_provider() {
2217 let registry = DriverRegistry::new();
2218 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2219
2220 let result = registry.create_chat_driver(&config);
2221
2222 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2224 assert_eq!(provider, "anthropic");
2225 } else {
2226 panic!("Expected DriverNotRegistered error");
2227 }
2228 }
2229
2230 #[test]
2231 fn test_driver_registry_registration() {
2232 let mut registry = DriverRegistry::new();
2233
2234 assert!(!registry.has_driver(&DriverId::OpenAI));
2235 assert!(!registry.has_driver(&DriverId::Anthropic));
2236
2237 registry.register(DriverId::OpenAI, |_config| {
2238 struct MockDriver;
2239 #[async_trait]
2240 impl ChatDriver for MockDriver {
2241 async fn chat_completion_stream(
2242 &self,
2243 _messages: Vec<LlmMessage>,
2244 _config: &LlmCallConfig,
2245 ) -> Result<LlmResponseStream> {
2246 unimplemented!()
2247 }
2248 }
2249 Box::new(MockDriver)
2250 });
2251
2252 assert!(registry.has_driver(&DriverId::OpenAI));
2253 assert!(!registry.has_driver(&DriverId::Anthropic));
2254 }
2255
2256 #[test]
2257 fn test_register_external_and_create_driver_without_api_key() {
2258 struct MockDriver;
2259 #[async_trait]
2260 impl ChatDriver for MockDriver {
2261 async fn chat_completion_stream(
2262 &self,
2263 _messages: Vec<LlmMessage>,
2264 _config: &LlmCallConfig,
2265 ) -> Result<LlmResponseStream> {
2266 unimplemented!()
2267 }
2268 }
2269
2270 let mut registry = DriverRegistry::new();
2271 registry.register_external("openai-codex", |config| {
2272 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2274 Box::new(MockDriver)
2275 });
2276
2277 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2278
2279 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2281 ProviderMetadata {
2282 refresh_token: Some("rt".into()),
2283 ..Default::default()
2284 },
2285 );
2286 assert!(registry.create_chat_driver(&config).is_ok());
2287 }
2288
2289 #[test]
2290 fn test_register_defaults_to_chat_only_descriptor() {
2291 struct MockDriver;
2292 #[async_trait]
2293 impl ChatDriver for MockDriver {
2294 async fn chat_completion_stream(
2295 &self,
2296 _messages: Vec<LlmMessage>,
2297 _config: &LlmCallConfig,
2298 ) -> Result<LlmResponseStream> {
2299 unimplemented!()
2300 }
2301 }
2302
2303 let mut registry = DriverRegistry::new();
2304 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2305
2306 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2307 assert_eq!(descriptor.display_name, "Anthropic");
2308 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2309 assert!(descriptor.chat.is_some());
2310 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2312 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2313 assert!(descriptor.credential_schema.fields[0].required);
2314
2315 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2317 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2318 assert!(sim.credential_schema.fields.is_empty());
2319 }
2320
2321 #[test]
2322 fn test_descriptor_services_and_lookup() {
2323 struct MockDriver;
2324 #[async_trait]
2325 impl ChatDriver for MockDriver {
2326 async fn chat_completion_stream(
2327 &self,
2328 _messages: Vec<LlmMessage>,
2329 _config: &LlmCallConfig,
2330 ) -> Result<LlmResponseStream> {
2331 unimplemented!()
2332 }
2333 }
2334
2335 let mut registry = DriverRegistry::new();
2336 registry.register_descriptor(DriverDescriptor {
2337 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2338 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2339 });
2340 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2341
2342 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2343 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2344 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2345 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2346
2347 let realtime = registry.providers_for(ServiceKind::Realtime);
2348 assert_eq!(realtime, vec![DriverId::OpenAI]);
2349 let mut chat = registry.providers_for(ServiceKind::Chat);
2350 chat.sort_by_key(|p| p.to_string());
2351 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2352 }
2353
2354 #[test]
2355 fn test_create_chat_driver_fails_without_chat_factory() {
2356 let mut registry = DriverRegistry::new();
2357 registry.register_descriptor(DriverDescriptor {
2358 id: DriverId::external("embeddings-only"),
2359 display_name: "Embeddings Only".to_string(),
2360 services: vec![ServiceKind::Embeddings],
2361 credential_schema: CredentialFormSchema::empty(),
2362 chat: None,
2363 embeddings: None,
2364 });
2365
2366 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2367 let err = match registry.create_chat_driver(&config) {
2368 Ok(_) => panic!("expected error for missing chat factory"),
2369 Err(err) => err,
2370 };
2371 assert!(
2372 err.to_string()
2373 .contains("does not implement the chat service"),
2374 "unexpected error: {err}"
2375 );
2376 }
2377
2378 #[test]
2379 #[should_panic(expected = "already registered")]
2380 fn test_register_duplicate_panics() {
2381 struct MockDriver;
2382 #[async_trait]
2383 impl ChatDriver for MockDriver {
2384 async fn chat_completion_stream(
2385 &self,
2386 _messages: Vec<LlmMessage>,
2387 _config: &LlmCallConfig,
2388 ) -> Result<LlmResponseStream> {
2389 unimplemented!()
2390 }
2391 }
2392
2393 let mut registry = DriverRegistry::new();
2394 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2395 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2397 }
2398
2399 #[test]
2400 fn test_register_or_replace_overwrites() {
2401 struct MockDriver;
2402 #[async_trait]
2403 impl ChatDriver for MockDriver {
2404 async fn chat_completion_stream(
2405 &self,
2406 _messages: Vec<LlmMessage>,
2407 _config: &LlmCallConfig,
2408 ) -> Result<LlmResponseStream> {
2409 unimplemented!()
2410 }
2411 }
2412
2413 let mut registry = DriverRegistry::new();
2414 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2415 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2417 assert!(registry.has_driver(&DriverId::LlmSim));
2418 }
2419
2420 use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2425
2426 #[test]
2427 fn test_message_has_image_files_with_image_file() {
2428 let message = Message {
2429 id: uuid::Uuid::new_v4().into(),
2430 role: MessageRole::User,
2431 content: vec![
2432 ContentPart::Text(TextContentPart {
2433 text: "Look at this image".to_string(),
2434 }),
2435 ContentPart::ImageFile(ImageFileContentPart {
2436 image_id: uuid::Uuid::new_v4().into(),
2437 filename: Some("test.png".to_string()),
2438 }),
2439 ],
2440 phase: None,
2441 thinking: None,
2442 thinking_signature: None,
2443 controls: None,
2444 metadata: None,
2445 external_actor: None,
2446 created_at: chrono::Utc::now(),
2447 };
2448
2449 assert!(LlmMessage::message_has_image_files(&message));
2450 }
2451
2452 #[test]
2453 fn test_message_has_image_files_without_image_file() {
2454 let message = Message {
2455 id: uuid::Uuid::new_v4().into(),
2456 role: MessageRole::User,
2457 content: vec![ContentPart::Text(TextContentPart {
2458 text: "Just text".to_string(),
2459 })],
2460 phase: None,
2461 thinking: None,
2462 thinking_signature: None,
2463 controls: None,
2464 metadata: None,
2465 external_actor: None,
2466 created_at: chrono::Utc::now(),
2467 };
2468
2469 assert!(!LlmMessage::message_has_image_files(&message));
2470 }
2471
2472 #[test]
2473 fn test_extract_image_file_ids() {
2474 let id1 = uuid::Uuid::new_v4();
2475 let id2 = uuid::Uuid::new_v4();
2476
2477 let message = Message {
2478 id: uuid::Uuid::new_v4().into(),
2479 role: MessageRole::User,
2480 content: vec![
2481 ContentPart::Text(TextContentPart {
2482 text: "Look at these images".to_string(),
2483 }),
2484 ContentPart::ImageFile(ImageFileContentPart {
2485 image_id: id1.into(),
2486 filename: Some("test1.png".to_string()),
2487 }),
2488 ContentPart::ImageFile(ImageFileContentPart {
2489 image_id: id2.into(),
2490 filename: Some("test2.png".to_string()),
2491 }),
2492 ],
2493 phase: None,
2494 thinking: None,
2495 thinking_signature: None,
2496 controls: None,
2497 metadata: None,
2498 external_actor: None,
2499 created_at: chrono::Utc::now(),
2500 };
2501
2502 let ids = LlmMessage::extract_image_file_ids(&message);
2503 assert_eq!(ids.len(), 2);
2504 assert!(ids.contains(&id1));
2505 assert!(ids.contains(&id2));
2506 }
2507
2508 #[test]
2509 fn test_from_message_with_images_text_only() {
2510 let message = Message {
2511 id: uuid::Uuid::new_v4().into(),
2512 role: MessageRole::User,
2513 content: vec![ContentPart::Text(TextContentPart {
2514 text: "Hello".to_string(),
2515 })],
2516 phase: None,
2517 thinking: None,
2518 thinking_signature: None,
2519 controls: None,
2520 metadata: None,
2521 external_actor: None,
2522 created_at: chrono::Utc::now(),
2523 };
2524
2525 let resolved = std::collections::HashMap::new();
2526 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2527
2528 assert_eq!(llm_message.role, LlmMessageRole::User);
2529 match llm_message.content {
2530 LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2531 _ => panic!("Expected text content"),
2532 }
2533 }
2534
2535 #[test]
2536 fn test_from_message_with_images_resolved_image() {
2537 let image_id = uuid::Uuid::new_v4();
2538 let message = Message {
2539 id: uuid::Uuid::new_v4().into(),
2540 role: MessageRole::User,
2541 content: vec![
2542 ContentPart::Text(TextContentPart {
2543 text: "Look at this".to_string(),
2544 }),
2545 ContentPart::ImageFile(ImageFileContentPart {
2546 image_id: image_id.into(),
2547 filename: Some("test.png".to_string()),
2548 }),
2549 ],
2550 phase: None,
2551 thinking: None,
2552 thinking_signature: None,
2553 controls: None,
2554 metadata: None,
2555 external_actor: None,
2556 created_at: chrono::Utc::now(),
2557 };
2558
2559 let mut resolved = std::collections::HashMap::new();
2560 resolved.insert(
2561 image_id,
2562 crate::ResolvedImage::new("base64data", "image/png"),
2563 );
2564
2565 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2566
2567 match &llm_message.content {
2568 LlmMessageContent::Parts(parts) => {
2569 assert_eq!(parts.len(), 2);
2570 assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2572 if let LlmContentPart::Image { url } = &parts[1] {
2574 assert!(url.starts_with("data:image/png;base64,"));
2575 } else {
2576 panic!("Expected image content part");
2577 }
2578 }
2579 _ => panic!("Expected parts content"),
2580 }
2581 }
2582
2583 #[test]
2584 fn test_from_message_with_images_unresolved_image() {
2585 let image_id = uuid::Uuid::new_v4();
2586 let message = Message {
2587 id: uuid::Uuid::new_v4().into(),
2588 role: MessageRole::User,
2589 content: vec![ContentPart::ImageFile(ImageFileContentPart {
2590 image_id: image_id.into(),
2591 filename: Some("missing.png".to_string()),
2592 })],
2593 phase: None,
2594 thinking: None,
2595 thinking_signature: None,
2596 controls: None,
2597 metadata: None,
2598 external_actor: None,
2599 created_at: chrono::Utc::now(),
2600 };
2601
2602 let resolved = std::collections::HashMap::new();
2604 let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2605
2606 match &llm_message.content {
2609 LlmMessageContent::Text(text) => {
2610 assert!(text.contains("Image not found"));
2611 }
2612 LlmMessageContent::Parts(parts) => {
2613 assert_eq!(parts.len(), 1);
2614 if let LlmContentPart::Text { text } = &parts[0] {
2615 assert!(text.contains("Image not found"));
2616 } else {
2617 panic!("Expected text placeholder for missing image");
2618 }
2619 }
2620 }
2621 }
2622
2623 #[test]
2624 fn test_prepend_text_prefix_simple_text() {
2625 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2626 msg.prepend_text_prefix("[Alice] ");
2627 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2628 }
2629
2630 #[test]
2631 fn test_prepend_text_prefix_parts() {
2632 let mut msg = LlmMessage::parts(
2633 LlmMessageRole::User,
2634 vec![
2635 LlmContentPart::Text {
2636 text: "Hello".to_string(),
2637 },
2638 LlmContentPart::Image {
2639 url: "data:image/png;base64,abc".to_string(),
2640 },
2641 ],
2642 );
2643 msg.prepend_text_prefix("[Bob] ");
2644 match &msg.content {
2645 LlmMessageContent::Parts(parts) => {
2646 if let LlmContentPart::Text { text } = &parts[0] {
2647 assert_eq!(text, "[Bob] Hello");
2648 } else {
2649 panic!("Expected text part");
2650 }
2651 }
2652 _ => panic!("Expected parts content"),
2653 }
2654 }
2655
2656 #[test]
2657 fn test_prepend_text_prefix_parts_no_text() {
2658 let mut msg = LlmMessage::parts(
2659 LlmMessageRole::User,
2660 vec![LlmContentPart::Image {
2661 url: "data:image/png;base64,abc".to_string(),
2662 }],
2663 );
2664 msg.prepend_text_prefix("[Eve] ");
2665 match &msg.content {
2666 LlmMessageContent::Parts(parts) => {
2667 assert_eq!(parts.len(), 2);
2668 if let LlmContentPart::Text { text } = &parts[0] {
2669 assert_eq!(text, "[Eve] ");
2670 } else {
2671 panic!("Expected prepended text part");
2672 }
2673 }
2674 _ => panic!("Expected parts content"),
2675 }
2676 }
2677
2678 #[test]
2679 fn test_openrouter_plugin_config_is_empty() {
2680 assert!(OpenRouterPluginConfig::default().is_empty());
2681 assert!(
2682 !OpenRouterPluginConfig {
2683 web: Some(OpenRouterWebSearchPlugin::default()),
2684 file: None,
2685 }
2686 .is_empty()
2687 );
2688 assert!(
2689 !OpenRouterPluginConfig {
2690 web: None,
2691 file: Some(OpenRouterFilePlugin {}),
2692 }
2693 .is_empty()
2694 );
2695 }
2696
2697 #[test]
2698 fn test_openrouter_routing_is_empty_with_plugins() {
2699 let with_plugins = OpenRouterRoutingConfig {
2700 plugins: Some(OpenRouterPluginConfig {
2701 web: Some(OpenRouterWebSearchPlugin::default()),
2702 file: None,
2703 }),
2704 ..Default::default()
2705 };
2706 assert!(!with_plugins.is_empty());
2707
2708 let empty_plugins = OpenRouterRoutingConfig {
2709 plugins: Some(OpenRouterPluginConfig::default()),
2710 ..Default::default()
2711 };
2712 assert!(empty_plugins.is_empty());
2713 }
2714
2715 #[test]
2716 fn test_openrouter_web_search_plugin_serialization() {
2717 let plugin = OpenRouterWebSearchPlugin {
2718 max_results: Some(10),
2719 search_prompt: Some("search for Rust crates".to_string()),
2720 };
2721 let json = serde_json::to_value(&plugin).unwrap();
2722 assert_eq!(json["max_results"], 10);
2723 assert_eq!(json["search_prompt"], "search for Rust crates");
2724 }
2725
2726 #[test]
2727 fn test_openrouter_web_search_plugin_omits_none_fields() {
2728 let plugin = OpenRouterWebSearchPlugin::default();
2729 let json = serde_json::to_value(&plugin).unwrap();
2730 assert!(json.get("max_results").is_none());
2731 assert!(json.get("search_prompt").is_none());
2732 }
2733
2734 #[test]
2735 fn test_capacity_strategy_shared_capacity_is_noop() {
2736 let base = OpenRouterRoutingConfig {
2737 models: vec!["openai/gpt-5-mini".to_string()],
2738 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2739 ..Default::default()
2740 };
2741 let result = base.apply_capacity_strategy().unwrap();
2742 assert_eq!(
2743 result.capacity_strategy,
2744 Some(OpenRouterCapacityStrategy::SharedCapacity)
2745 );
2746 assert!(result.provider.is_none());
2747 }
2748
2749 #[test]
2750 fn test_capacity_strategy_none_is_noop() {
2751 let base = OpenRouterRoutingConfig {
2752 models: vec!["openai/gpt-5-mini".to_string()],
2753 capacity_strategy: None,
2754 ..Default::default()
2755 };
2756 let result = base.apply_capacity_strategy().unwrap();
2757 assert!(result.provider.is_none());
2758 }
2759
2760 #[test]
2761 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2762 let base = OpenRouterRoutingConfig {
2763 models: vec!["openai/gpt-5-mini".to_string()],
2764 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2765 ..Default::default()
2766 };
2767 let result = base.apply_capacity_strategy().unwrap();
2768 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2769 assert_eq!(provider.allow_fallbacks, Some(true));
2770 }
2771
2772 #[test]
2773 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2774 let base = OpenRouterRoutingConfig {
2776 models: vec!["openai/gpt-5-mini".to_string()],
2777 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2778 provider: Some(OpenRouterProviderRouting {
2779 allow_fallbacks: Some(false),
2780 ..Default::default()
2781 }),
2782 ..Default::default()
2783 };
2784 let result = base.apply_capacity_strategy().unwrap();
2785 let provider = result.provider.as_ref().unwrap();
2786 assert_eq!(provider.allow_fallbacks, Some(false));
2787 }
2788
2789 #[test]
2790 fn test_capacity_strategy_byok_only_requires_provider_only() {
2791 let base = OpenRouterRoutingConfig {
2792 models: vec!["openai/gpt-5-mini".to_string()],
2793 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2794 ..Default::default()
2795 };
2796 let err = base.apply_capacity_strategy().unwrap_err();
2797 assert!(
2798 err.contains("provider.only"),
2799 "error should mention provider.only: {err}"
2800 );
2801 }
2802
2803 #[test]
2804 fn test_capacity_strategy_byok_only_disables_fallbacks() {
2805 let base = OpenRouterRoutingConfig {
2806 models: vec!["openai/gpt-5-mini".to_string()],
2807 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2808 provider: Some(OpenRouterProviderRouting {
2809 only: vec!["my-byok-provider".to_string()],
2810 ..Default::default()
2811 }),
2812 ..Default::default()
2813 };
2814 let result = base.apply_capacity_strategy().unwrap();
2815 let provider = result.provider.as_ref().unwrap();
2816 assert_eq!(provider.allow_fallbacks, Some(false));
2817 assert_eq!(provider.only, vec!["my-byok-provider"]);
2818 }
2819
2820 #[test]
2821 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
2822 let with_strategy = OpenRouterRoutingConfig {
2823 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2824 ..Default::default()
2825 };
2826 assert!(!with_strategy.is_empty());
2827
2828 let byok_first = OpenRouterRoutingConfig {
2829 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2830 ..Default::default()
2831 };
2832 assert!(!byok_first.is_empty());
2833
2834 let shared = OpenRouterRoutingConfig {
2835 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2836 ..Default::default()
2837 };
2838 assert!(shared.is_empty());
2839 }
2840
2841 #[test]
2846 fn test_preset_no_presets_is_noop() {
2847 let base = OpenRouterRoutingConfig {
2848 models: vec!["openai/gpt-5-mini".to_string()],
2849 ..Default::default()
2850 };
2851 let result = base.apply_presets().unwrap();
2852 assert_eq!(result, base);
2853 }
2854
2855 #[test]
2856 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
2857 let base = OpenRouterRoutingConfig {
2858 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2859 ..Default::default()
2860 };
2861 let result = base.apply_presets().unwrap();
2862 assert!(result.presets.is_empty(), "presets cleared after apply");
2863 let provider = result.provider.expect("provider set by preset");
2864 assert_eq!(provider.require_parameters, Some(true));
2865 assert_eq!(
2866 provider.sort,
2867 Some(OpenRouterProviderSort::Simple(
2868 OpenRouterProviderSortBy::Price
2869 ))
2870 );
2871 }
2872
2873 #[test]
2874 fn test_preset_lowest_latency_review_sets_sort_throughput() {
2875 let base = OpenRouterRoutingConfig {
2876 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
2877 ..Default::default()
2878 };
2879 let result = base.apply_presets().unwrap();
2880 let provider = result.provider.expect("provider set by preset");
2881 assert_eq!(
2882 provider.sort,
2883 Some(OpenRouterProviderSort::Simple(
2884 OpenRouterProviderSortBy::Throughput
2885 ))
2886 );
2887 }
2888
2889 #[test]
2890 fn test_preset_zdr_only_sets_zdr() {
2891 let base = OpenRouterRoutingConfig {
2892 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2893 ..Default::default()
2894 };
2895 let result = base.apply_presets().unwrap();
2896 let provider = result.provider.expect("provider set");
2897 assert_eq!(provider.zdr, Some(true));
2898 }
2899
2900 #[test]
2901 fn test_preset_byok_first_sets_allow_fallbacks() {
2902 let base = OpenRouterRoutingConfig {
2903 presets: vec![OpenRouterRoutingPreset::ByokFirst],
2904 ..Default::default()
2905 };
2906 let result = base.apply_presets().unwrap();
2907 let provider = result.provider.expect("provider set");
2908 assert_eq!(provider.allow_fallbacks, Some(true));
2909 }
2910
2911 #[test]
2912 fn test_preset_no_data_collection_sets_data_collection_deny() {
2913 let base = OpenRouterRoutingConfig {
2914 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
2915 ..Default::default()
2916 };
2917 let result = base.apply_presets().unwrap();
2918 let provider = result.provider.expect("provider set");
2919 assert_eq!(
2920 provider.data_collection,
2921 Some(OpenRouterDataCollection::Deny)
2922 );
2923 }
2924
2925 #[test]
2926 fn test_preset_strict_json_sets_require_parameters() {
2927 let base = OpenRouterRoutingConfig {
2928 presets: vec![OpenRouterRoutingPreset::StrictJson],
2929 ..Default::default()
2930 };
2931 let result = base.apply_presets().unwrap();
2932 let provider = result.provider.expect("provider set");
2933 assert_eq!(provider.require_parameters, Some(true));
2934 }
2935
2936 #[test]
2937 fn test_preset_reasoning_required_sets_require_parameters() {
2938 let base = OpenRouterRoutingConfig {
2939 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
2940 ..Default::default()
2941 };
2942 let result = base.apply_presets().unwrap();
2943 let provider = result.provider.expect("provider set");
2944 assert_eq!(provider.require_parameters, Some(true));
2945 }
2946
2947 #[test]
2948 fn test_preset_max_price_converts_usd_per_million() {
2949 let base = OpenRouterRoutingConfig {
2950 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2951 prompt_usd_per_million: Some(5.0),
2952 completion_usd_per_million: Some(15.0),
2953 }],
2954 ..Default::default()
2955 };
2956 let result = base.apply_presets().unwrap();
2957 let provider = result.provider.expect("provider set");
2958 let max_price = provider.max_price.expect("max_price set");
2959 let prompt = max_price.prompt.expect("prompt set");
2961 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
2962 let completion = max_price.completion.expect("completion set");
2963 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
2964 }
2965
2966 #[test]
2967 fn test_preset_max_price_rejects_negative_values() {
2968 let base = OpenRouterRoutingConfig {
2969 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2970 prompt_usd_per_million: Some(-1.0),
2971 completion_usd_per_million: None,
2972 }],
2973 ..Default::default()
2974 };
2975 let err = base.apply_presets().unwrap_err();
2976 assert!(
2977 err.contains("non-negative"),
2978 "error should mention non-negative: {err}"
2979 );
2980 }
2981
2982 #[test]
2983 fn test_preset_max_price_both_none_no_provider_field() {
2984 let base = OpenRouterRoutingConfig {
2985 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2986 prompt_usd_per_million: None,
2987 completion_usd_per_million: None,
2988 }],
2989 ..Default::default()
2990 };
2991 let result = base.apply_presets().unwrap();
2992 assert!(
2993 result.provider.is_none(),
2994 "MaxPrice with no dimensions should not produce a provider field"
2995 );
2996 }
2997
2998 #[test]
2999 fn test_preset_explicit_provider_overrides_preset() {
3000 let base = OpenRouterRoutingConfig {
3001 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3002 provider: Some(OpenRouterProviderRouting {
3003 sort: Some(OpenRouterProviderSort::Simple(
3005 OpenRouterProviderSortBy::Throughput,
3006 )),
3007 ..Default::default()
3008 }),
3009 ..Default::default()
3010 };
3011 let result = base.apply_presets().unwrap();
3012 let provider = result.provider.expect("provider set");
3013 assert_eq!(
3015 provider.sort,
3016 Some(OpenRouterProviderSort::Simple(
3017 OpenRouterProviderSortBy::Throughput
3018 ))
3019 );
3020 assert_eq!(provider.require_parameters, Some(true));
3022 }
3023
3024 #[test]
3025 fn test_preset_multiple_presets_combined() {
3026 let base = OpenRouterRoutingConfig {
3027 presets: vec![
3028 OpenRouterRoutingPreset::ZdrOnly,
3029 OpenRouterRoutingPreset::NoDataCollection,
3030 OpenRouterRoutingPreset::LowestLatencyReview,
3031 ],
3032 ..Default::default()
3033 };
3034 let result = base.apply_presets().unwrap();
3035 let provider = result.provider.expect("provider set");
3036 assert_eq!(provider.zdr, Some(true));
3037 assert_eq!(
3038 provider.data_collection,
3039 Some(OpenRouterDataCollection::Deny)
3040 );
3041 assert_eq!(
3042 provider.sort,
3043 Some(OpenRouterProviderSort::Simple(
3044 OpenRouterProviderSortBy::Throughput
3045 ))
3046 );
3047 }
3048
3049 #[test]
3050 fn test_preset_later_preset_overrides_sort() {
3051 let base = OpenRouterRoutingConfig {
3052 presets: vec![
3053 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3056 ..Default::default()
3057 };
3058 let result = base.apply_presets().unwrap();
3059 let provider = result.provider.expect("provider set");
3060 assert_eq!(
3062 provider.sort,
3063 Some(OpenRouterProviderSort::Simple(
3064 OpenRouterProviderSortBy::Throughput
3065 ))
3066 );
3067 assert_eq!(provider.require_parameters, Some(true));
3069 }
3070
3071 #[test]
3072 fn test_preset_non_empty_in_is_empty() {
3073 let with_preset = OpenRouterRoutingConfig {
3074 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3075 ..Default::default()
3076 };
3077 assert!(!with_preset.is_empty());
3078
3079 let without = OpenRouterRoutingConfig::default();
3080 assert!(without.is_empty());
3081 }
3082}