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    /// Catch-all for future/unknown quantization levels.
328    #[serde(untagged)]
329    Other(String),
330}
331
332/// How to sort provider candidates.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(rename_all = "lowercase")]
335pub enum ProviderSort {
336    Price,
337    Throughput,
338    Latency,
339}
340
341/// Provider-level routing preferences (OpenRouter extension).
342///
343/// Controls which inference providers are selected and how they are ranked.
344/// See <https://openrouter.ai/docs/features/provider-routing>.
345#[derive(Debug, Clone, Default, Serialize, Deserialize)]
346pub struct ProviderPreferences {
347    /// Preferred provider ordering (e.g. `["DeepInfra", "Together"]`).
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub order: Vec<String>,
350    /// Allowed quantization levels. Empty means no filter.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub quantizations: Vec<Quantization>,
353    /// Whether to fall back to other providers if preferred ones are unavailable.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub allow_fallbacks: Option<bool>,
356    /// Only route to providers that support all request parameters.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub require_parameters: Option<bool>,
359    /// Opt out of provider-level data collection (`"deny"` to opt out).
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub data_collection: Option<String>,
362    /// Only route to endpoints with zero data retention.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub zdr: Option<bool>,
365    /// Sort providers by this criterion before selection.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub sort: Option<ProviderSort>,
368    /// Providers to exclude from routing.
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    pub ignore: Vec<String>,
371}
372
373impl SamplingParams {
374    /// Override `self` with values from `other`, keeping `self`'s value when
375    /// `other` has a field set to `None`. Useful for profile layering.
376    ///
377    /// The semantics are simple: for each field, if `other` has a `Some` value it
378    /// wins; otherwise `self`'s value (if any) is retained. This makes it ideal
379    /// for stacking an overlay profile on top of a base profile — the overlay
380    /// only changes what it explicitly sets.
381    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/// Complete LLM configuration for an agent turn.
423///
424/// Passed to [`LlmProvider::complete`](crate::llm::LlmProvider::complete)
425/// and cloned into each `Started` event so listeners know the current config.
426#[derive(Debug, Clone)]
427pub struct AgentLlmConfig {
428    /// The model to use for this turn (includes provider prefix).
429    pub model: Model,
430    /// Maximum output tokens. If `None`, the provider decides.
431    pub max_tokens: Option<u32>,
432    /// Whether reasoning/thinking mode is enabled.
433    pub reasoning: ReasoningLevel,
434    /// Extended sampling parameters (temperature, top_p, penalties).
435    pub sampling: SamplingParams,
436    /// Opaque user identifier for per-user tracking in provider dashboards.
437    pub user: Option<String>,
438    /// Provider routing preferences (used by OpenRouter).
439    pub provider_preferences: Option<ProviderPreferences>,
440    /// Stable conversation identifier for provider cache routing.
441    pub session_id: Option<SessionId>,
442}
443
444impl AgentLlmConfig {
445    /// Create a default LLM configuration. Use `with_model()` to set the model.
446    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    /// Set the user identifier for per-user tracking in provider dashboards.
459    pub fn with_user(mut self, user: impl Into<String>) -> Self {
460        self.user = Some(user.into());
461        self
462    }
463
464    /// Set the reasoning/thinking effort level.
465    pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
466        self.reasoning = level;
467        self
468    }
469
470    /// Set provider routing preferences (used by OpenRouter).
471    pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
472        self.provider_preferences = Some(prefs);
473        self
474    }
475
476    /// Set the stable conversation identifier used for provider cache routing.
477    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    /// Swap in a different model while preserving all other parameters.
483    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}