Skip to main content

flashmind_types/
model.rs

1//! Model identifiers, provider variants, and sampling parameters.
2//!
3//! The [`Model`] type parses from a `"provider:model-name"` string via `FromStr`
4//! (e.g., `"openrouter:anthropic/claude-sonnet-4"`) and serialises back to the same format.
5//! Aliased models (`"fast,gpt-4.1"`) let users define shorthand names that resolve
6//! to real model IDs at API-call time.
7//!
8//! # Key types
9//!
10//! | Type | Role |
11//! |------|------|
12//! | [`Model`] | Fully-qualified model ID (`provider:name`); parses bidirectionally |
13//! | [`Provider`] | LLM backend variant (OpenRouter, Ollama, Anthropic, OpenAI, Connect) |
14//! | [`AliasedModel`] | Model name with optional aliasing (`name,real_name`) |
15//! | [`ReasoningLevel`] | Extended chain-of-thought effort (Off / Low / Medium / High) |
16//! | [`SamplingParams`] | Extended sampling config (top_p, top_k, min_p, penalties) |
17//! | [`AgentLlmConfig`] | Complete LLM config for an agent turn (model, temp, reasoning, sampling) |
18
19use 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/// Extended chain-of-thought / reasoning effort level.
29///
30/// Controls how much "thinking" budget the model gets. Not all providers
31/// support granular levels; when unsupported, any non-[`Off`](Self::Off)
32/// variant is treated as reasoning-enabled with a provider-chosen budget.
33///
34/// `"on"` deserialises as [`Medium`](Self::Medium) for backwards compatibility.
35#[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    /// Reasoning/thinking is disabled. The model responds directly.
53    Off,
54    /// Minimal reasoning budget.
55    Low,
56    /// Moderate reasoning budget (default when reasoning is simply "enabled").
57    #[serde(alias = "on")]
58    Medium,
59    /// Maximum reasoning budget.
60    High,
61}
62
63impl ReasoningLevel {
64    /// Returns `true` for any level that enables reasoning.
65    pub fn is_on(&self) -> bool {
66        !matches!(self, Self::Off)
67    }
68}
69
70/// LLM backend variant. Used to route completion requests and select the
71/// correct [`crate::llm::LlmProvider`] implementation from the registry.
72#[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's API (`https://api.mistral.ai`).
81    Mistral,
82    /// Proxy to a remote Flash connect server (client-side use).
83    Connect,
84    /// A provider implemented by an application or third-party crate.
85    ///
86    /// Custom identifiers round-trip through model configuration, so providers
87    /// do not need to claim compatibility with a built-in backend.
88    Custom(String),
89}
90
91impl Provider {
92    /// Hard-coded default model ID used when none is configured.
93    ///
94    /// | Provider     | Default Model                       |
95    /// |--------------|-------------------------------------|
96    /// | `OpenRouter` | `anthropic/claude-sonnet-4`         |
97    /// | `Ollama`     | `qwen3:8b`                          |
98    /// | `Anthropic`  | `claude-sonnet-4-20250514`          |
99    /// | `OpenAi`     | `gpt-4.1`                           |
100    /// | `Mistral`    | `mistral-medium-latest`             |
101    /// | `Connect`    | `flashone-229b`                     |
102    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/// A model name with optional aliasing.
150///
151/// Strings like `"fast-gpt4, gpt-4.1"` parse to an `AliasedModel` where:
152/// - `name = "fast-gpt4"` — used in config and display
153/// - `real_name = Some("gpt-4.1")` — resolved when building API requests
154///
155/// If no comma is present, `real_name` is `None` and both fields are identical.
156#[derive(Debug, Clone, PartialEq, Eq, Hash)]
157pub struct AliasedModel {
158    /// Display / config name (may be a user-defined alias).
159    pub name: String,
160    /// Actual model ID sent to the API if this is an alias, otherwise `None`.
161    pub real_name: Option<String>,
162}
163
164impl AliasedModel {
165    /// The name used when probing provider capabilities (e.g. context window lookup).
166    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/// Fully-qualified model identifier combining a backend [`Provider`] with an [`AliasedModel`].
218///
219/// String format: `"provider:name"` (e.g. `"openrouter:anthropic/claude-sonnet-4-20250514"`).
220/// Parses bidirectionally via `FromStr` / `Display`.
221#[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    /// Returns the display/config name of the model.
276    pub fn name(&self) -> &str {
277        &self.model.name
278    }
279
280    /// Returns the resolved name used for capability lookups. If this is an alias,
281    /// returns the real underlying model name; otherwise returns the display name.
282    pub fn capability_name(&self) -> &str {
283        self.model.capability_name()
284    }
285}
286
287/// Extended sampling parameters beyond just temperature.
288///
289/// All fields are optional; unset fields use the model's default.
290/// Serialises to a flat TOML table. Merge via [`merge`](Self::merge).
291#[derive(Debug, Clone, Default, Serialize, Deserialize)]
292pub struct SamplingParams {
293    /// Sampling temperature (0.0–2.0). Lower values make output more deterministic; higher values increase creativity.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub temperature: Option<Decimal>,
296    /// Nucleus sampling threshold (0.0–1.0). Only tokens within the top-p probability mass are considered.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub top_p: Option<Decimal>,
299    /// Hard cutoff: only sample from the K most likely next tokens.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub top_k: Option<u32>,
302    /// Dynamic minimum probability filter (0.0–1.0). Tokens below this absolute probability are excluded.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub min_p: Option<Decimal>,
305    /// Additive penalty for tokens already present in the output (-2.0 to 2.0). Positive values discourage repetition of seen tokens.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub presence_penalty: Option<Decimal>,
308    /// Multiplicative penalty on repeated tokens (1.0–2.0). Values above 1.0 make repeated tokens less likely.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub repetition_penalty: Option<Decimal>,
311}
312
313// ---------------------------------------------------------------------------
314// Provider Preferences
315// ---------------------------------------------------------------------------
316
317/// Quantization level for model weights.
318#[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    /// The provider does not disclose the quantization level.
328    Unknown,
329    /// Catch-all for additional quantization levels.
330    #[serde(untagged)]
331    Other(String),
332}
333
334/// How to sort provider candidates.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "lowercase")]
337pub enum ProviderSort {
338    Price,
339    Throughput,
340    Latency,
341}
342
343/// Provider-level routing preferences (OpenRouter extension).
344///
345/// Controls which inference providers are selected and how they are ranked.
346/// See <https://openrouter.ai/docs/features/provider-routing>.
347#[derive(Debug, Clone, Default, Serialize, Deserialize)]
348pub struct ProviderPreferences {
349    /// Preferred provider ordering (e.g. `["DeepInfra", "Together"]`).
350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
351    pub order: Vec<String>,
352    /// Allowed quantization levels. Empty means no filter.
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub quantizations: Vec<Quantization>,
355    /// Whether to fall back to other providers if preferred ones are unavailable.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub allow_fallbacks: Option<bool>,
358    /// Only route to providers that support all request parameters.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub require_parameters: Option<bool>,
361    /// Opt out of provider-level data collection (`"deny"` to opt out).
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub data_collection: Option<String>,
364    /// Only route to endpoints with zero data retention.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub zdr: Option<bool>,
367    /// Sort providers by this criterion before selection.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub sort: Option<ProviderSort>,
370    /// Providers to exclude from routing.
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub ignore: Vec<String>,
373}
374
375impl SamplingParams {
376    /// Override `self` with values from `other`, keeping `self`'s value when
377    /// `other` has a field set to `None`. Useful for profile layering.
378    ///
379    /// The semantics are simple: for each field, if `other` has a `Some` value it
380    /// wins; otherwise `self`'s value (if any) is retained. This makes it ideal
381    /// for stacking an overlay profile on top of a base profile — the overlay
382    /// only changes what it explicitly sets.
383    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/// Complete LLM configuration for an agent turn.
425///
426/// Passed to [`LlmProvider::complete`](crate::llm::LlmProvider::complete)
427/// and cloned into each `Started` event so listeners know the current config.
428#[derive(Debug, Clone)]
429pub struct AgentLlmConfig {
430    /// The model to use for this turn (includes provider prefix).
431    pub model: Model,
432    /// Maximum output tokens. If `None`, the provider decides.
433    pub max_tokens: Option<u32>,
434    /// Whether reasoning/thinking mode is enabled.
435    pub reasoning: ReasoningLevel,
436    /// Extended sampling parameters (temperature, top_p, penalties).
437    pub sampling: SamplingParams,
438    /// Opaque user identifier for per-user tracking in provider dashboards.
439    pub user: Option<String>,
440    /// Provider routing preferences (used by OpenRouter).
441    pub provider_preferences: Option<ProviderPreferences>,
442    /// Stable conversation identifier for provider cache routing.
443    pub session_id: Option<SessionId>,
444}
445
446impl AgentLlmConfig {
447    /// Create a default LLM configuration. Use `with_model()` to set the model.
448    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    /// Set the user identifier for per-user tracking in provider dashboards.
461    pub fn with_user(mut self, user: impl Into<String>) -> Self {
462        self.user = Some(user.into());
463        self
464    }
465
466    /// Set the reasoning/thinking effort level.
467    pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
468        self.reasoning = level;
469        self
470    }
471
472    /// Set provider routing preferences (used by OpenRouter).
473    pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
474        self.provider_preferences = Some(prefs);
475        self
476    }
477
478    /// Set the stable conversation identifier used for provider cache routing.
479    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    /// Swap in a different model while preserving all other parameters.
485    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}