1use std::fmt;
20use std::str::FromStr;
21
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24
25use crate::error::ParseError;
26use crate::session::SessionId;
27
28#[derive(
36 Debug,
37 Clone,
38 Copy,
39 Serialize,
40 Deserialize,
41 Default,
42 PartialEq,
43 Eq,
44 strum::Display,
45 strum::EnumString,
46 strum::VariantArray,
47)]
48#[serde(rename_all = "lowercase")]
49#[strum(serialize_all = "lowercase")]
50pub enum ReasoningLevel {
51 #[default]
52 Off,
54 Low,
56 #[serde(alias = "on")]
58 Medium,
59 High,
61}
62
63impl ReasoningLevel {
64 pub fn is_on(&self) -> bool {
66 !matches!(self, Self::Off)
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
73#[serde(rename_all = "lowercase")]
74pub enum Provider {
75 OpenRouter,
76 #[default]
77 Ollama,
78 Anthropic,
79 OpenAi,
80 Mistral,
82 Connect,
84 Custom(String),
89}
90
91impl Provider {
92 pub fn default_model(&self) -> &str {
103 match self {
104 Provider::OpenRouter => "anthropic/claude-sonnet-4",
105 Provider::Ollama => "qwen3:8b",
106 Provider::Anthropic => "claude-sonnet-4-20250514",
107 Provider::OpenAi => "gpt-4.1",
108 Provider::Mistral => "mistral-medium-latest",
109 Provider::Connect => "flashone-229b",
110 Provider::Custom(_) => "default",
111 }
112 }
113}
114
115impl fmt::Display for Provider {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 match self {
118 Self::OpenRouter => f.write_str("openrouter"),
119 Self::Ollama => f.write_str("ollama"),
120 Self::Anthropic => f.write_str("anthropic"),
121 Self::OpenAi => f.write_str("openai"),
122 Self::Mistral => f.write_str("mistral"),
123 Self::Connect => f.write_str("connect"),
124 Self::Custom(name) => f.write_str(name),
125 }
126 }
127}
128
129impl FromStr for Provider {
130 type Err = ParseError;
131
132 fn from_str(value: &str) -> Result<Self, Self::Err> {
133 let value = value.trim();
134 if value.is_empty() || value.contains(':') || value.contains(',') {
135 return Err(ParseError::new(format!("invalid provider: {value}")));
136 }
137 Ok(match value.to_ascii_lowercase().as_str() {
138 "openrouter" => Self::OpenRouter,
139 "ollama" => Self::Ollama,
140 "anthropic" => Self::Anthropic,
141 "openai" => Self::OpenAi,
142 "mistral" => Self::Mistral,
143 "connect" => Self::Connect,
144 _ => Self::Custom(value.to_string()),
145 })
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
157pub struct AliasedModel {
158 pub name: String,
160 pub real_name: Option<String>,
162}
163
164impl AliasedModel {
165 pub fn capability_name(&self) -> &str {
167 self.real_name.as_deref().unwrap_or(&self.name)
168 }
169}
170
171impl Serialize for AliasedModel {
172 fn serialize<S: serde::Serializer>(
173 &self,
174 serializer: S,
175 ) -> std::result::Result<S::Ok, S::Error> {
176 serializer.serialize_str(&self.to_string())
177 }
178}
179
180impl<'de> Deserialize<'de> for AliasedModel {
181 fn deserialize<D: serde::Deserializer<'de>>(
182 deserializer: D,
183 ) -> std::result::Result<Self, D::Error> {
184 let s = String::deserialize(deserializer)?;
185 s.parse().map_err(serde::de::Error::custom)
186 }
187}
188
189impl FromStr for AliasedModel {
190 type Err = ParseError;
191
192 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
193 let (name, real_name) = if let Some((alias, real)) = s.split_once(',') {
194 (alias.trim().to_string(), Some(real.trim().to_string()))
195 } else {
196 (s.trim().to_string(), None)
197 };
198 if name.is_empty() {
199 return Err(ParseError::new("model name cannot be empty"));
200 }
201 if matches!(&real_name, Some(real) if real.is_empty()) {
202 return Err(ParseError::new("real model name cannot be empty"));
203 }
204 Ok(AliasedModel { name, real_name })
205 }
206}
207
208impl fmt::Display for AliasedModel {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 match &self.real_name {
211 Some(real) => write!(f, "{},{}", self.name, real),
212 None => write!(f, "{}", self.name),
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Hash)]
222pub struct Model {
223 pub provider: Provider,
224 pub model: AliasedModel,
225}
226
227impl Serialize for Model {
228 fn serialize<S: serde::Serializer>(
229 &self,
230 serializer: S,
231 ) -> std::result::Result<S::Ok, S::Error> {
232 serializer.serialize_str(&self.to_string())
233 }
234}
235
236impl<'de> Deserialize<'de> for Model {
237 fn deserialize<D: serde::Deserializer<'de>>(
238 deserializer: D,
239 ) -> std::result::Result<Self, D::Error> {
240 let s = String::deserialize(deserializer)?;
241 s.parse().map_err(serde::de::Error::custom)
242 }
243}
244
245impl fmt::Display for Model {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 write!(f, "{}:{}", self.provider, self.model)
248 }
249}
250
251impl FromStr for Model {
252 type Err = ParseError;
253
254 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
255 let (provider_str, model_part) = s.split_once(':').ok_or_else(|| {
256 ParseError::new(format!(
257 "Invalid model format '{}': expected 'provider:name'",
258 s
259 ))
260 })?;
261 let provider: Provider = provider_str.parse().map_err(|_| {
262 ParseError::new(format!(
263 "Unknown provider '{}' in model string",
264 provider_str
265 ))
266 })?;
267
268 let model: AliasedModel = model_part.parse()?;
269
270 Ok(Model { provider, model })
271 }
272}
273
274impl Model {
275 pub fn name(&self) -> &str {
277 &self.model.name
278 }
279
280 pub fn capability_name(&self) -> &str {
283 self.model.capability_name()
284 }
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize)]
292pub struct SamplingParams {
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub temperature: Option<Decimal>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub top_p: Option<Decimal>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub top_k: Option<u32>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub min_p: Option<Decimal>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub presence_penalty: Option<Decimal>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub repetition_penalty: Option<Decimal>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "lowercase")]
320pub enum Quantization {
321 Bf16,
322 Fp16,
323 Fp32,
324 Fp8,
325 Int4,
326 Int8,
327 Unknown,
329 #[serde(untagged)]
331 Other(String),
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "lowercase")]
337pub enum ProviderSort {
338 Price,
339 Throughput,
340 Latency,
341}
342
343#[derive(Debug, Clone, Default, Serialize, Deserialize)]
348pub struct ProviderPreferences {
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub order: Vec<String>,
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub quantizations: Vec<Quantization>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub allow_fallbacks: Option<bool>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub require_parameters: Option<bool>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub data_collection: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub zdr: Option<bool>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub sort: Option<ProviderSort>,
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
372 pub ignore: Vec<String>,
373}
374
375impl SamplingParams {
376 pub fn merge(&self, other: &Self) -> Self {
384 Self {
385 temperature: other.temperature.or(self.temperature),
386 top_p: other.top_p.or(self.top_p),
387 top_k: other.top_k.or(self.top_k),
388 min_p: other.min_p.or(self.min_p),
389 presence_penalty: other.presence_penalty.or(self.presence_penalty),
390 repetition_penalty: other.repetition_penalty.or(self.repetition_penalty),
391 }
392 }
393}
394
395impl fmt::Display for SamplingParams {
396 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397 let mut params = Vec::new();
398 if let Some(t) = self.temperature {
399 params.push(format!("t={}", t));
400 }
401 if let Some(top_p) = self.top_p {
402 params.push(format!("top_p={}", top_p));
403 }
404 if let Some(top_k) = self.top_k {
405 params.push(format!("top_k={}", top_k));
406 }
407 if let Some(min_p) = self.min_p {
408 params.push(format!("min_p={}", min_p));
409 }
410 if let Some(pp) = self.presence_penalty {
411 params.push(format!("p_penalty={}", pp));
412 }
413 if let Some(rp) = self.repetition_penalty {
414 params.push(format!("r_penalty={}", rp));
415 }
416 if params.is_empty() {
417 write!(f, "(none)")
418 } else {
419 write!(f, "{}", params.join(", "))
420 }
421 }
422}
423
424#[derive(Debug, Clone)]
429pub struct AgentLlmConfig {
430 pub model: Model,
432 pub max_tokens: Option<u32>,
434 pub reasoning: ReasoningLevel,
436 pub sampling: SamplingParams,
438 pub user: Option<String>,
440 pub provider_preferences: Option<ProviderPreferences>,
442 pub session_id: Option<SessionId>,
444}
445
446impl AgentLlmConfig {
447 pub fn new(model: Model) -> Self {
449 Self {
450 model,
451 max_tokens: None,
452 reasoning: ReasoningLevel::Off,
453 sampling: SamplingParams::default(),
454 user: None,
455 provider_preferences: None,
456 session_id: None,
457 }
458 }
459
460 pub fn with_user(mut self, user: impl Into<String>) -> Self {
462 self.user = Some(user.into());
463 self
464 }
465
466 pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
468 self.reasoning = level;
469 self
470 }
471
472 pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
474 self.provider_preferences = Some(prefs);
475 self
476 }
477
478 pub fn with_session_id(mut self, session_id: impl Into<SessionId>) -> Self {
480 self.session_id = Some(session_id.into());
481 self
482 }
483
484 pub fn with_model(&self, model: Model) -> Self {
486 Self {
487 model,
488 max_tokens: self.max_tokens,
489 reasoning: self.reasoning,
490 sampling: self.sampling.clone(),
491 user: self.user.clone(),
492 provider_preferences: self.provider_preferences.clone(),
493 session_id: self.session_id.clone(),
494 }
495 }
496}
497
498impl fmt::Display for AgentLlmConfig {
499 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500 write!(f, "{}", self.sampling)
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use rust_decimal_macros::dec;
508
509 #[test]
510 fn parse_model_round_trip() {
511 let model: Model = "openrouter:anthropic/claude-sonnet-4".parse().unwrap();
512 assert_eq!(model.provider, Provider::OpenRouter);
513 assert_eq!(model.model.name, "anthropic/claude-sonnet-4");
514 assert_eq!(model.to_string(), "openrouter:anthropic/claude-sonnet-4");
515 }
516
517 #[test]
518 fn parse_aliased_model() {
519 let aliased: AliasedModel = "fast, gpt-4.1".parse().unwrap();
520 assert_eq!(aliased.name, "fast");
521 assert_eq!(aliased.real_name.as_deref(), Some("gpt-4.1"));
522 assert_eq!(aliased.capability_name(), "gpt-4.1");
523 }
524
525 #[test]
526 fn parse_model_rejects_empty_names() {
527 assert!("ollama:".parse::<Model>().is_err());
528 assert!("ollama:alias,".parse::<Model>().is_err());
529 assert!("ollama:,real".parse::<Model>().is_err());
530 }
531
532 #[test]
533 fn parse_model_invalid_format() {
534 let result = "no-colon-here".parse::<Model>();
535 assert!(result.is_err());
536 }
537
538 #[test]
539 fn mistral_model_strings_pick_the_mistral_provider() {
540 let model: Model = "mistral:mistral-medium-3-5".parse().unwrap();
541 assert_eq!(model.provider, Provider::Mistral);
542 assert_eq!(model.name(), "mistral-medium-3-5");
543 assert_eq!(model.to_string(), "mistral:mistral-medium-3-5");
544 assert_eq!("Mistral".parse::<Provider>().unwrap(), Provider::Mistral);
545 let json = serde_json::to_string(&Provider::Mistral).unwrap();
546 assert_eq!(json, "\"mistral\"");
547 }
548
549 #[test]
550 fn parse_model_unknown_provider() {
551 let model: Model = "foobar:some-model".parse().unwrap();
552 assert_eq!(model.provider, Provider::Custom("foobar".into()));
553 }
554
555 #[test]
556 fn custom_provider_round_trips() {
557 let provider: Provider = "my-provider".parse().unwrap();
558 assert_eq!(provider.to_string(), "my-provider");
559 assert_eq!(provider, Provider::Custom("my-provider".into()));
560 }
561
562 #[test]
563 fn sampling_params_merge() {
564 let base = SamplingParams {
565 top_p: Some(dec!(0.9)),
566 top_k: Some(40),
567 ..Default::default()
568 };
569 let overlay = SamplingParams {
570 top_p: Some(dec!(0.8)),
571 min_p: Some(dec!(0.05)),
572 ..Default::default()
573 };
574 let merged = base.merge(&overlay);
575 assert_eq!(merged.top_p, Some(dec!(0.8)));
576 assert_eq!(merged.top_k, Some(40));
577 assert_eq!(merged.min_p, Some(dec!(0.05)));
578 }
579
580 #[test]
581 fn sampling_params_display_empty() {
582 let params = SamplingParams::default();
583 assert_eq!(params.to_string(), "(none)");
584 }
585
586 #[test]
587 fn agent_llm_config_display() {
588 let config = AgentLlmConfig {
589 model: "ollama:qwen3:8b".parse().unwrap(),
590 max_tokens: None,
591 reasoning: ReasoningLevel::Off,
592 sampling: SamplingParams {
593 temperature: Some(dec!(0.7)),
594 top_p: Some(dec!(0.9)),
595 ..Default::default()
596 },
597 user: None,
598 provider_preferences: None,
599 session_id: None,
600 };
601 let display = config.to_string();
602 assert!(display.contains("t=0.7"));
603 assert!(display.contains("top_p=0.9"));
604 }
605
606 #[test]
607 fn agent_llm_config_with_model() {
608 let config = AgentLlmConfig {
609 model: "ollama:test".parse().unwrap(),
610 max_tokens: Some(1000),
611 reasoning: ReasoningLevel::Medium,
612 sampling: SamplingParams {
613 temperature: Some(dec!(0.5)),
614 ..Default::default()
615 },
616 user: None,
617 provider_preferences: None,
618 session_id: Some("example-session".parse().unwrap()),
619 };
620 let new_model: Model = "anthropic:claude-sonnet-4-20250514".parse().unwrap();
621 let updated = config.with_model(new_model.clone());
622 assert_eq!(updated.model, new_model);
623 assert_eq!(updated.sampling.temperature, Some(dec!(0.5)));
624 assert_eq!(updated.max_tokens, Some(1000));
625 assert_eq!(
626 updated.session_id.as_ref().map(SessionId::as_str),
627 Some("example-session")
628 );
629 }
630
631 #[test]
632 fn provider_default_models() {
633 assert!(!Provider::Ollama.default_model().is_empty());
634 assert!(!Provider::OpenRouter.default_model().is_empty());
635 assert!(!Provider::Anthropic.default_model().is_empty());
636 assert!(!Provider::OpenAi.default_model().is_empty());
637 }
638
639 #[test]
640 fn reasoning_level_variants() {
641 assert!(!ReasoningLevel::Off.is_on());
642 assert!(ReasoningLevel::Low.is_on());
643 assert!(ReasoningLevel::Medium.is_on());
644 assert!(ReasoningLevel::High.is_on());
645 }
646
647 #[test]
648 fn reasoning_level_on_deserialises_as_medium() {
649 let level: ReasoningLevel = serde_json::from_str(r#""on""#).unwrap();
650 assert_eq!(level, ReasoningLevel::Medium);
651 }
652
653 #[test]
654 fn model_serde_round_trip() {
655 let model: Model = "ollama:llama3.2".parse().unwrap();
656 let json = serde_json::to_string(&model).unwrap();
657 let deserialized: Model = serde_json::from_str(&json).unwrap();
658 assert_eq!(model, deserialized);
659 }
660
661 #[test]
662 fn unknown_quantization_round_trip() {
663 let quantization: Quantization = serde_json::from_str(r#""unknown""#).unwrap();
664 assert_eq!(quantization, Quantization::Unknown);
665 assert_eq!(
666 serde_json::to_string(&quantization).unwrap(),
667 r#""unknown""#
668 );
669
670 let quantization: Quantization = serde_json::from_str(r#""mxfp8""#).unwrap();
671 assert_eq!(quantization, Quantization::Other("mxfp8".into()));
672 }
673}