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)]
47#[serde(rename_all = "lowercase")]
48#[strum(serialize_all = "lowercase")]
49pub enum ReasoningLevel {
50    #[default]
51    /// Reasoning/thinking is disabled. The model responds directly.
52    Off,
53    /// Minimal reasoning budget.
54    Low,
55    /// Moderate reasoning budget (default when reasoning is simply "enabled").
56    #[serde(alias = "on")]
57    Medium,
58    /// Maximum reasoning budget.
59    High,
60}
61
62impl ReasoningLevel {
63    /// Returns `true` for any level that enables reasoning.
64    pub fn is_on(&self) -> bool {
65        !matches!(self, Self::Off)
66    }
67}
68
69/// LLM backend variant. Used to route completion requests and select the
70/// correct [`crate::llm::LlmProvider`] implementation from the registry.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
72#[serde(rename_all = "lowercase")]
73pub enum Provider {
74    OpenRouter,
75    #[default]
76    Ollama,
77    Anthropic,
78    OpenAi,
79    /// Proxy to a remote Flash connect server (client-side use).
80    Connect,
81    /// A provider implemented by an application or third-party crate.
82    ///
83    /// Custom identifiers round-trip through model configuration, so providers
84    /// do not need to claim compatibility with a built-in backend.
85    Custom(String),
86}
87
88impl Provider {
89    /// Hard-coded default model ID used when none is configured.
90    ///
91    /// | Provider     | Default Model                       |
92    /// |--------------|-------------------------------------|
93    /// | `OpenRouter` | `anthropic/claude-sonnet-4`         |
94    /// | `Ollama`     | `qwen3:8b`                          |
95    /// | `Anthropic`  | `claude-sonnet-4-20250514`          |
96    /// | `OpenAi`     | `gpt-4.1`                           |
97    /// | `Connect`    | `flashone-229b`                     |
98    pub fn default_model(&self) -> &str {
99        match self {
100            Provider::OpenRouter => "anthropic/claude-sonnet-4",
101            Provider::Ollama => "qwen3:8b",
102            Provider::Anthropic => "claude-sonnet-4-20250514",
103            Provider::OpenAi => "gpt-4.1",
104            Provider::Connect => "flashone-229b",
105            Provider::Custom(_) => "default",
106        }
107    }
108}
109
110impl fmt::Display for Provider {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::OpenRouter => f.write_str("openrouter"),
114            Self::Ollama => f.write_str("ollama"),
115            Self::Anthropic => f.write_str("anthropic"),
116            Self::OpenAi => f.write_str("openai"),
117            Self::Connect => f.write_str("connect"),
118            Self::Custom(name) => f.write_str(name),
119        }
120    }
121}
122
123impl FromStr for Provider {
124    type Err = ParseError;
125
126    fn from_str(value: &str) -> Result<Self, Self::Err> {
127        let value = value.trim();
128        if value.is_empty() || value.contains(':') || value.contains(',') {
129            return Err(ParseError::new(format!("invalid provider: {value}")));
130        }
131        Ok(match value.to_ascii_lowercase().as_str() {
132            "openrouter" => Self::OpenRouter,
133            "ollama" => Self::Ollama,
134            "anthropic" => Self::Anthropic,
135            "openai" => Self::OpenAi,
136            "connect" => Self::Connect,
137            _ => Self::Custom(value.to_string()),
138        })
139    }
140}
141
142/// A model name with optional aliasing.
143///
144/// Strings like `"fast-gpt4, gpt-4.1"` parse to an `AliasedModel` where:
145/// - `name = "fast-gpt4"` — used in config and display
146/// - `real_name = Some("gpt-4.1")` — resolved when building API requests
147///
148/// If no comma is present, `real_name` is `None` and both fields are identical.
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct AliasedModel {
151    /// Display / config name (may be a user-defined alias).
152    pub name: String,
153    /// Actual model ID sent to the API if this is an alias, otherwise `None`.
154    pub real_name: Option<String>,
155}
156
157impl AliasedModel {
158    /// The name used when probing provider capabilities (e.g. context window lookup).
159    pub fn capability_name(&self) -> &str {
160        self.real_name.as_deref().unwrap_or(&self.name)
161    }
162}
163
164impl Serialize for AliasedModel {
165    fn serialize<S: serde::Serializer>(
166        &self,
167        serializer: S,
168    ) -> std::result::Result<S::Ok, S::Error> {
169        serializer.serialize_str(&self.to_string())
170    }
171}
172
173impl<'de> Deserialize<'de> for AliasedModel {
174    fn deserialize<D: serde::Deserializer<'de>>(
175        deserializer: D,
176    ) -> std::result::Result<Self, D::Error> {
177        let s = String::deserialize(deserializer)?;
178        s.parse().map_err(serde::de::Error::custom)
179    }
180}
181
182impl FromStr for AliasedModel {
183    type Err = ParseError;
184
185    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
186        let (name, real_name) = if let Some((alias, real)) = s.split_once(',') {
187            (alias.trim().to_string(), Some(real.trim().to_string()))
188        } else {
189            (s.trim().to_string(), None)
190        };
191        if name.is_empty() {
192            return Err(ParseError::new("model name cannot be empty"));
193        }
194        if matches!(&real_name, Some(real) if real.is_empty()) {
195            return Err(ParseError::new("real model name cannot be empty"));
196        }
197        Ok(AliasedModel { name, real_name })
198    }
199}
200
201impl fmt::Display for AliasedModel {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        match &self.real_name {
204            Some(real) => write!(f, "{},{}", self.name, real),
205            None => write!(f, "{}", self.name),
206        }
207    }
208}
209
210/// Fully-qualified model identifier combining a backend [`Provider`] with an [`AliasedModel`].
211///
212/// String format: `"provider:name"` (e.g. `"openrouter:anthropic/claude-sonnet-4-20250514"`).
213/// Parses bidirectionally via `FromStr` / `Display`.
214#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215pub struct Model {
216    pub provider: Provider,
217    pub model: AliasedModel,
218}
219
220impl Serialize for Model {
221    fn serialize<S: serde::Serializer>(
222        &self,
223        serializer: S,
224    ) -> std::result::Result<S::Ok, S::Error> {
225        serializer.serialize_str(&self.to_string())
226    }
227}
228
229impl<'de> Deserialize<'de> for Model {
230    fn deserialize<D: serde::Deserializer<'de>>(
231        deserializer: D,
232    ) -> std::result::Result<Self, D::Error> {
233        let s = String::deserialize(deserializer)?;
234        s.parse().map_err(serde::de::Error::custom)
235    }
236}
237
238impl fmt::Display for Model {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        write!(f, "{}:{}", self.provider, self.model)
241    }
242}
243
244impl FromStr for Model {
245    type Err = ParseError;
246
247    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
248        let (provider_str, model_part) = s.split_once(':').ok_or_else(|| {
249            ParseError::new(format!(
250                "Invalid model format '{}': expected 'provider:name'",
251                s
252            ))
253        })?;
254        let provider: Provider = provider_str.parse().map_err(|_| {
255            ParseError::new(format!(
256                "Unknown provider '{}' in model string",
257                provider_str
258            ))
259        })?;
260
261        let model: AliasedModel = model_part.parse()?;
262
263        Ok(Model { provider, model })
264    }
265}
266
267impl Model {
268    /// Returns the display/config name of the model.
269    pub fn name(&self) -> &str {
270        &self.model.name
271    }
272
273    /// Returns the resolved name used for capability lookups. If this is an alias,
274    /// returns the real underlying model name; otherwise returns the display name.
275    pub fn capability_name(&self) -> &str {
276        self.model.capability_name()
277    }
278}
279
280/// Extended sampling parameters beyond just temperature.
281///
282/// All fields are optional; unset fields use the model's default.
283/// Serialises to a flat TOML table. Merge via [`merge`](Self::merge).
284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
285pub struct SamplingParams {
286    /// Sampling temperature (0.0–2.0). Lower values make output more deterministic; higher values increase creativity.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub temperature: Option<Decimal>,
289    /// Nucleus sampling threshold (0.0–1.0). Only tokens within the top-p probability mass are considered.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub top_p: Option<Decimal>,
292    /// Hard cutoff: only sample from the K most likely next tokens.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub top_k: Option<u32>,
295    /// Dynamic minimum probability filter (0.0–1.0). Tokens below this absolute probability are excluded.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub min_p: Option<Decimal>,
298    /// Additive penalty for tokens already present in the output (-2.0 to 2.0). Positive values discourage repetition of seen tokens.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub presence_penalty: Option<Decimal>,
301    /// Multiplicative penalty on repeated tokens (1.0–2.0). Values above 1.0 make repeated tokens less likely.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub repetition_penalty: Option<Decimal>,
304}
305
306// ---------------------------------------------------------------------------
307// Provider Preferences
308// ---------------------------------------------------------------------------
309
310/// Quantization level for model weights.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "lowercase")]
313pub enum Quantization {
314    Bf16,
315    Fp16,
316    Fp32,
317    Fp8,
318    Int4,
319    Int8,
320    /// Catch-all for future/unknown quantization levels.
321    #[serde(untagged)]
322    Other(String),
323}
324
325/// How to sort provider candidates.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "lowercase")]
328pub enum ProviderSort {
329    Price,
330    Throughput,
331    Latency,
332}
333
334/// Provider-level routing preferences (OpenRouter extension).
335///
336/// Controls which inference providers are selected and how they are ranked.
337/// See <https://openrouter.ai/docs/features/provider-routing>.
338#[derive(Debug, Clone, Default, Serialize, Deserialize)]
339pub struct ProviderPreferences {
340    /// Preferred provider ordering (e.g. `["DeepInfra", "Together"]`).
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub order: Vec<String>,
343    /// Allowed quantization levels. Empty means no filter.
344    #[serde(default, skip_serializing_if = "Vec::is_empty")]
345    pub quantizations: Vec<Quantization>,
346    /// Whether to fall back to other providers if preferred ones are unavailable.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub allow_fallbacks: Option<bool>,
349    /// Only route to providers that support all request parameters.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub require_parameters: Option<bool>,
352    /// Opt out of provider-level data collection (`"deny"` to opt out).
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub data_collection: Option<String>,
355    /// Only route to endpoints with zero data retention.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub zdr: Option<bool>,
358    /// Sort providers by this criterion before selection.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub sort: Option<ProviderSort>,
361    /// Providers to exclude from routing.
362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
363    pub ignore: Vec<String>,
364}
365
366impl SamplingParams {
367    /// Override `self` with values from `other`, keeping `self`'s value when
368    /// `other` has a field set to `None`. Useful for profile layering.
369    ///
370    /// The semantics are simple: for each field, if `other` has a `Some` value it
371    /// wins; otherwise `self`'s value (if any) is retained. This makes it ideal
372    /// for stacking an overlay profile on top of a base profile — the overlay
373    /// only changes what it explicitly sets.
374    pub fn merge(&self, other: &Self) -> Self {
375        Self {
376            temperature: other.temperature.or(self.temperature),
377            top_p: other.top_p.or(self.top_p),
378            top_k: other.top_k.or(self.top_k),
379            min_p: other.min_p.or(self.min_p),
380            presence_penalty: other.presence_penalty.or(self.presence_penalty),
381            repetition_penalty: other.repetition_penalty.or(self.repetition_penalty),
382        }
383    }
384}
385
386impl fmt::Display for SamplingParams {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        let mut params = Vec::new();
389        if let Some(t) = self.temperature {
390            params.push(format!("t={}", t));
391        }
392        if let Some(top_p) = self.top_p {
393            params.push(format!("top_p={}", top_p));
394        }
395        if let Some(top_k) = self.top_k {
396            params.push(format!("top_k={}", top_k));
397        }
398        if let Some(min_p) = self.min_p {
399            params.push(format!("min_p={}", min_p));
400        }
401        if let Some(pp) = self.presence_penalty {
402            params.push(format!("p_penalty={}", pp));
403        }
404        if let Some(rp) = self.repetition_penalty {
405            params.push(format!("r_penalty={}", rp));
406        }
407        if params.is_empty() {
408            write!(f, "(none)")
409        } else {
410            write!(f, "{}", params.join(", "))
411        }
412    }
413}
414
415/// Complete LLM configuration for an agent turn.
416///
417/// Passed to [`LlmProvider::complete`](crate::llm::LlmProvider::complete)
418/// and cloned into each `Started` event so listeners know the current config.
419#[derive(Debug, Clone)]
420pub struct AgentLlmConfig {
421    /// The model to use for this turn (includes provider prefix).
422    pub model: Model,
423    /// Maximum output tokens. If `None`, the provider decides.
424    pub max_tokens: Option<u32>,
425    /// Whether reasoning/thinking mode is enabled.
426    pub reasoning: ReasoningLevel,
427    /// Extended sampling parameters (temperature, top_p, penalties).
428    pub sampling: SamplingParams,
429    /// Opaque user identifier for per-user tracking in provider dashboards.
430    pub user: Option<String>,
431    /// Provider routing preferences (used by OpenRouter).
432    pub provider_preferences: Option<ProviderPreferences>,
433    /// Stable conversation identifier for provider cache routing.
434    pub session_id: Option<SessionId>,
435}
436
437impl AgentLlmConfig {
438    /// Create a default LLM configuration. Use `with_model()` to set the model.
439    pub fn new(model: Model) -> Self {
440        Self {
441            model,
442            max_tokens: None,
443            reasoning: ReasoningLevel::Off,
444            sampling: SamplingParams::default(),
445            user: None,
446            provider_preferences: None,
447            session_id: None,
448        }
449    }
450
451    /// Set the user identifier for per-user tracking in provider dashboards.
452    pub fn with_user(mut self, user: impl Into<String>) -> Self {
453        self.user = Some(user.into());
454        self
455    }
456
457    /// Set the reasoning/thinking effort level.
458    pub fn with_reasoning(mut self, level: ReasoningLevel) -> Self {
459        self.reasoning = level;
460        self
461    }
462
463    /// Set provider routing preferences (used by OpenRouter).
464    pub fn with_provider_preferences(mut self, prefs: ProviderPreferences) -> Self {
465        self.provider_preferences = Some(prefs);
466        self
467    }
468
469    /// Set the stable conversation identifier used for provider cache routing.
470    pub fn with_session_id(mut self, session_id: impl Into<SessionId>) -> Self {
471        self.session_id = Some(session_id.into());
472        self
473    }
474
475    /// Swap in a different model while preserving all other parameters.
476    pub fn with_model(&self, model: Model) -> Self {
477        Self {
478            model,
479            max_tokens: self.max_tokens,
480            reasoning: self.reasoning,
481            sampling: self.sampling.clone(),
482            user: self.user.clone(),
483            provider_preferences: self.provider_preferences.clone(),
484            session_id: self.session_id.clone(),
485        }
486    }
487}
488
489impl fmt::Display for AgentLlmConfig {
490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491        write!(f, "{}", self.sampling)
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use rust_decimal_macros::dec;
499
500    #[test]
501    fn parse_model_round_trip() {
502        let model: Model = "openrouter:anthropic/claude-sonnet-4".parse().unwrap();
503        assert_eq!(model.provider, Provider::OpenRouter);
504        assert_eq!(model.model.name, "anthropic/claude-sonnet-4");
505        assert_eq!(model.to_string(), "openrouter:anthropic/claude-sonnet-4");
506    }
507
508    #[test]
509    fn parse_aliased_model() {
510        let aliased: AliasedModel = "fast, gpt-4.1".parse().unwrap();
511        assert_eq!(aliased.name, "fast");
512        assert_eq!(aliased.real_name.as_deref(), Some("gpt-4.1"));
513        assert_eq!(aliased.capability_name(), "gpt-4.1");
514    }
515
516    #[test]
517    fn parse_model_rejects_empty_names() {
518        assert!("ollama:".parse::<Model>().is_err());
519        assert!("ollama:alias,".parse::<Model>().is_err());
520        assert!("ollama:,real".parse::<Model>().is_err());
521    }
522
523    #[test]
524    fn parse_model_invalid_format() {
525        let result = "no-colon-here".parse::<Model>();
526        assert!(result.is_err());
527    }
528
529    #[test]
530    fn parse_model_unknown_provider() {
531        let model: Model = "foobar:some-model".parse().unwrap();
532        assert_eq!(model.provider, Provider::Custom("foobar".into()));
533    }
534
535    #[test]
536    fn custom_provider_round_trips() {
537        let provider: Provider = "my-provider".parse().unwrap();
538        assert_eq!(provider.to_string(), "my-provider");
539        assert_eq!(provider, Provider::Custom("my-provider".into()));
540    }
541
542    #[test]
543    fn sampling_params_merge() {
544        let base = SamplingParams {
545            top_p: Some(dec!(0.9)),
546            top_k: Some(40),
547            ..Default::default()
548        };
549        let overlay = SamplingParams {
550            top_p: Some(dec!(0.8)),
551            min_p: Some(dec!(0.05)),
552            ..Default::default()
553        };
554        let merged = base.merge(&overlay);
555        assert_eq!(merged.top_p, Some(dec!(0.8)));
556        assert_eq!(merged.top_k, Some(40));
557        assert_eq!(merged.min_p, Some(dec!(0.05)));
558    }
559
560    #[test]
561    fn sampling_params_display_empty() {
562        let params = SamplingParams::default();
563        assert_eq!(params.to_string(), "(none)");
564    }
565
566    #[test]
567    fn agent_llm_config_display() {
568        let config = AgentLlmConfig {
569            model: "ollama:qwen3:8b".parse().unwrap(),
570            max_tokens: None,
571            reasoning: ReasoningLevel::Off,
572            sampling: SamplingParams {
573                temperature: Some(dec!(0.7)),
574                top_p: Some(dec!(0.9)),
575                ..Default::default()
576            },
577            user: None,
578            provider_preferences: None,
579            session_id: None,
580        };
581        let display = config.to_string();
582        assert!(display.contains("t=0.7"));
583        assert!(display.contains("top_p=0.9"));
584    }
585
586    #[test]
587    fn agent_llm_config_with_model() {
588        let config = AgentLlmConfig {
589            model: "ollama:test".parse().unwrap(),
590            max_tokens: Some(1000),
591            reasoning: ReasoningLevel::Medium,
592            sampling: SamplingParams {
593                temperature: Some(dec!(0.5)),
594                ..Default::default()
595            },
596            user: None,
597            provider_preferences: None,
598            session_id: Some("example-session".parse().unwrap()),
599        };
600        let new_model: Model = "anthropic:claude-sonnet-4-20250514".parse().unwrap();
601        let updated = config.with_model(new_model.clone());
602        assert_eq!(updated.model, new_model);
603        assert_eq!(updated.sampling.temperature, Some(dec!(0.5)));
604        assert_eq!(updated.max_tokens, Some(1000));
605        assert_eq!(
606            updated.session_id.as_ref().map(SessionId::as_str),
607            Some("example-session")
608        );
609    }
610
611    #[test]
612    fn provider_default_models() {
613        assert!(!Provider::Ollama.default_model().is_empty());
614        assert!(!Provider::OpenRouter.default_model().is_empty());
615        assert!(!Provider::Anthropic.default_model().is_empty());
616        assert!(!Provider::OpenAi.default_model().is_empty());
617    }
618
619    #[test]
620    fn reasoning_level_variants() {
621        assert!(!ReasoningLevel::Off.is_on());
622        assert!(ReasoningLevel::Low.is_on());
623        assert!(ReasoningLevel::Medium.is_on());
624        assert!(ReasoningLevel::High.is_on());
625    }
626
627    #[test]
628    fn reasoning_level_on_deserialises_as_medium() {
629        let level: ReasoningLevel = serde_json::from_str(r#""on""#).unwrap();
630        assert_eq!(level, ReasoningLevel::Medium);
631    }
632
633    #[test]
634    fn model_serde_round_trip() {
635        let model: Model = "ollama:llama3.2".parse().unwrap();
636        let json = serde_json::to_string(&model).unwrap();
637        let deserialized: Model = serde_json::from_str(&json).unwrap();
638        assert_eq!(model, deserialized);
639    }
640}