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 #[serde(untagged)]
329 Other(String),
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(rename_all = "lowercase")]
335pub enum ProviderSort {
336 Price,
337 Throughput,
338 Latency,
339}
340
341#[derive(Debug, Clone, Default, Serialize, Deserialize)]
346pub struct ProviderPreferences {
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub order: Vec<String>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub quantizations: Vec<Quantization>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub allow_fallbacks: Option<bool>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub require_parameters: Option<bool>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub data_collection: Option<String>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub zdr: Option<bool>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub sort: Option<ProviderSort>,
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
370 pub ignore: Vec<String>,
371}
372
373impl SamplingParams {
374 pub fn merge(&self, other: &Self) -> Self {
382 Self {
383 temperature: other.temperature.or(self.temperature),
384 top_p: other.top_p.or(self.top_p),
385 top_k: other.top_k.or(self.top_k),
386 min_p: other.min_p.or(self.min_p),
387 presence_penalty: other.presence_penalty.or(self.presence_penalty),
388 repetition_penalty: other.repetition_penalty.or(self.repetition_penalty),
389 }
390 }
391}
392
393impl fmt::Display for SamplingParams {
394 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
395 let mut params = Vec::new();
396 if let Some(t) = self.temperature {
397 params.push(format!("t={}", t));
398 }
399 if let Some(top_p) = self.top_p {
400 params.push(format!("top_p={}", top_p));
401 }
402 if let Some(top_k) = self.top_k {
403 params.push(format!("top_k={}", top_k));
404 }
405 if let Some(min_p) = self.min_p {
406 params.push(format!("min_p={}", min_p));
407 }
408 if let Some(pp) = self.presence_penalty {
409 params.push(format!("p_penalty={}", pp));
410 }
411 if let Some(rp) = self.repetition_penalty {
412 params.push(format!("r_penalty={}", rp));
413 }
414 if params.is_empty() {
415 write!(f, "(none)")
416 } else {
417 write!(f, "{}", params.join(", "))
418 }
419 }
420}
421
422#[derive(Debug, Clone)]
427pub struct AgentLlmConfig {
428 pub model: Model,
430 pub max_tokens: Option<u32>,
432 pub reasoning: ReasoningLevel,
434 pub sampling: SamplingParams,
436 pub user: Option<String>,
438 pub provider_preferences: Option<ProviderPreferences>,
440 pub session_id: Option<SessionId>,
442}
443
444impl AgentLlmConfig {
445 pub fn new(model: Model) -> Self {
447 Self {
448 model,
449 max_tokens: None,
450 reasoning: ReasoningLevel::Off,
451 sampling: SamplingParams::default(),
452 user: None,
453 provider_preferences: None,
454 session_id: None,
455 }
456 }
457
458 pub fn with_user(mut self, user: impl Into<String>) -> Self {
460 self.user = Some(user.into());
461 self
462 }
463
464 pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
466 self.reasoning = level;
467 self
468 }
469
470 pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
472 self.provider_preferences = Some(prefs);
473 self
474 }
475
476 pub fn with_session_id(mut self, session_id: impl Into<SessionId>) -> Self {
478 self.session_id = Some(session_id.into());
479 self
480 }
481
482 pub fn with_model(&self, model: Model) -> Self {
484 Self {
485 model,
486 max_tokens: self.max_tokens,
487 reasoning: self.reasoning,
488 sampling: self.sampling.clone(),
489 user: self.user.clone(),
490 provider_preferences: self.provider_preferences.clone(),
491 session_id: self.session_id.clone(),
492 }
493 }
494}
495
496impl fmt::Display for AgentLlmConfig {
497 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498 write!(f, "{}", self.sampling)
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use rust_decimal_macros::dec;
506
507 #[test]
508 fn parse_model_round_trip() {
509 let model: Model = "openrouter:anthropic/claude-sonnet-4".parse().unwrap();
510 assert_eq!(model.provider, Provider::OpenRouter);
511 assert_eq!(model.model.name, "anthropic/claude-sonnet-4");
512 assert_eq!(model.to_string(), "openrouter:anthropic/claude-sonnet-4");
513 }
514
515 #[test]
516 fn parse_aliased_model() {
517 let aliased: AliasedModel = "fast, gpt-4.1".parse().unwrap();
518 assert_eq!(aliased.name, "fast");
519 assert_eq!(aliased.real_name.as_deref(), Some("gpt-4.1"));
520 assert_eq!(aliased.capability_name(), "gpt-4.1");
521 }
522
523 #[test]
524 fn parse_model_rejects_empty_names() {
525 assert!("ollama:".parse::<Model>().is_err());
526 assert!("ollama:alias,".parse::<Model>().is_err());
527 assert!("ollama:,real".parse::<Model>().is_err());
528 }
529
530 #[test]
531 fn parse_model_invalid_format() {
532 let result = "no-colon-here".parse::<Model>();
533 assert!(result.is_err());
534 }
535
536 #[test]
537 fn mistral_model_strings_pick_the_mistral_provider() {
538 let model: Model = "mistral:mistral-medium-3-5".parse().unwrap();
539 assert_eq!(model.provider, Provider::Mistral);
540 assert_eq!(model.name(), "mistral-medium-3-5");
541 assert_eq!(model.to_string(), "mistral:mistral-medium-3-5");
542 assert_eq!("Mistral".parse::<Provider>().unwrap(), Provider::Mistral);
543 let json = serde_json::to_string(&Provider::Mistral).unwrap();
544 assert_eq!(json, "\"mistral\"");
545 }
546
547 #[test]
548 fn parse_model_unknown_provider() {
549 let model: Model = "foobar:some-model".parse().unwrap();
550 assert_eq!(model.provider, Provider::Custom("foobar".into()));
551 }
552
553 #[test]
554 fn custom_provider_round_trips() {
555 let provider: Provider = "my-provider".parse().unwrap();
556 assert_eq!(provider.to_string(), "my-provider");
557 assert_eq!(provider, Provider::Custom("my-provider".into()));
558 }
559
560 #[test]
561 fn sampling_params_merge() {
562 let base = SamplingParams {
563 top_p: Some(dec!(0.9)),
564 top_k: Some(40),
565 ..Default::default()
566 };
567 let overlay = SamplingParams {
568 top_p: Some(dec!(0.8)),
569 min_p: Some(dec!(0.05)),
570 ..Default::default()
571 };
572 let merged = base.merge(&overlay);
573 assert_eq!(merged.top_p, Some(dec!(0.8)));
574 assert_eq!(merged.top_k, Some(40));
575 assert_eq!(merged.min_p, Some(dec!(0.05)));
576 }
577
578 #[test]
579 fn sampling_params_display_empty() {
580 let params = SamplingParams::default();
581 assert_eq!(params.to_string(), "(none)");
582 }
583
584 #[test]
585 fn agent_llm_config_display() {
586 let config = AgentLlmConfig {
587 model: "ollama:qwen3:8b".parse().unwrap(),
588 max_tokens: None,
589 reasoning: ReasoningLevel::Off,
590 sampling: SamplingParams {
591 temperature: Some(dec!(0.7)),
592 top_p: Some(dec!(0.9)),
593 ..Default::default()
594 },
595 user: None,
596 provider_preferences: None,
597 session_id: None,
598 };
599 let display = config.to_string();
600 assert!(display.contains("t=0.7"));
601 assert!(display.contains("top_p=0.9"));
602 }
603
604 #[test]
605 fn agent_llm_config_with_model() {
606 let config = AgentLlmConfig {
607 model: "ollama:test".parse().unwrap(),
608 max_tokens: Some(1000),
609 reasoning: ReasoningLevel::Medium,
610 sampling: SamplingParams {
611 temperature: Some(dec!(0.5)),
612 ..Default::default()
613 },
614 user: None,
615 provider_preferences: None,
616 session_id: Some("example-session".parse().unwrap()),
617 };
618 let new_model: Model = "anthropic:claude-sonnet-4-20250514".parse().unwrap();
619 let updated = config.with_model(new_model.clone());
620 assert_eq!(updated.model, new_model);
621 assert_eq!(updated.sampling.temperature, Some(dec!(0.5)));
622 assert_eq!(updated.max_tokens, Some(1000));
623 assert_eq!(
624 updated.session_id.as_ref().map(SessionId::as_str),
625 Some("example-session")
626 );
627 }
628
629 #[test]
630 fn provider_default_models() {
631 assert!(!Provider::Ollama.default_model().is_empty());
632 assert!(!Provider::OpenRouter.default_model().is_empty());
633 assert!(!Provider::Anthropic.default_model().is_empty());
634 assert!(!Provider::OpenAi.default_model().is_empty());
635 }
636
637 #[test]
638 fn reasoning_level_variants() {
639 assert!(!ReasoningLevel::Off.is_on());
640 assert!(ReasoningLevel::Low.is_on());
641 assert!(ReasoningLevel::Medium.is_on());
642 assert!(ReasoningLevel::High.is_on());
643 }
644
645 #[test]
646 fn reasoning_level_on_deserialises_as_medium() {
647 let level: ReasoningLevel = serde_json::from_str(r#""on""#).unwrap();
648 assert_eq!(level, ReasoningLevel::Medium);
649 }
650
651 #[test]
652 fn model_serde_round_trip() {
653 let model: Model = "ollama:llama3.2".parse().unwrap();
654 let json = serde_json::to_string(&model).unwrap();
655 let deserialized: Model = serde_json::from_str(&json).unwrap();
656 assert_eq!(model, deserialized);
657 }
658}