1use std::fmt;
20use std::str::FromStr;
21
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24use serde_with::{DeserializeFromStr, SerializeDisplay};
25
26use crate::error::ParseError;
27use crate::session::SessionId;
28
29#[derive(
37 Debug,
38 Clone,
39 Copy,
40 Serialize,
41 Deserialize,
42 Default,
43 PartialEq,
44 Eq,
45 strum::Display,
46 strum::EnumString,
47 strum::VariantArray,
48)]
49#[serde(rename_all = "lowercase")]
50#[strum(serialize_all = "lowercase")]
51pub enum ReasoningLevel {
52 #[default]
53 Off,
55 Low,
57 #[serde(alias = "on")]
59 Medium,
60 High,
62}
63
64impl ReasoningLevel {
65 pub fn is_on(&self) -> bool {
67 !matches!(self, Self::Off)
68 }
69}
70
71#[derive(
76 Debug,
77 Clone,
78 Copy,
79 PartialEq,
80 Eq,
81 Hash,
82 SerializeDisplay,
83 DeserializeFromStr,
84 strum::Display,
85 strum::EnumString,
86 strum::VariantArray,
87)]
88pub enum ApiKey {
89 #[strum(serialize = "openrouter")]
91 OpenRouter,
92 #[strum(serialize = "anthropic")]
94 Anthropic,
95 #[strum(serialize = "openai")]
97 OpenAi,
98 #[strum(serialize = "mistral")]
100 Mistral,
101 #[strum(serialize = "elevenlabs")]
103 ElevenLabs,
104 #[strum(serialize = "meshy")]
106 Meshy,
107 #[strum(serialize = "tripo")]
109 Tripo,
110 #[strum(serialize = "gemini")]
112 Gemini,
113 #[strum(serialize = "fal")]
115 Fal,
116 #[strum(serialize = "moonshot")]
118 Moonshot,
119 #[strum(serialize = "zai")]
121 Zai,
122 #[strum(serialize = "kimi-code")]
124 KimiCode,
125 #[strum(serialize = "firecrawl")]
127 Firecrawl,
128}
129
130impl ApiKey {
131 pub const fn env_var(self) -> &'static str {
133 match self {
134 Self::OpenRouter => "OPENROUTER_API_KEY",
135 Self::Anthropic => "ANTHROPIC_API_KEY",
136 Self::OpenAi => "OPENAI_API_KEY",
137 Self::Mistral => "MISTRAL_API_KEY",
138 Self::ElevenLabs => "ELEVENLABS_API_KEY",
139 Self::Meshy => "MESHY_API_KEY",
140 Self::Tripo => "TRIPO_API_KEY",
141 Self::Gemini => "GEMINI_API_KEY",
142 Self::Fal => "FAL_KEY",
143 Self::Moonshot => "MOONSHOT_API_KEY",
144 Self::Zai => "ZAI_API_KEY",
145 Self::KimiCode => "KIMI_CODE_API_KEY",
146 Self::Firecrawl => "FIRECRAWL_API_KEY",
147 }
148 }
149
150 pub const fn env_aliases(self) -> &'static [&'static str] {
152 match self {
153 Self::Gemini => &["GOOGLE_API_KEY"],
154 _ => &[],
155 }
156 }
157
158 pub const fn human_readable_name(self) -> &'static str {
160 match self {
161 Self::OpenRouter => "OpenRouter",
162 Self::Anthropic => "Anthropic",
163 Self::OpenAi => "OpenAI",
164 Self::Mistral => "Mistral",
165 Self::ElevenLabs => "ElevenLabs",
166 Self::Meshy => "Meshy",
167 Self::Tripo => "Tripo",
168 Self::Gemini => "Gemini",
169 Self::Fal => "Fal",
170 Self::Moonshot => "Moonshot",
171 Self::Zai => "Z.ai",
172 Self::KimiCode => "Kimi Code",
173 Self::Firecrawl => "Firecrawl",
174 }
175 }
176
177 pub fn read_env(self) -> Option<String> {
182 self.read_env_with(|name| std::env::var(name).ok())
183 }
184
185 fn read_env_with(self, mut lookup: impl FnMut(&str) -> Option<String>) -> Option<String> {
186 std::iter::once(self.env_var())
187 .chain(self.env_aliases().iter().copied())
188 .find_map(&mut lookup)
189 }
190}
191
192#[derive(Debug, Clone, SerializeDisplay, DeserializeFromStr, PartialEq, Eq, Hash, Default)]
195pub enum Provider {
196 OpenRouter,
197 #[default]
198 Ollama,
199 Anthropic,
200 OpenAi,
201 Mistral,
203 Connect,
205 Custom(String),
210}
211
212impl Provider {
213 pub fn default_model(&self) -> &str {
224 match self {
225 Provider::OpenRouter => "anthropic/claude-sonnet-4",
226 Provider::Ollama => "qwen3:8b",
227 Provider::Anthropic => "claude-sonnet-4-20250514",
228 Provider::OpenAi => "gpt-4.1",
229 Provider::Mistral => "mistral-medium-latest",
230 Provider::Connect => "flashone-229b",
231 Provider::Custom(_) => "default",
232 }
233 }
234}
235
236impl fmt::Display for Provider {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 match self {
239 Self::OpenRouter => f.write_str("openrouter"),
240 Self::Ollama => f.write_str("ollama"),
241 Self::Anthropic => f.write_str("anthropic"),
242 Self::OpenAi => f.write_str("openai"),
243 Self::Mistral => f.write_str("mistral"),
244 Self::Connect => f.write_str("connect"),
245 Self::Custom(name) => f.write_str(name),
246 }
247 }
248}
249
250impl FromStr for Provider {
251 type Err = ParseError;
252
253 fn from_str(value: &str) -> Result<Self, Self::Err> {
254 let value = value.trim();
255 if value.is_empty() || value.contains(':') || value.contains(',') {
256 return Err(ParseError::new(format!("invalid provider: {value}")));
257 }
258 Ok(match value.to_ascii_lowercase().as_str() {
259 "openrouter" => Self::OpenRouter,
260 "ollama" => Self::Ollama,
261 "anthropic" => Self::Anthropic,
262 "openai" => Self::OpenAi,
263 "mistral" => Self::Mistral,
264 "connect" => Self::Connect,
265 _ => Self::Custom(value.to_string()),
266 })
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
278pub struct AliasedModel {
279 pub name: String,
281 pub real_name: Option<String>,
283}
284
285impl AliasedModel {
286 pub fn capability_name(&self) -> &str {
288 self.real_name.as_deref().unwrap_or(&self.name)
289 }
290}
291
292impl FromStr for AliasedModel {
293 type Err = ParseError;
294
295 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
296 let (name, real_name) = if let Some((alias, real)) = s.split_once(',') {
297 (alias.trim().to_string(), Some(real.trim().to_string()))
298 } else {
299 (s.trim().to_string(), None)
300 };
301 if name.is_empty() {
302 return Err(ParseError::new("model name cannot be empty"));
303 }
304 if matches!(&real_name, Some(real) if real.is_empty()) {
305 return Err(ParseError::new("real model name cannot be empty"));
306 }
307 Ok(AliasedModel { name, real_name })
308 }
309}
310
311impl fmt::Display for AliasedModel {
312 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313 match &self.real_name {
314 Some(real) => write!(f, "{},{}", self.name, real),
315 None => write!(f, "{}", self.name),
316 }
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
325pub struct Model {
326 pub provider: Provider,
327 pub model: AliasedModel,
328}
329
330impl fmt::Display for Model {
331 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332 write!(f, "{}:{}", self.provider, self.model)
333 }
334}
335
336impl FromStr for Model {
337 type Err = ParseError;
338
339 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
340 let (provider_str, model_part) = s.split_once(':').ok_or_else(|| {
341 ParseError::new(format!(
342 "Invalid model format '{}': expected 'provider:name'",
343 s
344 ))
345 })?;
346 let provider: Provider = provider_str.parse().map_err(|_| {
347 ParseError::new(format!(
348 "Unknown provider '{}' in model string",
349 provider_str
350 ))
351 })?;
352
353 let model: AliasedModel = model_part.parse()?;
354
355 Ok(Model { provider, model })
356 }
357}
358
359impl Model {
360 pub fn name(&self) -> &str {
362 &self.model.name
363 }
364
365 pub fn capability_name(&self) -> &str {
368 self.model.capability_name()
369 }
370}
371
372#[derive(Debug, Clone, Default, Serialize, Deserialize)]
377pub struct SamplingParams {
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub temperature: Option<Decimal>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub top_p: Option<Decimal>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub top_k: Option<u32>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub min_p: Option<Decimal>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub presence_penalty: Option<Decimal>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub repetition_penalty: Option<Decimal>,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(rename_all = "lowercase")]
405pub enum Quantization {
406 Bf16,
407 Fp16,
408 Fp32,
409 Fp8,
410 Int4,
411 Int8,
412 Unknown,
414 #[serde(untagged)]
416 Other(String),
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "lowercase")]
422pub enum ProviderSort {
423 Price,
424 Throughput,
425 Latency,
426}
427
428#[derive(Debug, Clone, Default, Serialize, Deserialize)]
433pub struct ProviderPreferences {
434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
436 pub order: Vec<String>,
437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 pub quantizations: Vec<Quantization>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub allow_fallbacks: Option<bool>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub require_parameters: Option<bool>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub data_collection: Option<String>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub zdr: Option<bool>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub sort: Option<ProviderSort>,
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub ignore: Vec<String>,
458}
459
460impl SamplingParams {
461 pub fn merge(&self, other: &Self) -> Self {
469 Self {
470 temperature: other.temperature.or(self.temperature),
471 top_p: other.top_p.or(self.top_p),
472 top_k: other.top_k.or(self.top_k),
473 min_p: other.min_p.or(self.min_p),
474 presence_penalty: other.presence_penalty.or(self.presence_penalty),
475 repetition_penalty: other.repetition_penalty.or(self.repetition_penalty),
476 }
477 }
478}
479
480impl fmt::Display for SamplingParams {
481 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482 let mut params = Vec::new();
483 if let Some(t) = self.temperature {
484 params.push(format!("t={}", t));
485 }
486 if let Some(top_p) = self.top_p {
487 params.push(format!("top_p={}", top_p));
488 }
489 if let Some(top_k) = self.top_k {
490 params.push(format!("top_k={}", top_k));
491 }
492 if let Some(min_p) = self.min_p {
493 params.push(format!("min_p={}", min_p));
494 }
495 if let Some(pp) = self.presence_penalty {
496 params.push(format!("p_penalty={}", pp));
497 }
498 if let Some(rp) = self.repetition_penalty {
499 params.push(format!("r_penalty={}", rp));
500 }
501 if params.is_empty() {
502 write!(f, "(none)")
503 } else {
504 write!(f, "{}", params.join(", "))
505 }
506 }
507}
508
509#[derive(Debug, Clone)]
514pub struct AgentLlmConfig {
515 pub model: Model,
517 pub max_tokens: Option<u32>,
519 pub reasoning: ReasoningLevel,
521 pub sampling: SamplingParams,
523 pub user: Option<String>,
525 pub provider_preferences: Option<ProviderPreferences>,
527 pub session_id: Option<SessionId>,
529}
530
531impl AgentLlmConfig {
532 pub fn new(model: Model) -> Self {
534 Self {
535 model,
536 max_tokens: None,
537 reasoning: ReasoningLevel::Off,
538 sampling: SamplingParams::default(),
539 user: None,
540 provider_preferences: None,
541 session_id: None,
542 }
543 }
544
545 pub fn with_user(mut self, user: impl Into<String>) -> Self {
547 self.user = Some(user.into());
548 self
549 }
550
551 pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
553 self.reasoning = level;
554 self
555 }
556
557 pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
559 self.provider_preferences = Some(prefs);
560 self
561 }
562
563 pub fn with_session_id(mut self, session_id: impl Into<SessionId>) -> Self {
565 self.session_id = Some(session_id.into());
566 self
567 }
568
569 pub fn with_model(&self, model: Model) -> Self {
571 Self {
572 model,
573 max_tokens: self.max_tokens,
574 reasoning: self.reasoning,
575 sampling: self.sampling.clone(),
576 user: self.user.clone(),
577 provider_preferences: self.provider_preferences.clone(),
578 session_id: self.session_id.clone(),
579 }
580 }
581}
582
583impl fmt::Display for AgentLlmConfig {
584 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
585 write!(f, "{}", self.sampling)
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use rust_decimal_macros::dec;
593
594 #[test]
595 fn api_key_contract() {
596 use strum::VariantArray;
597
598 let expected = [
600 (
601 ApiKey::OpenRouter,
602 "openrouter",
603 "OPENROUTER_API_KEY",
604 &[][..],
605 "OpenRouter",
606 ),
607 (
608 ApiKey::Anthropic,
609 "anthropic",
610 "ANTHROPIC_API_KEY",
611 &[][..],
612 "Anthropic",
613 ),
614 (
615 ApiKey::OpenAi,
616 "openai",
617 "OPENAI_API_KEY",
618 &[][..],
619 "OpenAI",
620 ),
621 (
622 ApiKey::Mistral,
623 "mistral",
624 "MISTRAL_API_KEY",
625 &[][..],
626 "Mistral",
627 ),
628 (
629 ApiKey::ElevenLabs,
630 "elevenlabs",
631 "ELEVENLABS_API_KEY",
632 &[][..],
633 "ElevenLabs",
634 ),
635 (ApiKey::Meshy, "meshy", "MESHY_API_KEY", &[][..], "Meshy"),
636 (ApiKey::Tripo, "tripo", "TRIPO_API_KEY", &[][..], "Tripo"),
637 (
638 ApiKey::Gemini,
639 "gemini",
640 "GEMINI_API_KEY",
641 &["GOOGLE_API_KEY"][..],
642 "Gemini",
643 ),
644 (ApiKey::Fal, "fal", "FAL_KEY", &[][..], "Fal"),
645 (
646 ApiKey::Moonshot,
647 "moonshot",
648 "MOONSHOT_API_KEY",
649 &[][..],
650 "Moonshot",
651 ),
652 (ApiKey::Zai, "zai", "ZAI_API_KEY", &[][..], "Z.ai"),
653 (
654 ApiKey::KimiCode,
655 "kimi-code",
656 "KIMI_CODE_API_KEY",
657 &[][..],
658 "Kimi Code",
659 ),
660 (
661 ApiKey::Firecrawl,
662 "firecrawl",
663 "FIRECRAWL_API_KEY",
664 &[][..],
665 "Firecrawl",
666 ),
667 ];
668 assert_eq!(ApiKey::VARIANTS.len(), expected.len());
669 for (key, slug, env, aliases, name) in expected {
670 assert_eq!(key.to_string(), slug);
671 assert_eq!(slug.parse::<ApiKey>().unwrap(), key);
672 assert_eq!(serde_json::to_value(key).unwrap(), slug);
673 assert_eq!(
674 serde_json::from_value::<ApiKey>(serde_json::json!(slug)).unwrap(),
675 key
676 );
677 assert_eq!(key.env_var(), env);
678 assert_eq!(key.env_aliases(), aliases);
679 assert_eq!(key.human_readable_name(), name);
680 assert!(ApiKey::VARIANTS.contains(&key));
681 }
682 }
683
684 #[test]
685 fn api_key_environment_lookup_preserves_precedence_and_empty_values() {
686 for primary in [Some("primary"), Some(""), None] {
687 let mut names = Vec::new();
688 let value = ApiKey::Gemini.read_env_with(|name| {
689 names.push(name.to_owned());
690 match name {
691 "GEMINI_API_KEY" => primary.map(str::to_owned),
692 "GOOGLE_API_KEY" => Some("alias".to_owned()),
693 _ => panic!("unexpected environment variable"),
694 }
695 });
696 assert_eq!(value.as_deref(), Some(primary.unwrap_or("alias")));
697 assert_eq!(names.len(), if primary.is_some() { 1 } else { 2 });
698 }
699 for key in <ApiKey as strum::VariantArray>::VARIANTS {
700 assert_eq!(key.read_env_with(|_| None), None);
701 let value = key.read_env_with(|name| {
702 assert_eq!(name, key.env_var());
703 Some("example-key".to_owned())
704 });
705 assert_eq!(value.as_deref(), Some("example-key"));
706 }
707 }
708
709 #[test]
710 fn model_strings_keep_their_serde_format_and_validation() {
711 for raw in ["example-model", "fast,example-model"] {
712 let model: AliasedModel = raw.parse().unwrap();
713 assert_eq!(serde_json::to_value(&model).unwrap(), raw);
714 assert_eq!(
715 serde_json::from_value::<AliasedModel>(serde_json::json!(raw)).unwrap(),
716 model
717 );
718 }
719 for raw in ["openai:example-model", "my-provider:fast,example-model"] {
720 let model: Model = raw.parse().unwrap();
721 assert_eq!(serde_json::to_value(&model).unwrap(), raw);
722 assert_eq!(
723 serde_json::from_value::<Model>(serde_json::json!(raw)).unwrap(),
724 model
725 );
726 }
727 for raw in ["", ",example-model", "fast,"] {
728 assert!(serde_json::from_value::<AliasedModel>(serde_json::json!(raw)).is_err());
729 }
730 for raw in [
731 "",
732 "example-model",
733 "openai:",
734 ":example-model",
735 "openai:fast,",
736 ] {
737 assert!(serde_json::from_value::<Model>(serde_json::json!(raw)).is_err());
738 }
739 }
740
741 #[test]
742 fn providers_serialize_as_names_and_deserialize_through_the_parser() {
743 for provider in [
744 Provider::OpenRouter,
745 Provider::Ollama,
746 Provider::Anthropic,
747 Provider::OpenAi,
748 Provider::Mistral,
749 Provider::Connect,
750 Provider::Custom("my-provider".into()),
751 Provider::Custom("MyProvider".into()),
752 ] {
753 let value = serde_json::to_value(&provider).unwrap();
754 assert_eq!(value, provider.to_string());
755 assert_eq!(serde_json::from_value::<Provider>(value).unwrap(), provider);
756 }
757 assert_eq!(
758 serde_json::from_value::<Provider>(serde_json::json!(" OpenAI ")).unwrap(),
759 Provider::OpenAi
760 );
761 for raw in ["", " ", "invalid:provider", "invalid,provider"] {
762 assert!(serde_json::from_value::<Provider>(serde_json::json!(raw)).is_err());
763 }
764 }
765
766 #[test]
767 fn parse_model_round_trip() {
768 let model: Model = "openrouter:anthropic/claude-sonnet-4".parse().unwrap();
769 assert_eq!(model.provider, Provider::OpenRouter);
770 assert_eq!(model.model.name, "anthropic/claude-sonnet-4");
771 assert_eq!(model.to_string(), "openrouter:anthropic/claude-sonnet-4");
772 }
773
774 #[test]
775 fn parse_aliased_model() {
776 let aliased: AliasedModel = "fast, gpt-4.1".parse().unwrap();
777 assert_eq!(aliased.name, "fast");
778 assert_eq!(aliased.real_name.as_deref(), Some("gpt-4.1"));
779 assert_eq!(aliased.capability_name(), "gpt-4.1");
780 }
781
782 #[test]
783 fn parse_model_rejects_empty_names() {
784 assert!("ollama:".parse::<Model>().is_err());
785 assert!("ollama:alias,".parse::<Model>().is_err());
786 assert!("ollama:,real".parse::<Model>().is_err());
787 }
788
789 #[test]
790 fn parse_model_invalid_format() {
791 let result = "no-colon-here".parse::<Model>();
792 assert!(result.is_err());
793 }
794
795 #[test]
796 fn mistral_model_strings_pick_the_mistral_provider() {
797 let model: Model = "mistral:mistral-medium-3-5".parse().unwrap();
798 assert_eq!(model.provider, Provider::Mistral);
799 assert_eq!(model.name(), "mistral-medium-3-5");
800 assert_eq!(model.to_string(), "mistral:mistral-medium-3-5");
801 assert_eq!("Mistral".parse::<Provider>().unwrap(), Provider::Mistral);
802 let json = serde_json::to_string(&Provider::Mistral).unwrap();
803 assert_eq!(json, "\"mistral\"");
804 }
805
806 #[test]
807 fn parse_model_unknown_provider() {
808 let model: Model = "foobar:some-model".parse().unwrap();
809 assert_eq!(model.provider, Provider::Custom("foobar".into()));
810 }
811
812 #[test]
813 fn custom_provider_round_trips() {
814 let provider: Provider = "my-provider".parse().unwrap();
815 assert_eq!(provider.to_string(), "my-provider");
816 assert_eq!(provider, Provider::Custom("my-provider".into()));
817 }
818
819 #[test]
820 fn sampling_params_merge() {
821 let base = SamplingParams {
822 top_p: Some(dec!(0.9)),
823 top_k: Some(40),
824 ..Default::default()
825 };
826 let overlay = SamplingParams {
827 top_p: Some(dec!(0.8)),
828 min_p: Some(dec!(0.05)),
829 ..Default::default()
830 };
831 let merged = base.merge(&overlay);
832 assert_eq!(merged.top_p, Some(dec!(0.8)));
833 assert_eq!(merged.top_k, Some(40));
834 assert_eq!(merged.min_p, Some(dec!(0.05)));
835 }
836
837 #[test]
838 fn sampling_params_display_empty() {
839 let params = SamplingParams::default();
840 assert_eq!(params.to_string(), "(none)");
841 }
842
843 #[test]
844 fn agent_llm_config_display() {
845 let config = AgentLlmConfig {
846 model: "ollama:qwen3:8b".parse().unwrap(),
847 max_tokens: None,
848 reasoning: ReasoningLevel::Off,
849 sampling: SamplingParams {
850 temperature: Some(dec!(0.7)),
851 top_p: Some(dec!(0.9)),
852 ..Default::default()
853 },
854 user: None,
855 provider_preferences: None,
856 session_id: None,
857 };
858 let display = config.to_string();
859 assert!(display.contains("t=0.7"));
860 assert!(display.contains("top_p=0.9"));
861 }
862
863 #[test]
864 fn agent_llm_config_with_model() {
865 let config = AgentLlmConfig {
866 model: "ollama:test".parse().unwrap(),
867 max_tokens: Some(1000),
868 reasoning: ReasoningLevel::Medium,
869 sampling: SamplingParams {
870 temperature: Some(dec!(0.5)),
871 ..Default::default()
872 },
873 user: None,
874 provider_preferences: None,
875 session_id: Some("example-session".parse().unwrap()),
876 };
877 let new_model: Model = "anthropic:claude-sonnet-4-20250514".parse().unwrap();
878 let updated = config.with_model(new_model.clone());
879 assert_eq!(updated.model, new_model);
880 assert_eq!(updated.sampling.temperature, Some(dec!(0.5)));
881 assert_eq!(updated.max_tokens, Some(1000));
882 assert_eq!(
883 updated.session_id.as_ref().map(SessionId::as_str),
884 Some("example-session")
885 );
886 }
887
888 #[test]
889 fn provider_default_models() {
890 assert!(!Provider::Ollama.default_model().is_empty());
891 assert!(!Provider::OpenRouter.default_model().is_empty());
892 assert!(!Provider::Anthropic.default_model().is_empty());
893 assert!(!Provider::OpenAi.default_model().is_empty());
894 }
895
896 #[test]
897 fn reasoning_level_variants() {
898 assert!(!ReasoningLevel::Off.is_on());
899 assert!(ReasoningLevel::Low.is_on());
900 assert!(ReasoningLevel::Medium.is_on());
901 assert!(ReasoningLevel::High.is_on());
902 }
903
904 #[test]
905 fn reasoning_level_on_deserialises_as_medium() {
906 let level: ReasoningLevel = serde_json::from_str(r#""on""#).unwrap();
907 assert_eq!(level, ReasoningLevel::Medium);
908 }
909
910 #[test]
911 fn model_serde_round_trip() {
912 let model: Model = "ollama:llama3.2".parse().unwrap();
913 let json = serde_json::to_string(&model).unwrap();
914 let deserialized: Model = serde_json::from_str(&json).unwrap();
915 assert_eq!(model, deserialized);
916 }
917
918 #[test]
919 fn unknown_quantization_round_trip() {
920 let quantization: Quantization = serde_json::from_str(r#""unknown""#).unwrap();
921 assert_eq!(quantization, Quantization::Unknown);
922 assert_eq!(
923 serde_json::to_string(&quantization).unwrap(),
924 r#""unknown""#
925 );
926
927 let quantization: Quantization = serde_json::from_str(r#""mxfp8""#).unwrap();
928 assert_eq!(quantization, Quantization::Other("mxfp8".into()));
929 }
930}