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)]
47#[serde(rename_all = "lowercase")]
48#[strum(serialize_all = "lowercase")]
49pub enum ReasoningLevel {
50 #[default]
51 Off,
53 Low,
55 #[serde(alias = "on")]
57 Medium,
58 High,
60}
61
62impl ReasoningLevel {
63 pub fn is_on(&self) -> bool {
65 !matches!(self, Self::Off)
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
72#[serde(rename_all = "lowercase")]
73pub enum Provider {
74 OpenRouter,
75 #[default]
76 Ollama,
77 Anthropic,
78 OpenAi,
79 Connect,
81 Custom(String),
86}
87
88impl Provider {
89 pub fn default_model(&self) -> &str {
99 match self {
100 Provider::OpenRouter => "anthropic/claude-sonnet-4",
101 Provider::Ollama => "qwen3:8b",
102 Provider::Anthropic => "claude-sonnet-4-20250514",
103 Provider::OpenAi => "gpt-4.1",
104 Provider::Connect => "flashone-229b",
105 Provider::Custom(_) => "default",
106 }
107 }
108}
109
110impl fmt::Display for Provider {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::OpenRouter => f.write_str("openrouter"),
114 Self::Ollama => f.write_str("ollama"),
115 Self::Anthropic => f.write_str("anthropic"),
116 Self::OpenAi => f.write_str("openai"),
117 Self::Connect => f.write_str("connect"),
118 Self::Custom(name) => f.write_str(name),
119 }
120 }
121}
122
123impl FromStr for Provider {
124 type Err = ParseError;
125
126 fn from_str(value: &str) -> Result<Self, Self::Err> {
127 let value = value.trim();
128 if value.is_empty() || value.contains(':') || value.contains(',') {
129 return Err(ParseError::new(format!("invalid provider: {value}")));
130 }
131 Ok(match value.to_ascii_lowercase().as_str() {
132 "openrouter" => Self::OpenRouter,
133 "ollama" => Self::Ollama,
134 "anthropic" => Self::Anthropic,
135 "openai" => Self::OpenAi,
136 "connect" => Self::Connect,
137 _ => Self::Custom(value.to_string()),
138 })
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct AliasedModel {
151 pub name: String,
153 pub real_name: Option<String>,
155}
156
157impl AliasedModel {
158 pub fn capability_name(&self) -> &str {
160 self.real_name.as_deref().unwrap_or(&self.name)
161 }
162}
163
164impl Serialize for AliasedModel {
165 fn serialize<S: serde::Serializer>(
166 &self,
167 serializer: S,
168 ) -> std::result::Result<S::Ok, S::Error> {
169 serializer.serialize_str(&self.to_string())
170 }
171}
172
173impl<'de> Deserialize<'de> for AliasedModel {
174 fn deserialize<D: serde::Deserializer<'de>>(
175 deserializer: D,
176 ) -> std::result::Result<Self, D::Error> {
177 let s = String::deserialize(deserializer)?;
178 s.parse().map_err(serde::de::Error::custom)
179 }
180}
181
182impl FromStr for AliasedModel {
183 type Err = ParseError;
184
185 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
186 let (name, real_name) = if let Some((alias, real)) = s.split_once(',') {
187 (alias.trim().to_string(), Some(real.trim().to_string()))
188 } else {
189 (s.trim().to_string(), None)
190 };
191 if name.is_empty() {
192 return Err(ParseError::new("model name cannot be empty"));
193 }
194 if matches!(&real_name, Some(real) if real.is_empty()) {
195 return Err(ParseError::new("real model name cannot be empty"));
196 }
197 Ok(AliasedModel { name, real_name })
198 }
199}
200
201impl fmt::Display for AliasedModel {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 match &self.real_name {
204 Some(real) => write!(f, "{},{}", self.name, real),
205 None => write!(f, "{}", self.name),
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215pub struct Model {
216 pub provider: Provider,
217 pub model: AliasedModel,
218}
219
220impl Serialize for Model {
221 fn serialize<S: serde::Serializer>(
222 &self,
223 serializer: S,
224 ) -> std::result::Result<S::Ok, S::Error> {
225 serializer.serialize_str(&self.to_string())
226 }
227}
228
229impl<'de> Deserialize<'de> for Model {
230 fn deserialize<D: serde::Deserializer<'de>>(
231 deserializer: D,
232 ) -> std::result::Result<Self, D::Error> {
233 let s = String::deserialize(deserializer)?;
234 s.parse().map_err(serde::de::Error::custom)
235 }
236}
237
238impl fmt::Display for Model {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 write!(f, "{}:{}", self.provider, self.model)
241 }
242}
243
244impl FromStr for Model {
245 type Err = ParseError;
246
247 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
248 let (provider_str, model_part) = s.split_once(':').ok_or_else(|| {
249 ParseError::new(format!(
250 "Invalid model format '{}': expected 'provider:name'",
251 s
252 ))
253 })?;
254 let provider: Provider = provider_str.parse().map_err(|_| {
255 ParseError::new(format!(
256 "Unknown provider '{}' in model string",
257 provider_str
258 ))
259 })?;
260
261 let model: AliasedModel = model_part.parse()?;
262
263 Ok(Model { provider, model })
264 }
265}
266
267impl Model {
268 pub fn name(&self) -> &str {
270 &self.model.name
271 }
272
273 pub fn capability_name(&self) -> &str {
276 self.model.capability_name()
277 }
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
285pub struct SamplingParams {
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub temperature: Option<Decimal>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub top_p: Option<Decimal>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub top_k: Option<u32>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub min_p: Option<Decimal>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub presence_penalty: Option<Decimal>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub repetition_penalty: Option<Decimal>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "lowercase")]
313pub enum Quantization {
314 Bf16,
315 Fp16,
316 Fp32,
317 Fp8,
318 Int4,
319 Int8,
320 #[serde(untagged)]
322 Other(String),
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "lowercase")]
328pub enum ProviderSort {
329 Price,
330 Throughput,
331 Latency,
332}
333
334#[derive(Debug, Clone, Default, Serialize, Deserialize)]
339pub struct ProviderPreferences {
340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
342 pub order: Vec<String>,
343 #[serde(default, skip_serializing_if = "Vec::is_empty")]
345 pub quantizations: Vec<Quantization>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub allow_fallbacks: Option<bool>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub require_parameters: Option<bool>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub data_collection: Option<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub zdr: Option<bool>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub sort: Option<ProviderSort>,
361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 pub ignore: Vec<String>,
364}
365
366impl SamplingParams {
367 pub fn merge(&self, other: &Self) -> Self {
375 Self {
376 temperature: other.temperature.or(self.temperature),
377 top_p: other.top_p.or(self.top_p),
378 top_k: other.top_k.or(self.top_k),
379 min_p: other.min_p.or(self.min_p),
380 presence_penalty: other.presence_penalty.or(self.presence_penalty),
381 repetition_penalty: other.repetition_penalty.or(self.repetition_penalty),
382 }
383 }
384}
385
386impl fmt::Display for SamplingParams {
387 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388 let mut params = Vec::new();
389 if let Some(t) = self.temperature {
390 params.push(format!("t={}", t));
391 }
392 if let Some(top_p) = self.top_p {
393 params.push(format!("top_p={}", top_p));
394 }
395 if let Some(top_k) = self.top_k {
396 params.push(format!("top_k={}", top_k));
397 }
398 if let Some(min_p) = self.min_p {
399 params.push(format!("min_p={}", min_p));
400 }
401 if let Some(pp) = self.presence_penalty {
402 params.push(format!("p_penalty={}", pp));
403 }
404 if let Some(rp) = self.repetition_penalty {
405 params.push(format!("r_penalty={}", rp));
406 }
407 if params.is_empty() {
408 write!(f, "(none)")
409 } else {
410 write!(f, "{}", params.join(", "))
411 }
412 }
413}
414
415#[derive(Debug, Clone)]
420pub struct AgentLlmConfig {
421 pub model: Model,
423 pub max_tokens: Option<u32>,
425 pub reasoning: ReasoningLevel,
427 pub sampling: SamplingParams,
429 pub user: Option<String>,
431 pub provider_preferences: Option<ProviderPreferences>,
433 pub session_id: Option<SessionId>,
435}
436
437impl AgentLlmConfig {
438 pub fn new(model: Model) -> Self {
440 Self {
441 model,
442 max_tokens: None,
443 reasoning: ReasoningLevel::Off,
444 sampling: SamplingParams::default(),
445 user: None,
446 provider_preferences: None,
447 session_id: None,
448 }
449 }
450
451 pub fn with_user(mut self, user: impl Into<String>) -> Self {
453 self.user = Some(user.into());
454 self
455 }
456
457 pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
459 self.reasoning = level;
460 self
461 }
462
463 pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
465 self.provider_preferences = Some(prefs);
466 self
467 }
468
469 pub fn with_session_id(mut self, session_id: impl Into<SessionId>) -> Self {
471 self.session_id = Some(session_id.into());
472 self
473 }
474
475 pub fn with_model(&self, model: Model) -> Self {
477 Self {
478 model,
479 max_tokens: self.max_tokens,
480 reasoning: self.reasoning,
481 sampling: self.sampling.clone(),
482 user: self.user.clone(),
483 provider_preferences: self.provider_preferences.clone(),
484 session_id: self.session_id.clone(),
485 }
486 }
487}
488
489impl fmt::Display for AgentLlmConfig {
490 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491 write!(f, "{}", self.sampling)
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use rust_decimal_macros::dec;
499
500 #[test]
501 fn parse_model_round_trip() {
502 let model: Model = "openrouter:anthropic/claude-sonnet-4".parse().unwrap();
503 assert_eq!(model.provider, Provider::OpenRouter);
504 assert_eq!(model.model.name, "anthropic/claude-sonnet-4");
505 assert_eq!(model.to_string(), "openrouter:anthropic/claude-sonnet-4");
506 }
507
508 #[test]
509 fn parse_aliased_model() {
510 let aliased: AliasedModel = "fast, gpt-4.1".parse().unwrap();
511 assert_eq!(aliased.name, "fast");
512 assert_eq!(aliased.real_name.as_deref(), Some("gpt-4.1"));
513 assert_eq!(aliased.capability_name(), "gpt-4.1");
514 }
515
516 #[test]
517 fn parse_model_rejects_empty_names() {
518 assert!("ollama:".parse::<Model>().is_err());
519 assert!("ollama:alias,".parse::<Model>().is_err());
520 assert!("ollama:,real".parse::<Model>().is_err());
521 }
522
523 #[test]
524 fn parse_model_invalid_format() {
525 let result = "no-colon-here".parse::<Model>();
526 assert!(result.is_err());
527 }
528
529 #[test]
530 fn parse_model_unknown_provider() {
531 let model: Model = "foobar:some-model".parse().unwrap();
532 assert_eq!(model.provider, Provider::Custom("foobar".into()));
533 }
534
535 #[test]
536 fn custom_provider_round_trips() {
537 let provider: Provider = "my-provider".parse().unwrap();
538 assert_eq!(provider.to_string(), "my-provider");
539 assert_eq!(provider, Provider::Custom("my-provider".into()));
540 }
541
542 #[test]
543 fn sampling_params_merge() {
544 let base = SamplingParams {
545 top_p: Some(dec!(0.9)),
546 top_k: Some(40),
547 ..Default::default()
548 };
549 let overlay = SamplingParams {
550 top_p: Some(dec!(0.8)),
551 min_p: Some(dec!(0.05)),
552 ..Default::default()
553 };
554 let merged = base.merge(&overlay);
555 assert_eq!(merged.top_p, Some(dec!(0.8)));
556 assert_eq!(merged.top_k, Some(40));
557 assert_eq!(merged.min_p, Some(dec!(0.05)));
558 }
559
560 #[test]
561 fn sampling_params_display_empty() {
562 let params = SamplingParams::default();
563 assert_eq!(params.to_string(), "(none)");
564 }
565
566 #[test]
567 fn agent_llm_config_display() {
568 let config = AgentLlmConfig {
569 model: "ollama:qwen3:8b".parse().unwrap(),
570 max_tokens: None,
571 reasoning: ReasoningLevel::Off,
572 sampling: SamplingParams {
573 temperature: Some(dec!(0.7)),
574 top_p: Some(dec!(0.9)),
575 ..Default::default()
576 },
577 user: None,
578 provider_preferences: None,
579 session_id: None,
580 };
581 let display = config.to_string();
582 assert!(display.contains("t=0.7"));
583 assert!(display.contains("top_p=0.9"));
584 }
585
586 #[test]
587 fn agent_llm_config_with_model() {
588 let config = AgentLlmConfig {
589 model: "ollama:test".parse().unwrap(),
590 max_tokens: Some(1000),
591 reasoning: ReasoningLevel::Medium,
592 sampling: SamplingParams {
593 temperature: Some(dec!(0.5)),
594 ..Default::default()
595 },
596 user: None,
597 provider_preferences: None,
598 session_id: Some("example-session".parse().unwrap()),
599 };
600 let new_model: Model = "anthropic:claude-sonnet-4-20250514".parse().unwrap();
601 let updated = config.with_model(new_model.clone());
602 assert_eq!(updated.model, new_model);
603 assert_eq!(updated.sampling.temperature, Some(dec!(0.5)));
604 assert_eq!(updated.max_tokens, Some(1000));
605 assert_eq!(
606 updated.session_id.as_ref().map(SessionId::as_str),
607 Some("example-session")
608 );
609 }
610
611 #[test]
612 fn provider_default_models() {
613 assert!(!Provider::Ollama.default_model().is_empty());
614 assert!(!Provider::OpenRouter.default_model().is_empty());
615 assert!(!Provider::Anthropic.default_model().is_empty());
616 assert!(!Provider::OpenAi.default_model().is_empty());
617 }
618
619 #[test]
620 fn reasoning_level_variants() {
621 assert!(!ReasoningLevel::Off.is_on());
622 assert!(ReasoningLevel::Low.is_on());
623 assert!(ReasoningLevel::Medium.is_on());
624 assert!(ReasoningLevel::High.is_on());
625 }
626
627 #[test]
628 fn reasoning_level_on_deserialises_as_medium() {
629 let level: ReasoningLevel = serde_json::from_str(r#""on""#).unwrap();
630 assert_eq!(level, ReasoningLevel::Medium);
631 }
632
633 #[test]
634 fn model_serde_round_trip() {
635 let model: Model = "ollama:llama3.2".parse().unwrap();
636 let json = serde_json::to_string(&model).unwrap();
637 let deserialized: Model = serde_json::from_str(&json).unwrap();
638 assert_eq!(model, deserialized);
639 }
640}