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};
24use serde_with::{DeserializeFromStr, SerializeDisplay};
25
26use crate::error::ParseError;
27use crate::session::SessionId;
28
29/// Extended chain-of-thought / reasoning effort level.
30///
31/// Controls how much "thinking" budget the model gets. Not all providers
32/// support granular levels; when unsupported, any non-[`Off`](Self::Off)
33/// variant is treated as reasoning-enabled with a provider-chosen budget.
34///
35/// `"on"` deserialises as [`Medium`](Self::Medium) for backwards compatibility.
36#[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    /// Reasoning/thinking is disabled. The model responds directly.
54    Off,
55    /// Minimal reasoning budget.
56    Low,
57    /// Moderate reasoning budget (default when reasoning is simply "enabled").
58    #[serde(alias = "on")]
59    Medium,
60    /// Maximum reasoning budget.
61    High,
62}
63
64impl ReasoningLevel {
65    /// Returns `true` for any level that enables reasoning.
66    pub fn is_on(&self) -> bool {
67        !matches!(self, Self::Off)
68    }
69}
70
71/// An API key identifier, not a credential value.
72///
73/// Serialized slugs are a public storage contract. Environment aliases are
74/// checked only when the primary variable is absent or is not valid Unicode.
75#[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    /// OpenRouter API key.
90    #[strum(serialize = "openrouter")]
91    OpenRouter,
92    /// Anthropic API key.
93    #[strum(serialize = "anthropic")]
94    Anthropic,
95    /// OpenAI API key.
96    #[strum(serialize = "openai")]
97    OpenAi,
98    /// Mistral API key.
99    #[strum(serialize = "mistral")]
100    Mistral,
101    /// ElevenLabs API key.
102    #[strum(serialize = "elevenlabs")]
103    ElevenLabs,
104    /// Meshy API key.
105    #[strum(serialize = "meshy")]
106    Meshy,
107    /// Tripo API key.
108    #[strum(serialize = "tripo")]
109    Tripo,
110    /// Gemini API key.
111    #[strum(serialize = "gemini")]
112    Gemini,
113    /// Fal API key.
114    #[strum(serialize = "fal")]
115    Fal,
116    /// Moonshot API key.
117    #[strum(serialize = "moonshot")]
118    Moonshot,
119    /// Z.ai API key.
120    #[strum(serialize = "zai")]
121    Zai,
122    /// Kimi Code API key.
123    #[strum(serialize = "kimi-code")]
124    KimiCode,
125    /// Firecrawl API key.
126    #[strum(serialize = "firecrawl")]
127    Firecrawl,
128}
129
130impl ApiKey {
131    /// Primary environment variable.
132    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    /// Accepted environment aliases, in lookup order.
151    pub const fn env_aliases(self) -> &'static [&'static str] {
152        match self {
153            Self::Gemini => &["GOOGLE_API_KEY"],
154            _ => &[],
155        }
156    }
157
158    /// Name shown to users.
159    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    /// Read the primary variable, then aliases, retaining empty values.
178    ///
179    /// Callers decide whether an empty value is usable. An empty primary value
180    /// prevents alias lookup, just as a successful environment lookup does.
181    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/// LLM backend variant. Used to route completion requests and select the
193/// correct [`crate::llm::LlmProvider`] implementation from the registry.
194#[derive(Debug, Clone, SerializeDisplay, DeserializeFromStr, PartialEq, Eq, Hash, Default)]
195pub enum Provider {
196    OpenRouter,
197    #[default]
198    Ollama,
199    Anthropic,
200    OpenAi,
201    /// Mistral's API (`https://api.mistral.ai`).
202    Mistral,
203    /// Proxy to a remote Flash connect server (client-side use).
204    Connect,
205    /// A provider implemented by an application or third-party crate.
206    ///
207    /// Custom identifiers round-trip through model configuration, so providers
208    /// do not need to claim compatibility with a built-in backend.
209    Custom(String),
210}
211
212impl Provider {
213    /// Hard-coded default model ID used when none is configured.
214    ///
215    /// | Provider     | Default Model                       |
216    /// |--------------|-------------------------------------|
217    /// | `OpenRouter` | `anthropic/claude-sonnet-4`         |
218    /// | `Ollama`     | `qwen3:8b`                          |
219    /// | `Anthropic`  | `claude-sonnet-4-20250514`          |
220    /// | `OpenAi`     | `gpt-4.1`                           |
221    /// | `Mistral`    | `mistral-medium-latest`             |
222    /// | `Connect`    | `flashone-229b`                     |
223    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/// A model name with optional aliasing.
271///
272/// Strings like `"fast-gpt4, gpt-4.1"` parse to an `AliasedModel` where:
273/// - `name = "fast-gpt4"` — used in config and display
274/// - `real_name = Some("gpt-4.1")` — resolved when building API requests
275///
276/// If no comma is present, `real_name` is `None` and both fields are identical.
277#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
278pub struct AliasedModel {
279    /// Display / config name (may be a user-defined alias).
280    pub name: String,
281    /// Actual model ID sent to the API if this is an alias, otherwise `None`.
282    pub real_name: Option<String>,
283}
284
285impl AliasedModel {
286    /// The name used when probing provider capabilities (e.g. context window lookup).
287    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/// Fully-qualified model identifier combining a backend [`Provider`] with an [`AliasedModel`].
321///
322/// String format: `"provider:name"` (e.g. `"openrouter:anthropic/claude-sonnet-4-20250514"`).
323/// Parses bidirectionally via `FromStr` / `Display`.
324#[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    /// Returns the display/config name of the model.
361    pub fn name(&self) -> &str {
362        &self.model.name
363    }
364
365    /// Returns the resolved name used for capability lookups. If this is an alias,
366    /// returns the real underlying model name; otherwise returns the display name.
367    pub fn capability_name(&self) -> &str {
368        self.model.capability_name()
369    }
370}
371
372/// Extended sampling parameters beyond just temperature.
373///
374/// All fields are optional; unset fields use the model's default.
375/// Serialises to a flat TOML table. Merge via [`merge`](Self::merge).
376#[derive(Debug, Clone, Default, Serialize, Deserialize)]
377pub struct SamplingParams {
378    /// Sampling temperature (0.0–2.0). Lower values make output more deterministic; higher values increase creativity.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub temperature: Option<Decimal>,
381    /// Nucleus sampling threshold (0.0–1.0). Only tokens within the top-p probability mass are considered.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub top_p: Option<Decimal>,
384    /// Hard cutoff: only sample from the K most likely next tokens.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub top_k: Option<u32>,
387    /// Dynamic minimum probability filter (0.0–1.0). Tokens below this absolute probability are excluded.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub min_p: Option<Decimal>,
390    /// Additive penalty for tokens already present in the output (-2.0 to 2.0). Positive values discourage repetition of seen tokens.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub presence_penalty: Option<Decimal>,
393    /// Multiplicative penalty on repeated tokens (1.0–2.0). Values above 1.0 make repeated tokens less likely.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub repetition_penalty: Option<Decimal>,
396}
397
398// ---------------------------------------------------------------------------
399// Provider Preferences
400// ---------------------------------------------------------------------------
401
402/// Quantization level for model weights.
403#[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    /// The provider does not disclose the quantization level.
413    Unknown,
414    /// Catch-all for additional quantization levels.
415    #[serde(untagged)]
416    Other(String),
417}
418
419/// How to sort provider candidates.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "lowercase")]
422pub enum ProviderSort {
423    Price,
424    Throughput,
425    Latency,
426}
427
428/// Provider-level routing preferences (OpenRouter extension).
429///
430/// Controls which inference providers are selected and how they are ranked.
431/// See <https://openrouter.ai/docs/features/provider-routing>.
432#[derive(Debug, Clone, Default, Serialize, Deserialize)]
433pub struct ProviderPreferences {
434    /// Preferred provider ordering (e.g. `["DeepInfra", "Together"]`).
435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
436    pub order: Vec<String>,
437    /// Allowed quantization levels. Empty means no filter.
438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
439    pub quantizations: Vec<Quantization>,
440    /// Whether to fall back to other providers if preferred ones are unavailable.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub allow_fallbacks: Option<bool>,
443    /// Only route to providers that support all request parameters.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub require_parameters: Option<bool>,
446    /// Opt out of provider-level data collection (`"deny"` to opt out).
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub data_collection: Option<String>,
449    /// Only route to endpoints with zero data retention.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub zdr: Option<bool>,
452    /// Sort providers by this criterion before selection.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub sort: Option<ProviderSort>,
455    /// Providers to exclude from routing.
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    pub ignore: Vec<String>,
458}
459
460impl SamplingParams {
461    /// Override `self` with values from `other`, keeping `self`'s value when
462    /// `other` has a field set to `None`. Useful for profile layering.
463    ///
464    /// The semantics are simple: for each field, if `other` has a `Some` value it
465    /// wins; otherwise `self`'s value (if any) is retained. This makes it ideal
466    /// for stacking an overlay profile on top of a base profile — the overlay
467    /// only changes what it explicitly sets.
468    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/// Complete LLM configuration for an agent turn.
510///
511/// Passed to [`LlmProvider::complete`](crate::llm::LlmProvider::complete)
512/// and cloned into each `Started` event so listeners know the current config.
513#[derive(Debug, Clone)]
514pub struct AgentLlmConfig {
515    /// The model to use for this turn (includes provider prefix).
516    pub model: Model,
517    /// Maximum output tokens. If `None`, the provider decides.
518    pub max_tokens: Option<u32>,
519    /// Whether reasoning/thinking mode is enabled.
520    pub reasoning: ReasoningLevel,
521    /// Extended sampling parameters (temperature, top_p, penalties).
522    pub sampling: SamplingParams,
523    /// Opaque user identifier for per-user tracking in provider dashboards.
524    pub user: Option<String>,
525    /// Provider routing preferences (used by OpenRouter).
526    pub provider_preferences: Option<ProviderPreferences>,
527    /// Stable conversation identifier for provider cache routing.
528    pub session_id: Option<SessionId>,
529}
530
531impl AgentLlmConfig {
532    /// Create a default LLM configuration. Use `with_model()` to set the model.
533    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    /// Set the user identifier for per-user tracking in provider dashboards.
546    pub fn with_user(mut self, user: impl Into<String>) -> Self {
547        self.user = Some(user.into());
548        self
549    }
550
551    /// Set the reasoning/thinking effort level.
552    pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
553        self.reasoning = level;
554        self
555    }
556
557    /// Set provider routing preferences (used by OpenRouter).
558    pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
559        self.provider_preferences = Some(prefs);
560        self
561    }
562
563    /// Set the stable conversation identifier used for provider cache routing.
564    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    /// Swap in a different model while preserving all other parameters.
570    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        // Keep this literal table independent of the implementation.
599        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}