use std::fmt;
use std::str::FromStr;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use crate::error::ParseError;
use crate::session::SessionId;
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningLevel {
#[default]
Off,
Low,
#[serde(alias = "on")]
Medium,
High,
}
impl ReasoningLevel {
pub fn is_on(&self) -> bool {
!matches!(self, Self::Off)
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
SerializeDisplay,
DeserializeFromStr,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
pub enum ApiKey {
#[strum(serialize = "openrouter")]
OpenRouter,
#[strum(serialize = "anthropic")]
Anthropic,
#[strum(serialize = "openai")]
OpenAi,
#[strum(serialize = "mistral")]
Mistral,
#[strum(serialize = "elevenlabs")]
ElevenLabs,
#[strum(serialize = "meshy")]
Meshy,
#[strum(serialize = "tripo")]
Tripo,
#[strum(serialize = "gemini")]
Gemini,
#[strum(serialize = "fal")]
Fal,
#[strum(serialize = "moonshot")]
Moonshot,
#[strum(serialize = "zai")]
Zai,
#[strum(serialize = "kimi-code")]
KimiCode,
#[strum(serialize = "firecrawl")]
Firecrawl,
}
impl ApiKey {
pub const fn env_var(self) -> &'static str {
match self {
Self::OpenRouter => "OPENROUTER_API_KEY",
Self::Anthropic => "ANTHROPIC_API_KEY",
Self::OpenAi => "OPENAI_API_KEY",
Self::Mistral => "MISTRAL_API_KEY",
Self::ElevenLabs => "ELEVENLABS_API_KEY",
Self::Meshy => "MESHY_API_KEY",
Self::Tripo => "TRIPO_API_KEY",
Self::Gemini => "GEMINI_API_KEY",
Self::Fal => "FAL_KEY",
Self::Moonshot => "MOONSHOT_API_KEY",
Self::Zai => "ZAI_API_KEY",
Self::KimiCode => "KIMI_CODE_API_KEY",
Self::Firecrawl => "FIRECRAWL_API_KEY",
}
}
pub const fn env_aliases(self) -> &'static [&'static str] {
match self {
Self::Gemini => &["GOOGLE_API_KEY"],
_ => &[],
}
}
pub const fn human_readable_name(self) -> &'static str {
match self {
Self::OpenRouter => "OpenRouter",
Self::Anthropic => "Anthropic",
Self::OpenAi => "OpenAI",
Self::Mistral => "Mistral",
Self::ElevenLabs => "ElevenLabs",
Self::Meshy => "Meshy",
Self::Tripo => "Tripo",
Self::Gemini => "Gemini",
Self::Fal => "Fal",
Self::Moonshot => "Moonshot",
Self::Zai => "Z.ai",
Self::KimiCode => "Kimi Code",
Self::Firecrawl => "Firecrawl",
}
}
pub fn read_env(self) -> Option<String> {
self.read_env_with(|name| std::env::var(name).ok())
}
fn read_env_with(self, mut lookup: impl FnMut(&str) -> Option<String>) -> Option<String> {
std::iter::once(self.env_var())
.chain(self.env_aliases().iter().copied())
.find_map(&mut lookup)
}
}
#[derive(Debug, Clone, SerializeDisplay, DeserializeFromStr, PartialEq, Eq, Hash, Default)]
pub enum Provider {
OpenRouter,
#[default]
Ollama,
Anthropic,
OpenAi,
Mistral,
Connect,
Custom(String),
}
impl Provider {
pub fn default_model(&self) -> &str {
match self {
Provider::OpenRouter => "anthropic/claude-sonnet-4",
Provider::Ollama => "qwen3:8b",
Provider::Anthropic => "claude-sonnet-4-20250514",
Provider::OpenAi => "gpt-4.1",
Provider::Mistral => "mistral-medium-latest",
Provider::Connect => "flashone-229b",
Provider::Custom(_) => "default",
}
}
}
impl fmt::Display for Provider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OpenRouter => f.write_str("openrouter"),
Self::Ollama => f.write_str("ollama"),
Self::Anthropic => f.write_str("anthropic"),
Self::OpenAi => f.write_str("openai"),
Self::Mistral => f.write_str("mistral"),
Self::Connect => f.write_str("connect"),
Self::Custom(name) => f.write_str(name),
}
}
}
impl FromStr for Provider {
type Err = ParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let value = value.trim();
if value.is_empty() || value.contains(':') || value.contains(',') {
return Err(ParseError::new(format!("invalid provider: {value}")));
}
Ok(match value.to_ascii_lowercase().as_str() {
"openrouter" => Self::OpenRouter,
"ollama" => Self::Ollama,
"anthropic" => Self::Anthropic,
"openai" => Self::OpenAi,
"mistral" => Self::Mistral,
"connect" => Self::Connect,
_ => Self::Custom(value.to_string()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub struct AliasedModel {
pub name: String,
pub real_name: Option<String>,
}
impl AliasedModel {
pub fn capability_name(&self) -> &str {
self.real_name.as_deref().unwrap_or(&self.name)
}
}
impl FromStr for AliasedModel {
type Err = ParseError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let (name, real_name) = if let Some((alias, real)) = s.split_once(',') {
(alias.trim().to_string(), Some(real.trim().to_string()))
} else {
(s.trim().to_string(), None)
};
if name.is_empty() {
return Err(ParseError::new("model name cannot be empty"));
}
if matches!(&real_name, Some(real) if real.is_empty()) {
return Err(ParseError::new("real model name cannot be empty"));
}
Ok(AliasedModel { name, real_name })
}
}
impl fmt::Display for AliasedModel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.real_name {
Some(real) => write!(f, "{},{}", self.name, real),
None => write!(f, "{}", self.name),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub struct Model {
pub provider: Provider,
pub model: AliasedModel,
}
impl fmt::Display for Model {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.provider, self.model)
}
}
impl FromStr for Model {
type Err = ParseError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let (provider_str, model_part) = s.split_once(':').ok_or_else(|| {
ParseError::new(format!(
"Invalid model format '{}': expected 'provider:name'",
s
))
})?;
let provider: Provider = provider_str.parse().map_err(|_| {
ParseError::new(format!(
"Unknown provider '{}' in model string",
provider_str
))
})?;
let model: AliasedModel = model_part.parse()?;
Ok(Model { provider, model })
}
}
impl Model {
pub fn name(&self) -> &str {
&self.model.name
}
pub fn capability_name(&self) -> &str {
self.model.capability_name()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SamplingParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_p: Option<Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repetition_penalty: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Quantization {
Bf16,
Fp16,
Fp32,
Fp8,
Int4,
Int8,
Unknown,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderSort {
Price,
Throughput,
Latency,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProviderPreferences {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub order: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quantizations: Vec<Quantization>,
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zdr: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort: Option<ProviderSort>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<String>,
}
impl SamplingParams {
pub fn merge(&self, other: &Self) -> Self {
Self {
temperature: other.temperature.or(self.temperature),
top_p: other.top_p.or(self.top_p),
top_k: other.top_k.or(self.top_k),
min_p: other.min_p.or(self.min_p),
presence_penalty: other.presence_penalty.or(self.presence_penalty),
repetition_penalty: other.repetition_penalty.or(self.repetition_penalty),
}
}
}
impl fmt::Display for SamplingParams {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut params = Vec::new();
if let Some(t) = self.temperature {
params.push(format!("t={}", t));
}
if let Some(top_p) = self.top_p {
params.push(format!("top_p={}", top_p));
}
if let Some(top_k) = self.top_k {
params.push(format!("top_k={}", top_k));
}
if let Some(min_p) = self.min_p {
params.push(format!("min_p={}", min_p));
}
if let Some(pp) = self.presence_penalty {
params.push(format!("p_penalty={}", pp));
}
if let Some(rp) = self.repetition_penalty {
params.push(format!("r_penalty={}", rp));
}
if params.is_empty() {
write!(f, "(none)")
} else {
write!(f, "{}", params.join(", "))
}
}
}
#[derive(Debug, Clone)]
pub struct AgentLlmConfig {
pub model: Model,
pub max_tokens: Option<u32>,
pub reasoning: ReasoningLevel,
pub sampling: SamplingParams,
pub user: Option<String>,
pub provider_preferences: Option<ProviderPreferences>,
pub session_id: Option<SessionId>,
}
impl AgentLlmConfig {
pub fn new(model: Model) -> Self {
Self {
model,
max_tokens: None,
reasoning: ReasoningLevel::Off,
sampling: SamplingParams::default(),
user: None,
provider_preferences: None,
session_id: None,
}
}
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
self.reasoning = level;
self
}
pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
self.provider_preferences = Some(prefs);
self
}
pub fn with_session_id(mut self, session_id: impl Into<SessionId>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn with_model(&self, model: Model) -> Self {
Self {
model,
max_tokens: self.max_tokens,
reasoning: self.reasoning,
sampling: self.sampling.clone(),
user: self.user.clone(),
provider_preferences: self.provider_preferences.clone(),
session_id: self.session_id.clone(),
}
}
}
impl fmt::Display for AgentLlmConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.sampling)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn api_key_contract() {
use strum::VariantArray;
let expected = [
(
ApiKey::OpenRouter,
"openrouter",
"OPENROUTER_API_KEY",
&[][..],
"OpenRouter",
),
(
ApiKey::Anthropic,
"anthropic",
"ANTHROPIC_API_KEY",
&[][..],
"Anthropic",
),
(
ApiKey::OpenAi,
"openai",
"OPENAI_API_KEY",
&[][..],
"OpenAI",
),
(
ApiKey::Mistral,
"mistral",
"MISTRAL_API_KEY",
&[][..],
"Mistral",
),
(
ApiKey::ElevenLabs,
"elevenlabs",
"ELEVENLABS_API_KEY",
&[][..],
"ElevenLabs",
),
(ApiKey::Meshy, "meshy", "MESHY_API_KEY", &[][..], "Meshy"),
(ApiKey::Tripo, "tripo", "TRIPO_API_KEY", &[][..], "Tripo"),
(
ApiKey::Gemini,
"gemini",
"GEMINI_API_KEY",
&["GOOGLE_API_KEY"][..],
"Gemini",
),
(ApiKey::Fal, "fal", "FAL_KEY", &[][..], "Fal"),
(
ApiKey::Moonshot,
"moonshot",
"MOONSHOT_API_KEY",
&[][..],
"Moonshot",
),
(ApiKey::Zai, "zai", "ZAI_API_KEY", &[][..], "Z.ai"),
(
ApiKey::KimiCode,
"kimi-code",
"KIMI_CODE_API_KEY",
&[][..],
"Kimi Code",
),
(
ApiKey::Firecrawl,
"firecrawl",
"FIRECRAWL_API_KEY",
&[][..],
"Firecrawl",
),
];
assert_eq!(ApiKey::VARIANTS.len(), expected.len());
for (key, slug, env, aliases, name) in expected {
assert_eq!(key.to_string(), slug);
assert_eq!(slug.parse::<ApiKey>().unwrap(), key);
assert_eq!(serde_json::to_value(key).unwrap(), slug);
assert_eq!(
serde_json::from_value::<ApiKey>(serde_json::json!(slug)).unwrap(),
key
);
assert_eq!(key.env_var(), env);
assert_eq!(key.env_aliases(), aliases);
assert_eq!(key.human_readable_name(), name);
assert!(ApiKey::VARIANTS.contains(&key));
}
}
#[test]
fn api_key_environment_lookup_preserves_precedence_and_empty_values() {
for primary in [Some("primary"), Some(""), None] {
let mut names = Vec::new();
let value = ApiKey::Gemini.read_env_with(|name| {
names.push(name.to_owned());
match name {
"GEMINI_API_KEY" => primary.map(str::to_owned),
"GOOGLE_API_KEY" => Some("alias".to_owned()),
_ => panic!("unexpected environment variable"),
}
});
assert_eq!(value.as_deref(), Some(primary.unwrap_or("alias")));
assert_eq!(names.len(), if primary.is_some() { 1 } else { 2 });
}
for key in <ApiKey as strum::VariantArray>::VARIANTS {
assert_eq!(key.read_env_with(|_| None), None);
let value = key.read_env_with(|name| {
assert_eq!(name, key.env_var());
Some("example-key".to_owned())
});
assert_eq!(value.as_deref(), Some("example-key"));
}
}
#[test]
fn model_strings_keep_their_serde_format_and_validation() {
for raw in ["example-model", "fast,example-model"] {
let model: AliasedModel = raw.parse().unwrap();
assert_eq!(serde_json::to_value(&model).unwrap(), raw);
assert_eq!(
serde_json::from_value::<AliasedModel>(serde_json::json!(raw)).unwrap(),
model
);
}
for raw in ["openai:example-model", "my-provider:fast,example-model"] {
let model: Model = raw.parse().unwrap();
assert_eq!(serde_json::to_value(&model).unwrap(), raw);
assert_eq!(
serde_json::from_value::<Model>(serde_json::json!(raw)).unwrap(),
model
);
}
for raw in ["", ",example-model", "fast,"] {
assert!(serde_json::from_value::<AliasedModel>(serde_json::json!(raw)).is_err());
}
for raw in [
"",
"example-model",
"openai:",
":example-model",
"openai:fast,",
] {
assert!(serde_json::from_value::<Model>(serde_json::json!(raw)).is_err());
}
}
#[test]
fn providers_serialize_as_names_and_deserialize_through_the_parser() {
for provider in [
Provider::OpenRouter,
Provider::Ollama,
Provider::Anthropic,
Provider::OpenAi,
Provider::Mistral,
Provider::Connect,
Provider::Custom("my-provider".into()),
Provider::Custom("MyProvider".into()),
] {
let value = serde_json::to_value(&provider).unwrap();
assert_eq!(value, provider.to_string());
assert_eq!(serde_json::from_value::<Provider>(value).unwrap(), provider);
}
assert_eq!(
serde_json::from_value::<Provider>(serde_json::json!(" OpenAI ")).unwrap(),
Provider::OpenAi
);
for raw in ["", " ", "invalid:provider", "invalid,provider"] {
assert!(serde_json::from_value::<Provider>(serde_json::json!(raw)).is_err());
}
}
#[test]
fn parse_model_round_trip() {
let model: Model = "openrouter:anthropic/claude-sonnet-4".parse().unwrap();
assert_eq!(model.provider, Provider::OpenRouter);
assert_eq!(model.model.name, "anthropic/claude-sonnet-4");
assert_eq!(model.to_string(), "openrouter:anthropic/claude-sonnet-4");
}
#[test]
fn parse_aliased_model() {
let aliased: AliasedModel = "fast, gpt-4.1".parse().unwrap();
assert_eq!(aliased.name, "fast");
assert_eq!(aliased.real_name.as_deref(), Some("gpt-4.1"));
assert_eq!(aliased.capability_name(), "gpt-4.1");
}
#[test]
fn parse_model_rejects_empty_names() {
assert!("ollama:".parse::<Model>().is_err());
assert!("ollama:alias,".parse::<Model>().is_err());
assert!("ollama:,real".parse::<Model>().is_err());
}
#[test]
fn parse_model_invalid_format() {
let result = "no-colon-here".parse::<Model>();
assert!(result.is_err());
}
#[test]
fn mistral_model_strings_pick_the_mistral_provider() {
let model: Model = "mistral:mistral-medium-3-5".parse().unwrap();
assert_eq!(model.provider, Provider::Mistral);
assert_eq!(model.name(), "mistral-medium-3-5");
assert_eq!(model.to_string(), "mistral:mistral-medium-3-5");
assert_eq!("Mistral".parse::<Provider>().unwrap(), Provider::Mistral);
let json = serde_json::to_string(&Provider::Mistral).unwrap();
assert_eq!(json, "\"mistral\"");
}
#[test]
fn parse_model_unknown_provider() {
let model: Model = "foobar:some-model".parse().unwrap();
assert_eq!(model.provider, Provider::Custom("foobar".into()));
}
#[test]
fn custom_provider_round_trips() {
let provider: Provider = "my-provider".parse().unwrap();
assert_eq!(provider.to_string(), "my-provider");
assert_eq!(provider, Provider::Custom("my-provider".into()));
}
#[test]
fn sampling_params_merge() {
let base = SamplingParams {
top_p: Some(dec!(0.9)),
top_k: Some(40),
..Default::default()
};
let overlay = SamplingParams {
top_p: Some(dec!(0.8)),
min_p: Some(dec!(0.05)),
..Default::default()
};
let merged = base.merge(&overlay);
assert_eq!(merged.top_p, Some(dec!(0.8)));
assert_eq!(merged.top_k, Some(40));
assert_eq!(merged.min_p, Some(dec!(0.05)));
}
#[test]
fn sampling_params_display_empty() {
let params = SamplingParams::default();
assert_eq!(params.to_string(), "(none)");
}
#[test]
fn agent_llm_config_display() {
let config = AgentLlmConfig {
model: "ollama:qwen3:8b".parse().unwrap(),
max_tokens: None,
reasoning: ReasoningLevel::Off,
sampling: SamplingParams {
temperature: Some(dec!(0.7)),
top_p: Some(dec!(0.9)),
..Default::default()
},
user: None,
provider_preferences: None,
session_id: None,
};
let display = config.to_string();
assert!(display.contains("t=0.7"));
assert!(display.contains("top_p=0.9"));
}
#[test]
fn agent_llm_config_with_model() {
let config = AgentLlmConfig {
model: "ollama:test".parse().unwrap(),
max_tokens: Some(1000),
reasoning: ReasoningLevel::Medium,
sampling: SamplingParams {
temperature: Some(dec!(0.5)),
..Default::default()
},
user: None,
provider_preferences: None,
session_id: Some("example-session".parse().unwrap()),
};
let new_model: Model = "anthropic:claude-sonnet-4-20250514".parse().unwrap();
let updated = config.with_model(new_model.clone());
assert_eq!(updated.model, new_model);
assert_eq!(updated.sampling.temperature, Some(dec!(0.5)));
assert_eq!(updated.max_tokens, Some(1000));
assert_eq!(
updated.session_id.as_ref().map(SessionId::as_str),
Some("example-session")
);
}
#[test]
fn provider_default_models() {
assert!(!Provider::Ollama.default_model().is_empty());
assert!(!Provider::OpenRouter.default_model().is_empty());
assert!(!Provider::Anthropic.default_model().is_empty());
assert!(!Provider::OpenAi.default_model().is_empty());
}
#[test]
fn reasoning_level_variants() {
assert!(!ReasoningLevel::Off.is_on());
assert!(ReasoningLevel::Low.is_on());
assert!(ReasoningLevel::Medium.is_on());
assert!(ReasoningLevel::High.is_on());
}
#[test]
fn reasoning_level_on_deserialises_as_medium() {
let level: ReasoningLevel = serde_json::from_str(r#""on""#).unwrap();
assert_eq!(level, ReasoningLevel::Medium);
}
#[test]
fn model_serde_round_trip() {
let model: Model = "ollama:llama3.2".parse().unwrap();
let json = serde_json::to_string(&model).unwrap();
let deserialized: Model = serde_json::from_str(&json).unwrap();
assert_eq!(model, deserialized);
}
#[test]
fn unknown_quantization_round_trip() {
let quantization: Quantization = serde_json::from_str(r#""unknown""#).unwrap();
assert_eq!(quantization, Quantization::Unknown);
assert_eq!(
serde_json::to_string(&quantization).unwrap(),
r#""unknown""#
);
let quantization: Quantization = serde_json::from_str(r#""mxfp8""#).unwrap();
assert_eq!(quantization, Quantization::Other("mxfp8".into()));
}
}