use crate::credential_schema::CredentialFormSchema;
use crate::error::{AgentLoopError, Result};
use crate::openresponses_protocol::{CompactRequest, CompactResponse};
use crate::runtime_agent::RuntimeAgent;
use crate::tool_types::{ToolCall, ToolDefinition};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::Stream;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
#[derive(Debug, Clone)]
pub enum LlmStreamEvent {
TextDelta(String),
ThinkingDelta(String),
ThinkingSignature(String),
ReasonItem {
provider: String,
model: Option<String>,
item_id: String,
encrypted_content: Option<String>,
summary: Vec<String>,
token_count: Option<u32>,
},
ToolCalls(Vec<ToolCall>),
Done(Box<LlmCompletionMetadata>),
Error(String),
}
#[derive(Debug, Clone)]
pub struct DiscoveredModel {
pub model_id: String,
pub display_name: Option<String>,
pub created_at: Option<DateTime<Utc>>,
pub owned_by: Option<String>,
pub discovered_profile: Option<crate::model::ModelProfile>,
}
#[derive(Debug, Clone, Default)]
pub struct LlmCompletionMetadata {
pub total_tokens: Option<u32>,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub cache_read_tokens: Option<u32>,
pub cache_creation_tokens: Option<u32>,
pub provider_cost_usd: Option<f64>,
pub model: Option<String>,
pub finish_reason: Option<String>,
pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
pub response_id: Option<String>,
pub phase: Option<String>,
}
#[async_trait]
pub trait ChatDriver: Send + Sync {
async fn chat_completion_stream(
&self,
messages: Vec<LlmMessage>,
config: &LlmCallConfig,
) -> Result<LlmResponseStream>;
async fn chat_completion(
&self,
messages: Vec<LlmMessage>,
config: &LlmCallConfig,
) -> Result<LlmResponse> {
use futures::StreamExt;
let mut stream = self.chat_completion_stream(messages, config).await?;
let mut text = String::new();
let mut thinking = String::new();
let mut thinking_signature: Option<String> = None;
let mut tool_calls = Vec::new();
let mut metadata = LlmCompletionMetadata::default();
while let Some(event) = stream.next().await {
match event? {
LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
LlmStreamEvent::ReasonItem {
encrypted_content, ..
} => {
if let Some(sig) = encrypted_content {
thinking_signature = Some(sig);
}
}
LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
LlmStreamEvent::Done(meta) => metadata = *meta,
LlmStreamEvent::Error(err) => return Err(crate::error::AgentLoopError::llm(err)),
}
}
Ok(LlmResponse {
text,
thinking: if thinking.is_empty() {
None
} else {
Some(thinking)
},
thinking_signature,
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
metadata,
})
}
async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
Ok(None)
}
fn supports_compact(&self) -> bool {
false
}
async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
Ok(None)
}
}
#[async_trait]
impl ChatDriver for Box<dyn ChatDriver> {
async fn chat_completion_stream(
&self,
messages: Vec<LlmMessage>,
config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
(**self).chat_completion_stream(messages, config).await
}
async fn chat_completion(
&self,
messages: Vec<LlmMessage>,
config: &LlmCallConfig,
) -> Result<LlmResponse> {
(**self).chat_completion(messages, config).await
}
async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
(**self).list_models().await
}
fn supports_compact(&self) -> bool {
(**self).supports_compact()
}
async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
(**self).compact(request).await
}
}
#[derive(Debug, Clone)]
pub struct LlmMessage {
pub role: LlmMessageRole,
pub content: LlmMessageContent,
pub tool_calls: Option<Vec<ToolCall>>,
pub tool_call_id: Option<String>,
pub phase: Option<crate::message::ExecutionPhase>,
pub thinking: Option<String>,
pub thinking_signature: Option<String>,
}
impl LlmMessage {
pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
Self {
role,
content: LlmMessageContent::Text(content.into()),
tool_calls: None,
tool_call_id: None,
phase: None,
thinking: None,
thinking_signature: None,
}
}
pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
Self {
role,
content: LlmMessageContent::Parts(parts),
tool_calls: None,
tool_call_id: None,
phase: None,
thinking: None,
thinking_signature: None,
}
}
pub fn content_as_text(&self) -> String {
self.content.to_text()
}
pub fn prepend_text_prefix(&mut self, prefix: &str) {
match &mut self.content {
LlmMessageContent::Text(text) => {
*text = format!("{}{}", prefix, text);
}
LlmMessageContent::Parts(parts) => {
for part in parts.iter_mut() {
if let LlmContentPart::Text { text } = part {
*text = format!("{}{}", prefix, text);
return;
}
}
parts.insert(
0,
LlmContentPart::Text {
text: prefix.to_string(),
},
);
}
}
}
}
pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
let mut system: Option<String> = None;
for msg in messages {
if msg.role == LlmMessageRole::System {
let text = msg.content.to_text();
system = Some(match system.take() {
Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
_ => text,
});
}
}
system
}
#[derive(Debug, Clone)]
pub enum LlmMessageContent {
Text(String),
Parts(Vec<LlmContentPart>),
}
impl LlmMessageContent {
pub fn to_text(&self) -> String {
match self {
LlmMessageContent::Text(s) => s.clone(),
LlmMessageContent::Parts(parts) => parts
.iter()
.filter_map(|p| match p {
LlmContentPart::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join(""),
}
}
pub fn is_text(&self) -> bool {
matches!(self, LlmMessageContent::Text(_))
}
pub fn is_parts(&self) -> bool {
matches!(self, LlmMessageContent::Parts(_))
}
}
impl From<String> for LlmMessageContent {
fn from(s: String) -> Self {
LlmMessageContent::Text(s)
}
}
impl From<&str> for LlmMessageContent {
fn from(s: &str) -> Self {
LlmMessageContent::Text(s.to_string())
}
}
#[derive(Debug, Clone)]
pub enum LlmContentPart {
Text { text: String },
Image { url: String },
Audio { url: String },
}
impl LlmContentPart {
pub fn text(text: impl Into<String>) -> Self {
LlmContentPart::Text { text: text.into() }
}
pub fn image(url: impl Into<String>) -> Self {
LlmContentPart::Image { url: url.into() }
}
pub fn audio(url: impl Into<String>) -> Self {
LlmContentPart::Audio { url: url.into() }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LlmMessageRole {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ToolSearchConfig {
pub enabled: bool,
pub threshold: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum PromptCacheStrategy {
#[default]
Auto,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PromptCacheConfig {
pub enabled: bool,
#[serde(default)]
pub strategy: PromptCacheStrategy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_cached_content: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OpenRouterRoutingPreset {
CheapestWithTools,
LowestLatencyReview,
ZdrOnly,
ByokFirst,
NoDataCollection,
StrictJson,
ReasoningRequired,
MaxPrice {
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_usd_per_million: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
completion_usd_per_million: Option<f64>,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenRouterCapacityStrategy {
#[default]
SharedCapacity,
ByokFirst,
ByokOnly,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OpenRouterRoutingConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<OpenRouterRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<OpenRouterProviderRouting>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<OpenRouterPluginConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub presets: Vec<OpenRouterRoutingPreset>,
}
impl OpenRouterRoutingConfig {
pub fn is_empty(&self) -> bool {
self.models.is_empty()
&& self.route.is_none()
&& self.provider.is_none()
&& self.plugins.as_ref().is_none_or(|p| p.is_empty())
&& matches!(
self.capacity_strategy,
None | Some(OpenRouterCapacityStrategy::SharedCapacity)
)
&& self.presets.is_empty()
}
pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
Self {
models,
route,
provider: None,
plugins: None,
capacity_strategy: None,
presets: vec![],
}
}
pub fn validate_for_primary_model(
&self,
primary_model: &str,
) -> std::result::Result<(), String> {
if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
return Err(
"OpenRouter fallback routing requires at least one model in `models`".to_string(),
);
}
if let Some(first_model) = self.models.first()
&& first_model != primary_model
{
return Err(format!(
"OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
));
}
Ok(())
}
pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
match self.capacity_strategy {
None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
Some(OpenRouterCapacityStrategy::ByokFirst) => {
let mut result = self.clone();
let provider = result.provider.get_or_insert_with(Default::default);
if provider.allow_fallbacks.is_none() {
provider.allow_fallbacks = Some(true);
}
Ok(result)
}
Some(OpenRouterCapacityStrategy::ByokOnly) => {
let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
if only_is_empty {
return Err(
"OpenRouter BYOK-only strategy requires provider.only to list at least \
one upstream provider slug. Configure the provider list to match the \
BYOK providers registered in your OpenRouter workspace."
.to_string(),
);
}
let mut result = self.clone();
let provider = result.provider.get_or_insert_with(Default::default);
provider.allow_fallbacks = Some(false);
Ok(result)
}
}
}
pub fn apply_presets(&self) -> std::result::Result<Self, String> {
if self.presets.is_empty() {
return Ok(self.clone());
}
let mut derived = OpenRouterProviderRouting::default();
for preset in &self.presets {
match preset {
OpenRouterRoutingPreset::CheapestWithTools => {
derived.require_parameters = Some(true);
derived.sort = Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Price,
));
}
OpenRouterRoutingPreset::LowestLatencyReview => {
derived.sort = Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Throughput,
));
}
OpenRouterRoutingPreset::ZdrOnly => {
derived.zdr = Some(true);
}
OpenRouterRoutingPreset::ByokFirst => {
if derived.allow_fallbacks.is_none() {
derived.allow_fallbacks = Some(true);
}
}
OpenRouterRoutingPreset::NoDataCollection => {
derived.data_collection = Some(OpenRouterDataCollection::Deny);
}
OpenRouterRoutingPreset::StrictJson
| OpenRouterRoutingPreset::ReasoningRequired => {
derived.require_parameters = Some(true);
}
OpenRouterRoutingPreset::MaxPrice {
prompt_usd_per_million,
completion_usd_per_million,
} => {
if prompt_usd_per_million.is_some_and(|v| v < 0.0)
|| completion_usd_per_million.is_some_and(|v| v < 0.0)
{
return Err(
"MaxPrice preset values must be non-negative USD per million tokens"
.to_string(),
);
}
if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
let mp = derived.max_price.get_or_insert_with(Default::default);
if let Some(p) = prompt_usd_per_million {
mp.prompt = Some(p / 1_000_000.0);
}
if let Some(c) = completion_usd_per_million {
mp.completion = Some(c / 1_000_000.0);
}
}
}
}
}
let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
let mut result = self.clone();
result.presets = vec![];
result.provider = if merged.is_empty() {
None
} else {
Some(merged)
};
Ok(result)
}
}
fn merge_provider_routing(
derived: OpenRouterProviderRouting,
explicit: OpenRouterProviderRouting,
) -> OpenRouterProviderRouting {
OpenRouterProviderRouting {
order: if !explicit.order.is_empty() {
explicit.order
} else {
derived.order
},
only: if !explicit.only.is_empty() {
explicit.only
} else {
derived.only
},
ignore: if !explicit.ignore.is_empty() {
explicit.ignore
} else {
derived.ignore
},
allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
require_parameters: explicit.require_parameters.or(derived.require_parameters),
data_collection: explicit.data_collection.or(derived.data_collection),
zdr: explicit.zdr.or(derived.zdr),
enforce_distillable_text: explicit
.enforce_distillable_text
.or(derived.enforce_distillable_text),
quantizations: if !explicit.quantizations.is_empty() {
explicit.quantizations
} else {
derived.quantizations
},
sort: explicit.sort.or(derived.sort),
max_price: explicit.max_price.or(derived.max_price),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenRouterRoute {
Fallback,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OpenRouterProviderRouting {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub order: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub only: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_fallbacks: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_parameters: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_collection: Option<OpenRouterDataCollection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zdr: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enforce_distillable_text: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quantizations: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort: Option<OpenRouterProviderSort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_price: Option<OpenRouterMaxPrice>,
}
impl OpenRouterProviderRouting {
pub fn is_empty(&self) -> bool {
self.order.is_empty()
&& self.only.is_empty()
&& self.ignore.is_empty()
&& self.allow_fallbacks.is_none()
&& self.require_parameters.is_none()
&& self.data_collection.is_none()
&& self.zdr.is_none()
&& self.enforce_distillable_text.is_none()
&& self.quantizations.is_empty()
&& self.sort.is_none()
&& self.max_price.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenRouterDataCollection {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum OpenRouterProviderSort {
Simple(OpenRouterProviderSortBy),
Advanced(OpenRouterProviderSortOptions),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenRouterProviderSortBy {
Price,
Throughput,
Latency,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OpenRouterProviderSortOptions {
pub by: OpenRouterProviderSortBy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partition: Option<OpenRouterSortPartition>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenRouterSortPartition {
Model,
None,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OpenRouterMaxPrice {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OpenRouterWebSearchPlugin {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_results: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_prompt: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OpenRouterFilePlugin {}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OpenRouterPluginConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<OpenRouterWebSearchPlugin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<OpenRouterFilePlugin>,
}
impl OpenRouterPluginConfig {
pub fn is_empty(&self) -> bool {
self.web.is_none() && self.file.is_none()
}
}
pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
#[derive(Debug, Clone)]
pub struct LlmCallConfig {
pub model: String,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub tools: Vec<ToolDefinition>,
pub reasoning_effort: Option<String>,
pub metadata: HashMap<String, String>,
pub previous_response_id: Option<String>,
pub tool_search: Option<ToolSearchConfig>,
pub prompt_cache: Option<PromptCacheConfig>,
pub openrouter_routing: Option<OpenRouterRoutingConfig>,
}
impl From<&RuntimeAgent> for LlmCallConfig {
fn from(runtime_agent: &RuntimeAgent) -> Self {
Self {
model: runtime_agent.model.clone(),
temperature: runtime_agent.temperature,
max_tokens: runtime_agent.max_tokens,
tools: runtime_agent.tools.clone(),
reasoning_effort: None, metadata: HashMap::new(), previous_response_id: None,
tool_search: runtime_agent.tool_search.clone(),
prompt_cache: runtime_agent.prompt_cache.clone(),
openrouter_routing: None,
}
}
}
#[derive(Debug, Clone)]
pub struct LlmResponse {
pub text: String,
pub thinking: Option<String>,
pub thinking_signature: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
pub metadata: LlmCompletionMetadata,
}
pub struct LlmCallConfigBuilder {
config: LlmCallConfig,
}
impl LlmCallConfigBuilder {
pub fn from(runtime_agent: &RuntimeAgent) -> Self {
Self {
config: LlmCallConfig::from(runtime_agent),
}
}
pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
self.config.reasoning_effort = Some(effort.into());
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.config.model = model.into();
self
}
pub fn temperature(mut self, temp: f32) -> Self {
self.config.temperature = Some(temp);
self
}
pub fn max_tokens(mut self, tokens: u32) -> Self {
self.config.max_tokens = Some(tokens);
self
}
pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
self.config.tools = tools;
self
}
pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
self.config.metadata = metadata;
self
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.config.metadata.insert(key.into(), value.into());
self
}
pub fn previous_response_id(mut self, id: Option<String>) -> Self {
self.config.previous_response_id = id;
self
}
pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
self.config.tool_search = Some(config);
self
}
pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
self.config.prompt_cache = Some(config);
self
}
pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
self.config.openrouter_routing = (!config.is_empty()).then_some(config);
self
}
pub fn build(self) -> LlmCallConfig {
self.config
}
}
impl From<&crate::message::Message> for LlmMessage {
fn from(msg: &crate::message::Message) -> Self {
let role = match msg.role {
crate::message::MessageRole::System => LlmMessageRole::System,
crate::message::MessageRole::User => LlmMessageRole::User,
crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
};
let tool_calls: Vec<ToolCall> = msg
.tool_calls()
.into_iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
})
.collect();
LlmMessage {
role,
content: LlmMessageContent::Text(msg.content_to_llm_string()),
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
phase: msg.phase,
thinking: msg.thinking.clone(),
thinking_signature: msg.thinking_signature.clone(),
}
}
}
use crate::traits::ResolvedImage;
use uuid::Uuid;
impl LlmMessage {
pub fn from_message_with_images(
msg: &crate::message::Message,
resolved_images: &HashMap<Uuid, ResolvedImage>,
) -> Self {
use crate::message::{ContentPart, MessageRole};
let role = match msg.role {
MessageRole::System => LlmMessageRole::System,
MessageRole::User => LlmMessageRole::User,
MessageRole::Agent => LlmMessageRole::Assistant,
MessageRole::ToolResult => LlmMessageRole::Tool,
};
let mut parts: Vec<LlmContentPart> = Vec::new();
let mut tool_calls: Vec<ToolCall> = Vec::new();
for part in &msg.content {
match part {
ContentPart::Text(t) => {
parts.push(LlmContentPart::Text {
text: t.text.clone(),
});
}
ContentPart::Image(img) => {
if let Some(url) = &img.url {
parts.push(LlmContentPart::Image { url: url.clone() });
} else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
{
let data_url = format!("data:{};base64,{}", media_type, base64);
parts.push(LlmContentPart::Image { url: data_url });
}
}
ContentPart::ImageFile(img_file) => {
if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
parts.push(LlmContentPart::Image {
url: resolved.to_data_url(),
});
} else {
parts.push(LlmContentPart::Text {
text: format!("[Image not found: {}]", img_file.image_id),
});
}
}
ContentPart::ToolCall(tc) => {
tool_calls.push(ToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
});
}
ContentPart::ToolResult(tr) => {
let text = if let Some(err) = &tr.error {
format!("Tool error: {}", err)
} else if let Some(res) = &tr.result {
serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
} else {
"{}".to_string()
};
let text = truncate_tool_result(text);
parts.push(LlmContentPart::Text { text });
}
}
}
let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
if let LlmContentPart::Text { text } = &parts[0] {
LlmMessageContent::Text(text.clone())
} else {
LlmMessageContent::Parts(parts)
}
} else if parts.is_empty() {
LlmMessageContent::Text(String::new())
} else {
LlmMessageContent::Parts(parts)
};
LlmMessage {
role,
content,
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
phase: msg.phase,
thinking: msg.thinking.clone(),
thinking_signature: msg.thinking_signature.clone(),
}
}
pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
msg.content.iter().any(|p| p.is_image_file())
}
pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
msg.content
.iter()
.filter_map(|p| match p {
crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
_ => None,
})
.collect()
}
}
pub use crate::provider::DriverId;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderMetadata {
pub refresh_token: Option<String>,
pub account_id: Option<String>,
pub extra: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct ProviderConfig {
pub provider_type: DriverId,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub metadata: ProviderMetadata,
}
impl ProviderConfig {
pub fn new(provider_type: DriverId) -> Self {
Self {
provider_type,
api_key: None,
base_url: None,
metadata: ProviderMetadata::default(),
}
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
self.metadata = metadata;
self
}
}
#[derive(Debug, Clone)]
pub struct DriverConfig {
pub provider_type: DriverId,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub metadata: ProviderMetadata,
}
impl From<&crate::traits::ResolvedModel> for ProviderConfig {
fn from(model: &crate::traits::ResolvedModel) -> Self {
Self {
provider_type: model.provider_type.clone(),
api_key: model.api_key.clone(),
base_url: model.base_url.clone(),
metadata: model.provider_metadata.clone().unwrap_or_default(),
}
}
}
pub type BoxedChatDriver = Box<dyn ChatDriver>;
#[derive(Debug, Clone)]
pub struct EmbedRequest {
pub texts: Vec<String>,
pub model: String,
}
#[derive(Debug, Clone)]
pub struct EmbedResponse {
pub embeddings: Vec<Vec<f32>>,
pub usage_tokens: Option<u32>,
}
#[derive(Debug, thiserror::Error)]
pub enum EmbeddingsDriverError {
#[error("embeddings provider returned an error: {0}")]
Provider(String),
#[error("embeddings request failed: {0}")]
Transport(String),
}
#[async_trait]
pub trait EmbeddingsDriver: Send + Sync {
async fn embed(
&self,
request: EmbedRequest,
) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
}
pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
pub type EmbeddingsDriverFactory =
Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceKind {
Chat,
Embeddings,
Realtime,
Images,
Rerank,
}
impl std::fmt::Display for ServiceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ServiceKind::Chat => "chat",
ServiceKind::Embeddings => "embeddings",
ServiceKind::Realtime => "realtime",
ServiceKind::Images => "images",
ServiceKind::Rerank => "rerank",
};
f.write_str(s)
}
}
#[derive(Clone)]
pub struct DriverDescriptor {
pub id: DriverId,
pub display_name: String,
pub services: Vec<ServiceKind>,
pub credential_schema: CredentialFormSchema,
pub chat: Option<DriverFactory>,
pub embeddings: Option<EmbeddingsDriverFactory>,
}
impl DriverDescriptor {
pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
where
F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
{
let id = id.into();
Self {
display_name: default_display_name(&id),
credential_schema: default_credential_schema(&id),
services: vec![ServiceKind::Chat],
chat: Some(Arc::new(factory)),
embeddings: None,
id,
}
}
pub fn supports(&self, service: ServiceKind) -> bool {
self.services.contains(&service)
}
}
impl std::fmt::Debug for DriverDescriptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DriverDescriptor")
.field("id", &self.id)
.field("display_name", &self.display_name)
.field("services", &self.services)
.field("chat", &self.chat.is_some())
.field("embeddings", &self.embeddings.is_some())
.finish()
}
}
fn default_display_name(id: &DriverId) -> String {
match id {
DriverId::OpenAI => "OpenAI".to_string(),
DriverId::OpenRouter => "OpenRouter".to_string(),
DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
DriverId::Anthropic => "Anthropic".to_string(),
DriverId::Gemini => "Google Gemini".to_string(),
DriverId::Bedrock => "AWS Bedrock".to_string(),
DriverId::Mai => "Microsoft MAI".to_string(),
DriverId::LlmSim => "LLM Simulator".to_string(),
DriverId::External(id) => id.to_string(),
}
}
fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
match id {
DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
_ => CredentialFormSchema::api_key(String::new()),
}
}
#[derive(Clone, Default)]
pub struct DriverRegistry {
descriptors: HashMap<DriverId, DriverDescriptor>,
}
impl DriverRegistry {
pub fn new() -> Self {
Self {
descriptors: HashMap::new(),
}
}
pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
if self.descriptors.contains_key(&descriptor.id) {
panic!(
"driver already registered for provider '{}'; \
use register_descriptor_or_replace to overwrite intentionally",
descriptor.id
);
}
self.descriptors.insert(descriptor.id.clone(), descriptor);
}
pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
self.descriptors.insert(descriptor.id.clone(), descriptor);
}
pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
where
F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
{
self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
}
pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
where
F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
{
self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
}
pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
where
F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
{
self.register(DriverId::external(id), factory);
}
pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
let requires_api_key = !matches!(
config.provider_type,
DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
);
if requires_api_key && config.api_key.is_none() {
return Err(AgentLoopError::llm(
"API key is required. Configure the API key in provider settings.",
));
}
let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
AgentLoopError::driver_not_registered(config.provider_type.to_string())
})?;
let factory = descriptor.chat.as_ref().ok_or_else(|| {
AgentLoopError::llm(format!(
"Provider driver '{}' does not implement the chat service.",
config.provider_type
))
})?;
let driver_config = DriverConfig {
provider_type: config.provider_type.clone(),
api_key: config.api_key.clone(),
base_url: config.base_url.clone(),
metadata: config.metadata.clone(),
};
Ok(factory(&driver_config))
}
pub fn has_driver(&self, provider_type: &DriverId) -> bool {
self.descriptors.contains_key(provider_type)
}
pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
self.descriptors.get(provider_type)
}
pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
self.descriptors
.get(provider_type)
.is_some_and(|d| d.supports(service))
}
pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
self.descriptors
.values()
.filter(|d| d.supports(service))
.map(|d| d.id.clone())
.collect()
}
pub fn registered_providers(&self) -> Vec<DriverId> {
self.descriptors.keys().cloned().collect()
}
pub fn create_embeddings_driver(
&self,
config: &ProviderConfig,
) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
let requires_api_key = !matches!(
config.provider_type,
DriverId::LlmSim | DriverId::External(_)
);
if requires_api_key && config.api_key.is_none() {
return Err(EmbeddingsDriverError::Provider(
"API key is required. Configure the API key in provider settings.".to_string(),
));
}
let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
EmbeddingsDriverError::Provider(format!(
"No driver registered for provider '{}'",
config.provider_type
))
})?;
let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
EmbeddingsDriverError::Provider(format!(
"Provider driver '{}' does not implement the embeddings service.",
config.provider_type
))
})?;
let driver_config = DriverConfig {
provider_type: config.provider_type.clone(),
api_key: config.api_key.clone(),
base_url: config.base_url.clone(),
metadata: config.metadata.clone(),
};
Ok(factory(&driver_config))
}
}
const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
const TRUNCATION_SUFFIX: &str =
"\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
fn truncate_tool_result(text: String) -> String {
if text.len() <= MAX_TOOL_RESULT_BYTES {
return text;
}
let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
let mut end = content_budget;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
let mut truncated = text[..end].to_string();
truncated.push_str(TRUNCATION_SUFFIX);
truncated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fold_system_messages_none_when_absent() {
let messages = vec![
LlmMessage::text(LlmMessageRole::User, "hi"),
LlmMessage::text(LlmMessageRole::Assistant, "ok"),
];
assert_eq!(fold_system_messages(&messages), None);
}
#[test]
fn test_fold_system_messages_single() {
let messages = vec![
LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
LlmMessage::text(LlmMessageRole::User, "hi"),
];
assert_eq!(
fold_system_messages(&messages),
Some("AGENT-PROMPT".to_string())
);
}
#[test]
fn test_fold_system_messages_accumulates_in_order() {
let messages = vec![
LlmMessage::text(LlmMessageRole::System, "A"),
LlmMessage::text(LlmMessageRole::User, "hi"),
LlmMessage::text(LlmMessageRole::Assistant, "ok"),
LlmMessage::text(LlmMessageRole::System, "B"),
];
assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
}
#[test]
fn test_fold_system_messages_concatenates_parts() {
let messages = vec![LlmMessage::parts(
LlmMessageRole::System,
vec![
LlmContentPart::text("foo"),
LlmContentPart::image("data:image/png;base64,xxx"),
LlmContentPart::text("bar"),
],
)];
assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
}
#[test]
fn test_llm_call_config_builder_from_runtime_agent() {
let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
assert_eq!(llm_config.model, "gpt-4o");
assert!(llm_config.reasoning_effort.is_none());
assert!(llm_config.temperature.is_none());
assert!(llm_config.max_tokens.is_none());
assert!(llm_config.tools.is_empty());
assert!(llm_config.metadata.is_empty());
}
#[test]
fn test_llm_call_config_builder_with_metadata() {
let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
.with_metadata("session_id", "session_abc123")
.with_metadata("agent_id", "agent_xyz789")
.build();
assert_eq!(
llm_config.metadata.get("session_id"),
Some(&"session_abc123".to_string())
);
assert_eq!(
llm_config.metadata.get("agent_id"),
Some(&"agent_xyz789".to_string())
);
}
#[test]
fn test_llm_call_config_builder_with_metadata_hashmap() {
let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
let mut metadata = HashMap::new();
metadata.insert("key1".to_string(), "value1".to_string());
metadata.insert("key2".to_string(), "value2".to_string());
let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
.metadata(metadata)
.build();
assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
}
#[test]
fn test_llm_call_config_builder_with_reasoning_effort() {
let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
.reasoning_effort("high")
.build();
assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
}
#[test]
fn test_llm_call_config_builder_with_all_options() {
let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
.model("claude-3-opus")
.reasoning_effort("medium")
.temperature(0.7)
.max_tokens(1000)
.build();
assert_eq!(llm_config.model, "claude-3-opus");
assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
assert_eq!(llm_config.temperature, Some(0.7));
assert_eq!(llm_config.max_tokens, Some(1000));
}
#[test]
fn test_llm_call_config_builder_with_openrouter_routing() {
let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
let routing = OpenRouterRoutingConfig::fallback_models([
"openai/gpt-5-mini",
"anthropic/claude-sonnet-4.5",
]);
let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
.openrouter_routing(routing.clone())
.build();
assert_eq!(llm_config.openrouter_routing, Some(routing));
}
#[test]
fn test_openrouter_fallback_models_empty_is_empty() {
let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
assert!(routing.is_empty());
assert_eq!(routing.route, None);
}
#[test]
fn test_openrouter_routing_validates_primary_model() {
let routing = OpenRouterRoutingConfig::fallback_models([
"openai/gpt-5-mini",
"anthropic/claude-sonnet-4.5",
]);
assert!(
routing
.validate_for_primary_model("openai/gpt-5-mini")
.is_ok()
);
let err = routing
.validate_for_primary_model("anthropic/claude-sonnet-4.5")
.unwrap_err();
assert!(err.contains("models[0]"));
}
#[test]
fn test_openrouter_routing_rejects_fallback_without_models() {
let routing = OpenRouterRoutingConfig {
route: Some(OpenRouterRoute::Fallback),
..Default::default()
};
let err = routing
.validate_for_primary_model("openai/gpt-5-mini")
.unwrap_err();
assert!(err.contains("requires at least one model"));
}
#[test]
fn test_openrouter_routing_serializes_request_fields() {
let routing = OpenRouterRoutingConfig {
models: vec![
"openai/gpt-5-mini".to_string(),
"anthropic/claude-sonnet-4.5".to_string(),
],
route: Some(OpenRouterRoute::Fallback),
provider: Some(OpenRouterProviderRouting {
order: vec!["anthropic".to_string(), "openai".to_string()],
allow_fallbacks: Some(false),
require_parameters: Some(true),
data_collection: Some(OpenRouterDataCollection::Deny),
zdr: Some(true),
sort: Some(OpenRouterProviderSort::Advanced(
OpenRouterProviderSortOptions {
by: OpenRouterProviderSortBy::Throughput,
partition: Some(OpenRouterSortPartition::None),
},
)),
max_price: Some(OpenRouterMaxPrice {
prompt: Some(1.0),
completion: Some(2.0),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_value(routing).unwrap();
assert_eq!(
json,
serde_json::json!({
"models": [
"openai/gpt-5-mini",
"anthropic/claude-sonnet-4.5"
],
"route": "fallback",
"provider": {
"order": ["anthropic", "openai"],
"allow_fallbacks": false,
"require_parameters": true,
"data_collection": "deny",
"zdr": true,
"sort": {
"by": "throughput",
"partition": "none"
},
"max_price": {
"prompt": 1.0,
"completion": 2.0
}
}
})
);
}
#[test]
fn test_provider_type_parsing() {
assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
assert_eq!(
"openrouter".parse::<DriverId>().unwrap(),
DriverId::OpenRouter
);
assert_eq!(
"openai_completions".parse::<DriverId>().unwrap(),
DriverId::OpenAICompletions
);
assert_eq!(
"azure_openai".parse::<DriverId>().unwrap(),
DriverId::AzureOpenAI
);
assert_eq!(
"anthropic".parse::<DriverId>().unwrap(),
DriverId::Anthropic
);
assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
assert_eq!(
"ollama".parse::<DriverId>().unwrap(),
DriverId::external("ollama")
);
assert_eq!(
"custom".parse::<DriverId>().unwrap(),
DriverId::external("custom")
);
}
#[test]
fn test_external_provider_id_is_case_insensitive() {
assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
assert_eq!(
"Ollama".parse::<DriverId>().unwrap(),
"ollama".parse::<DriverId>().unwrap()
);
assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
assert_eq!(
DriverId::external("MyProvider"),
"myprovider".parse::<DriverId>().unwrap()
);
}
#[test]
fn test_provider_type_display() {
assert_eq!(DriverId::OpenAI.to_string(), "openai");
assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
assert_eq!(
DriverId::OpenAICompletions.to_string(),
"openai_completions"
);
assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
assert_eq!(DriverId::Gemini.to_string(), "gemini");
}
#[test]
fn test_provider_config_builder() {
let config = ProviderConfig::new(DriverId::Anthropic)
.with_api_key("test-key")
.with_base_url("https://custom.api.com");
assert_eq!(config.provider_type, DriverId::Anthropic);
assert_eq!(config.api_key, Some("test-key".to_string()));
assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
}
#[test]
fn test_driver_registry_requires_api_key() {
let mut registry = DriverRegistry::new();
registry.register(DriverId::OpenAI, |_config| {
struct MockDriver;
#[async_trait]
impl ChatDriver for MockDriver {
async fn chat_completion_stream(
&self,
_messages: Vec<LlmMessage>,
_config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
unimplemented!()
}
}
Box::new(MockDriver)
});
let config = ProviderConfig::new(DriverId::OpenAI);
let result = registry.create_chat_driver(&config);
assert!(result.is_err());
let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
let result = registry.create_chat_driver(&config_with_key);
assert!(result.is_ok());
}
#[test]
fn test_driver_registry_returns_error_for_unregistered_provider() {
let registry = DriverRegistry::new();
let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
let result = registry.create_chat_driver(&config);
if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
assert_eq!(provider, "anthropic");
} else {
panic!("Expected DriverNotRegistered error");
}
}
#[test]
fn test_driver_registry_registration() {
let mut registry = DriverRegistry::new();
assert!(!registry.has_driver(&DriverId::OpenAI));
assert!(!registry.has_driver(&DriverId::Anthropic));
registry.register(DriverId::OpenAI, |_config| {
struct MockDriver;
#[async_trait]
impl ChatDriver for MockDriver {
async fn chat_completion_stream(
&self,
_messages: Vec<LlmMessage>,
_config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
unimplemented!()
}
}
Box::new(MockDriver)
});
assert!(registry.has_driver(&DriverId::OpenAI));
assert!(!registry.has_driver(&DriverId::Anthropic));
}
#[test]
fn test_register_external_and_create_driver_without_api_key() {
struct MockDriver;
#[async_trait]
impl ChatDriver for MockDriver {
async fn chat_completion_stream(
&self,
_messages: Vec<LlmMessage>,
_config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
unimplemented!()
}
}
let mut registry = DriverRegistry::new();
registry.register_external("openai-codex", |config| {
assert_eq!(config.provider_type, DriverId::external("openai-codex"));
Box::new(MockDriver)
});
assert!(registry.has_driver(&DriverId::external("openai-codex")));
let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
ProviderMetadata {
refresh_token: Some("rt".into()),
..Default::default()
},
);
assert!(registry.create_chat_driver(&config).is_ok());
}
#[test]
fn test_register_defaults_to_chat_only_descriptor() {
struct MockDriver;
#[async_trait]
impl ChatDriver for MockDriver {
async fn chat_completion_stream(
&self,
_messages: Vec<LlmMessage>,
_config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
unimplemented!()
}
}
let mut registry = DriverRegistry::new();
registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
assert_eq!(descriptor.display_name, "Anthropic");
assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
assert!(descriptor.chat.is_some());
assert_eq!(descriptor.credential_schema.fields.len(), 1);
assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
assert!(descriptor.credential_schema.fields[0].required);
registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
assert!(sim.credential_schema.fields.is_empty());
}
#[test]
fn test_descriptor_services_and_lookup() {
struct MockDriver;
#[async_trait]
impl ChatDriver for MockDriver {
async fn chat_completion_stream(
&self,
_messages: Vec<LlmMessage>,
_config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
unimplemented!()
}
}
let mut registry = DriverRegistry::new();
registry.register_descriptor(DriverDescriptor {
services: vec![ServiceKind::Chat, ServiceKind::Realtime],
..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
});
registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
let realtime = registry.providers_for(ServiceKind::Realtime);
assert_eq!(realtime, vec![DriverId::OpenAI]);
let mut chat = registry.providers_for(ServiceKind::Chat);
chat.sort_by_key(|p| p.to_string());
assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
}
#[test]
fn test_create_chat_driver_fails_without_chat_factory() {
let mut registry = DriverRegistry::new();
registry.register_descriptor(DriverDescriptor {
id: DriverId::external("embeddings-only"),
display_name: "Embeddings Only".to_string(),
services: vec![ServiceKind::Embeddings],
credential_schema: CredentialFormSchema::empty(),
chat: None,
embeddings: None,
});
let config = ProviderConfig::new(DriverId::external("embeddings-only"));
let err = match registry.create_chat_driver(&config) {
Ok(_) => panic!("expected error for missing chat factory"),
Err(err) => err,
};
assert!(
err.to_string()
.contains("does not implement the chat service"),
"unexpected error: {err}"
);
}
#[test]
#[should_panic(expected = "already registered")]
fn test_register_duplicate_panics() {
struct MockDriver;
#[async_trait]
impl ChatDriver for MockDriver {
async fn chat_completion_stream(
&self,
_messages: Vec<LlmMessage>,
_config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
unimplemented!()
}
}
let mut registry = DriverRegistry::new();
registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
}
#[test]
fn test_register_or_replace_overwrites() {
struct MockDriver;
#[async_trait]
impl ChatDriver for MockDriver {
async fn chat_completion_stream(
&self,
_messages: Vec<LlmMessage>,
_config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
unimplemented!()
}
}
let mut registry = DriverRegistry::new();
registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
assert!(registry.has_driver(&DriverId::LlmSim));
}
use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
#[test]
fn test_message_has_image_files_with_image_file() {
let message = Message {
id: uuid::Uuid::new_v4().into(),
role: MessageRole::User,
content: vec![
ContentPart::Text(TextContentPart {
text: "Look at this image".to_string(),
}),
ContentPart::ImageFile(ImageFileContentPart {
image_id: uuid::Uuid::new_v4().into(),
filename: Some("test.png".to_string()),
}),
],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: chrono::Utc::now(),
};
assert!(LlmMessage::message_has_image_files(&message));
}
#[test]
fn test_message_has_image_files_without_image_file() {
let message = Message {
id: uuid::Uuid::new_v4().into(),
role: MessageRole::User,
content: vec![ContentPart::Text(TextContentPart {
text: "Just text".to_string(),
})],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: chrono::Utc::now(),
};
assert!(!LlmMessage::message_has_image_files(&message));
}
#[test]
fn test_extract_image_file_ids() {
let id1 = uuid::Uuid::new_v4();
let id2 = uuid::Uuid::new_v4();
let message = Message {
id: uuid::Uuid::new_v4().into(),
role: MessageRole::User,
content: vec![
ContentPart::Text(TextContentPart {
text: "Look at these images".to_string(),
}),
ContentPart::ImageFile(ImageFileContentPart {
image_id: id1.into(),
filename: Some("test1.png".to_string()),
}),
ContentPart::ImageFile(ImageFileContentPart {
image_id: id2.into(),
filename: Some("test2.png".to_string()),
}),
],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: chrono::Utc::now(),
};
let ids = LlmMessage::extract_image_file_ids(&message);
assert_eq!(ids.len(), 2);
assert!(ids.contains(&id1));
assert!(ids.contains(&id2));
}
#[test]
fn test_from_message_with_images_text_only() {
let message = Message {
id: uuid::Uuid::new_v4().into(),
role: MessageRole::User,
content: vec![ContentPart::Text(TextContentPart {
text: "Hello".to_string(),
})],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: chrono::Utc::now(),
};
let resolved = std::collections::HashMap::new();
let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
assert_eq!(llm_message.role, LlmMessageRole::User);
match llm_message.content {
LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
_ => panic!("Expected text content"),
}
}
#[test]
fn test_from_message_with_images_resolved_image() {
let image_id = uuid::Uuid::new_v4();
let message = Message {
id: uuid::Uuid::new_v4().into(),
role: MessageRole::User,
content: vec![
ContentPart::Text(TextContentPart {
text: "Look at this".to_string(),
}),
ContentPart::ImageFile(ImageFileContentPart {
image_id: image_id.into(),
filename: Some("test.png".to_string()),
}),
],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: chrono::Utc::now(),
};
let mut resolved = std::collections::HashMap::new();
resolved.insert(
image_id,
crate::ResolvedImage::new("base64data", "image/png"),
);
let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
match &llm_message.content {
LlmMessageContent::Parts(parts) => {
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
if let LlmContentPart::Image { url } = &parts[1] {
assert!(url.starts_with("data:image/png;base64,"));
} else {
panic!("Expected image content part");
}
}
_ => panic!("Expected parts content"),
}
}
#[test]
fn test_from_message_with_images_unresolved_image() {
let image_id = uuid::Uuid::new_v4();
let message = Message {
id: uuid::Uuid::new_v4().into(),
role: MessageRole::User,
content: vec![ContentPart::ImageFile(ImageFileContentPart {
image_id: image_id.into(),
filename: Some("missing.png".to_string()),
})],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: chrono::Utc::now(),
};
let resolved = std::collections::HashMap::new();
let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
match &llm_message.content {
LlmMessageContent::Text(text) => {
assert!(text.contains("Image not found"));
}
LlmMessageContent::Parts(parts) => {
assert_eq!(parts.len(), 1);
if let LlmContentPart::Text { text } = &parts[0] {
assert!(text.contains("Image not found"));
} else {
panic!("Expected text placeholder for missing image");
}
}
}
}
#[test]
fn test_prepend_text_prefix_simple_text() {
let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
msg.prepend_text_prefix("[Alice] ");
assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
}
#[test]
fn test_prepend_text_prefix_parts() {
let mut msg = LlmMessage::parts(
LlmMessageRole::User,
vec![
LlmContentPart::Text {
text: "Hello".to_string(),
},
LlmContentPart::Image {
url: "data:image/png;base64,abc".to_string(),
},
],
);
msg.prepend_text_prefix("[Bob] ");
match &msg.content {
LlmMessageContent::Parts(parts) => {
if let LlmContentPart::Text { text } = &parts[0] {
assert_eq!(text, "[Bob] Hello");
} else {
panic!("Expected text part");
}
}
_ => panic!("Expected parts content"),
}
}
#[test]
fn test_prepend_text_prefix_parts_no_text() {
let mut msg = LlmMessage::parts(
LlmMessageRole::User,
vec![LlmContentPart::Image {
url: "data:image/png;base64,abc".to_string(),
}],
);
msg.prepend_text_prefix("[Eve] ");
match &msg.content {
LlmMessageContent::Parts(parts) => {
assert_eq!(parts.len(), 2);
if let LlmContentPart::Text { text } = &parts[0] {
assert_eq!(text, "[Eve] ");
} else {
panic!("Expected prepended text part");
}
}
_ => panic!("Expected parts content"),
}
}
#[test]
fn test_openrouter_plugin_config_is_empty() {
assert!(OpenRouterPluginConfig::default().is_empty());
assert!(
!OpenRouterPluginConfig {
web: Some(OpenRouterWebSearchPlugin::default()),
file: None,
}
.is_empty()
);
assert!(
!OpenRouterPluginConfig {
web: None,
file: Some(OpenRouterFilePlugin {}),
}
.is_empty()
);
}
#[test]
fn test_openrouter_routing_is_empty_with_plugins() {
let with_plugins = OpenRouterRoutingConfig {
plugins: Some(OpenRouterPluginConfig {
web: Some(OpenRouterWebSearchPlugin::default()),
file: None,
}),
..Default::default()
};
assert!(!with_plugins.is_empty());
let empty_plugins = OpenRouterRoutingConfig {
plugins: Some(OpenRouterPluginConfig::default()),
..Default::default()
};
assert!(empty_plugins.is_empty());
}
#[test]
fn test_openrouter_web_search_plugin_serialization() {
let plugin = OpenRouterWebSearchPlugin {
max_results: Some(10),
search_prompt: Some("search for Rust crates".to_string()),
};
let json = serde_json::to_value(&plugin).unwrap();
assert_eq!(json["max_results"], 10);
assert_eq!(json["search_prompt"], "search for Rust crates");
}
#[test]
fn test_openrouter_web_search_plugin_omits_none_fields() {
let plugin = OpenRouterWebSearchPlugin::default();
let json = serde_json::to_value(&plugin).unwrap();
assert!(json.get("max_results").is_none());
assert!(json.get("search_prompt").is_none());
}
#[test]
fn test_capacity_strategy_shared_capacity_is_noop() {
let base = OpenRouterRoutingConfig {
models: vec!["openai/gpt-5-mini".to_string()],
capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
..Default::default()
};
let result = base.apply_capacity_strategy().unwrap();
assert_eq!(
result.capacity_strategy,
Some(OpenRouterCapacityStrategy::SharedCapacity)
);
assert!(result.provider.is_none());
}
#[test]
fn test_capacity_strategy_none_is_noop() {
let base = OpenRouterRoutingConfig {
models: vec!["openai/gpt-5-mini".to_string()],
capacity_strategy: None,
..Default::default()
};
let result = base.apply_capacity_strategy().unwrap();
assert!(result.provider.is_none());
}
#[test]
fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
let base = OpenRouterRoutingConfig {
models: vec!["openai/gpt-5-mini".to_string()],
capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
..Default::default()
};
let result = base.apply_capacity_strategy().unwrap();
let provider = result.provider.as_ref().expect("provider set by ByokFirst");
assert_eq!(provider.allow_fallbacks, Some(true));
}
#[test]
fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
let base = OpenRouterRoutingConfig {
models: vec!["openai/gpt-5-mini".to_string()],
capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
provider: Some(OpenRouterProviderRouting {
allow_fallbacks: Some(false),
..Default::default()
}),
..Default::default()
};
let result = base.apply_capacity_strategy().unwrap();
let provider = result.provider.as_ref().unwrap();
assert_eq!(provider.allow_fallbacks, Some(false));
}
#[test]
fn test_capacity_strategy_byok_only_requires_provider_only() {
let base = OpenRouterRoutingConfig {
models: vec!["openai/gpt-5-mini".to_string()],
capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
..Default::default()
};
let err = base.apply_capacity_strategy().unwrap_err();
assert!(
err.contains("provider.only"),
"error should mention provider.only: {err}"
);
}
#[test]
fn test_capacity_strategy_byok_only_disables_fallbacks() {
let base = OpenRouterRoutingConfig {
models: vec!["openai/gpt-5-mini".to_string()],
capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
provider: Some(OpenRouterProviderRouting {
only: vec!["my-byok-provider".to_string()],
..Default::default()
}),
..Default::default()
};
let result = base.apply_capacity_strategy().unwrap();
let provider = result.provider.as_ref().unwrap();
assert_eq!(provider.allow_fallbacks, Some(false));
assert_eq!(provider.only, vec!["my-byok-provider"]);
}
#[test]
fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
let with_strategy = OpenRouterRoutingConfig {
capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
..Default::default()
};
assert!(!with_strategy.is_empty());
let byok_first = OpenRouterRoutingConfig {
capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
..Default::default()
};
assert!(!byok_first.is_empty());
let shared = OpenRouterRoutingConfig {
capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
..Default::default()
};
assert!(shared.is_empty());
}
#[test]
fn test_preset_no_presets_is_noop() {
let base = OpenRouterRoutingConfig {
models: vec!["openai/gpt-5-mini".to_string()],
..Default::default()
};
let result = base.apply_presets().unwrap();
assert_eq!(result, base);
}
#[test]
fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
..Default::default()
};
let result = base.apply_presets().unwrap();
assert!(result.presets.is_empty(), "presets cleared after apply");
let provider = result.provider.expect("provider set by preset");
assert_eq!(provider.require_parameters, Some(true));
assert_eq!(
provider.sort,
Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Price
))
);
}
#[test]
fn test_preset_lowest_latency_review_sets_sort_throughput() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set by preset");
assert_eq!(
provider.sort,
Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Throughput
))
);
}
#[test]
fn test_preset_zdr_only_sets_zdr() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::ZdrOnly],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(provider.zdr, Some(true));
}
#[test]
fn test_preset_byok_first_sets_allow_fallbacks() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::ByokFirst],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(provider.allow_fallbacks, Some(true));
}
#[test]
fn test_preset_no_data_collection_sets_data_collection_deny() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::NoDataCollection],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(
provider.data_collection,
Some(OpenRouterDataCollection::Deny)
);
}
#[test]
fn test_preset_strict_json_sets_require_parameters() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::StrictJson],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(provider.require_parameters, Some(true));
}
#[test]
fn test_preset_reasoning_required_sets_require_parameters() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(provider.require_parameters, Some(true));
}
#[test]
fn test_preset_max_price_converts_usd_per_million() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::MaxPrice {
prompt_usd_per_million: Some(5.0),
completion_usd_per_million: Some(15.0),
}],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
let max_price = provider.max_price.expect("max_price set");
let prompt = max_price.prompt.expect("prompt set");
assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
let completion = max_price.completion.expect("completion set");
assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
}
#[test]
fn test_preset_max_price_rejects_negative_values() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::MaxPrice {
prompt_usd_per_million: Some(-1.0),
completion_usd_per_million: None,
}],
..Default::default()
};
let err = base.apply_presets().unwrap_err();
assert!(
err.contains("non-negative"),
"error should mention non-negative: {err}"
);
}
#[test]
fn test_preset_max_price_both_none_no_provider_field() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::MaxPrice {
prompt_usd_per_million: None,
completion_usd_per_million: None,
}],
..Default::default()
};
let result = base.apply_presets().unwrap();
assert!(
result.provider.is_none(),
"MaxPrice with no dimensions should not produce a provider field"
);
}
#[test]
fn test_preset_explicit_provider_overrides_preset() {
let base = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
provider: Some(OpenRouterProviderRouting {
sort: Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Throughput,
)),
..Default::default()
}),
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(
provider.sort,
Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Throughput
))
);
assert_eq!(provider.require_parameters, Some(true));
}
#[test]
fn test_preset_multiple_presets_combined() {
let base = OpenRouterRoutingConfig {
presets: vec![
OpenRouterRoutingPreset::ZdrOnly,
OpenRouterRoutingPreset::NoDataCollection,
OpenRouterRoutingPreset::LowestLatencyReview,
],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(provider.zdr, Some(true));
assert_eq!(
provider.data_collection,
Some(OpenRouterDataCollection::Deny)
);
assert_eq!(
provider.sort,
Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Throughput
))
);
}
#[test]
fn test_preset_later_preset_overrides_sort() {
let base = OpenRouterRoutingConfig {
presets: vec![
OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
..Default::default()
};
let result = base.apply_presets().unwrap();
let provider = result.provider.expect("provider set");
assert_eq!(
provider.sort,
Some(OpenRouterProviderSort::Simple(
OpenRouterProviderSortBy::Throughput
))
);
assert_eq!(provider.require_parameters, Some(true));
}
#[test]
fn test_preset_non_empty_in_is_empty() {
let with_preset = OpenRouterRoutingConfig {
presets: vec![OpenRouterRoutingPreset::ZdrOnly],
..Default::default()
};
assert!(!with_preset.is_empty());
let without = OpenRouterRoutingConfig::default();
assert!(without.is_empty());
}
}