Skip to main content

mermaid_cli/models/
config.rs

1//! Unified configuration system for models and backends
2//!
3//! Replaces the fragmented app::Config + models::ModelConfig split
4//! with a single, coherent, backend-agnostic configuration structure.
5
6use crate::constants::DEFAULT_TEMPERATURE;
7use crate::models::reasoning::ReasoningLevel;
8use crate::prompts;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Unified model configuration
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ModelConfig {
15    /// Model identifier (provider/model or just model name)
16    /// Examples: "ollama/qwen3-coder:30b", "qwen3-coder:30b", "gpt-4"
17    pub model: String,
18
19    /// Temperature (0.0-2.0, controls randomness)
20    #[serde(default = "default_temperature")]
21    pub temperature: f32,
22
23    /// Maximum tokens to generate
24    #[serde(default = "default_max_tokens")]
25    pub max_tokens: usize,
26
27    /// System prompt override (None = use default)
28    pub system_prompt: Option<String>,
29
30    /// Project-specific instructions appended to the system prompt
31    /// (Step 5h: MERMAID.md content). Runtime-only — never persisted.
32    /// On Anthropic, this gets its own `cache_control` block so the
33    /// static base stays cached even when the dynamic suffix changes.
34    /// On other adapters, it's concatenated onto the system prompt
35    /// with a `---` separator.
36    #[serde(skip)]
37    pub dynamic_system_suffix: Option<String>,
38
39    /// Requested reasoning depth. Adapters map this to provider-native
40    /// shapes via `nearest_effort()` against `ModelCapabilities
41    /// ::supports_reasoning`. Defaults to `Medium` — the OpenAI / Anthropic
42    /// / Gemini default and the level that produces useful chain-of-thought
43    /// without burning excessive latency for routine prompts.
44    #[serde(default)]
45    pub reasoning: ReasoningLevel,
46
47    /// Hide reasoning traces from the user-facing stream while still
48    /// allowing the model to reason server-side. Maps to Ollama's
49    /// `--hidethinking` semantics and Anthropic's `thinking.display:
50    /// "hidden"`. Internal plumbing; the reducer currently never
51    /// sets this (no UI toggle) but the adapter pipeline honors it
52    /// when a future toggle lands.
53    #[serde(default)]
54    pub hide_reasoning_trace: bool,
55
56    /// Backend-specific options (provider name -> key/value pairs)
57    /// Example: {"ollama": {"num_gpu": "10", "num_ctx": "8192"}}
58    #[serde(default)]
59    pub backend_options: HashMap<String, HashMap<String, String>>,
60
61    /// `mermaid run --output-schema` formatting turn: the JSON Schema the
62    /// response must conform to. Adapters map it to their native constrained
63    /// output (OpenAI-compat `response_format`, Gemini `responseJsonSchema`,
64    /// Ollama `format`); Anthropic has no native shape and relies on the
65    /// prompt + client-side validation. Runtime-only, never persisted.
66    #[serde(skip)]
67    pub output_schema: Option<serde_json::Value>,
68
69    /// Tool definitions the model sees, already translated into
70    /// OpenAI-compatible `{type: "function", function: {name,
71    /// description, parameters}}` shape. Runtime-only. Populated by
72    /// provider wrappers from `ChatRequest.tools` — adapters iterate
73    /// this directly, no internal registry.
74    #[serde(skip)]
75    pub tools: Vec<serde_json::Value>,
76
77    /// The model's real context window from cache-first live discovery,
78    /// copied off `ChatRequest.resolved_context_window` by provider
79    /// wrappers. Runtime-only; `None` = unknown (no window clamp).
80    #[serde(skip)]
81    pub resolved_context_window: Option<usize>,
82
83    /// The model's real per-response output ceiling, copied off
84    /// `ChatRequest.resolved_max_output`. Runtime-only; `None` = unknown
85    /// (adapters that require `max_tokens` fall back to a floor).
86    #[serde(skip)]
87    pub resolved_max_output: Option<usize>,
88}
89
90impl Default for ModelConfig {
91    fn default() -> Self {
92        Self {
93            // Intentionally empty — every real construction goes through
94            // a provider wrapper that sets `model` immediately.
95            model: String::new(),
96            temperature: default_temperature(),
97            max_tokens: default_max_tokens(),
98            system_prompt: Some(prompts::get_system_prompt()),
99            dynamic_system_suffix: None,
100            reasoning: ReasoningLevel::default(),
101            hide_reasoning_trace: false,
102            backend_options: HashMap::new(),
103            tools: Vec::new(),
104            resolved_context_window: None,
105            resolved_max_output: None,
106            output_schema: None,
107        }
108    }
109}
110
111impl ModelConfig {
112    /// Get a backend-specific option
113    pub fn get_backend_option(&self, backend: &str, key: &str) -> Option<&String> {
114        self.backend_options.get(backend)?.get(key)
115    }
116
117    /// Get backend option as integer
118    pub fn get_backend_option_i32(&self, backend: &str, key: &str) -> Option<i32> {
119        self.get_backend_option(backend, key)?.parse::<i32>().ok()
120    }
121
122    /// Get backend option as boolean
123    pub fn get_backend_option_bool(&self, backend: &str, key: &str) -> Option<bool> {
124        self.get_backend_option(backend, key)?.parse::<bool>().ok()
125    }
126
127    /// Set a backend-specific option
128    pub fn set_backend_option(&mut self, backend: String, key: String, value: String) {
129        self.backend_options
130            .entry(backend)
131            .or_default()
132            .insert(key, value);
133    }
134
135    /// Build the system-prompt string for adapters that don't support
136    /// per-block cache control (Gemini, OpenAI-compat, Ollama). Joins
137    /// the static base and the dynamic suffix (MERMAID.md content)
138    /// with a `---` separator. Anthropic's adapter doesn't use this
139    /// helper — it emits two separately-cached typed-text blocks.
140    ///
141    /// Returns `None` only when both fields are empty/unset.
142    pub fn combined_system_prompt(&self) -> Option<String> {
143        match (
144            self.system_prompt.as_deref(),
145            self.dynamic_system_suffix.as_deref(),
146        ) {
147            (Some(s), Some(suffix)) if !s.is_empty() && !suffix.is_empty() => {
148                Some(format!("{}\n\n---\n\n{}", s, suffix))
149            },
150            (Some(s), _) if !s.is_empty() => Some(s.to_string()),
151            (_, Some(suffix)) if !suffix.is_empty() => Some(suffix.to_string()),
152            _ => None,
153        }
154    }
155
156    /// Build a ModelConfig from user-facing app Config for a given model ID.
157    ///
158    /// Centralizes the wiring of temperature, max_tokens, reasoning level,
159    /// and Ollama hardware options that was previously scattered across
160    /// orchestrator.rs and model.rs.
161    ///
162    /// Reasoning resolution: per-model preference
163    /// (`config.reasoning_per_model[model_id]`) wins, then falls back to
164    /// the global `default_model.reasoning`. Set per-model via Alt+T or
165    /// `/reasoning <level>` while using the model in question.
166    pub fn from_app_config(config: &crate::app::Config, model_id: &str) -> Self {
167        let reasoning = config
168            .reasoning_per_model
169            .get(model_id)
170            .copied()
171            .unwrap_or(config.default_model.reasoning);
172        let mut mc = Self {
173            model: model_id.to_string(),
174            temperature: config.default_model.temperature,
175            max_tokens: config.default_model.max_tokens,
176            reasoning,
177            ..Self::default()
178        };
179        if let Some(v) = config.ollama.num_gpu {
180            mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
181        }
182        if let Some(v) = config.ollama.num_ctx {
183            mc.set_backend_option("ollama".into(), "num_ctx".into(), v.to_string());
184        }
185        if let Some(v) = config.ollama.num_thread {
186            mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
187        }
188        if let Some(v) = config.ollama.numa {
189            mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
190        }
191        mc
192    }
193
194    /// Extract Ollama-specific options
195    pub fn ollama_options(&self) -> OllamaOptions {
196        OllamaOptions {
197            num_gpu: self.get_backend_option_i32("ollama", "num_gpu"),
198            num_thread: self.get_backend_option_i32("ollama", "num_thread"),
199            num_ctx: self.get_backend_option_i32("ollama", "num_ctx"),
200            num_predict: self.get_backend_option_i32("ollama", "num_predict"),
201            numa: self.get_backend_option_bool("ollama", "numa"),
202        }
203    }
204}
205
206/// Ollama-specific options (extracted from backend_options)
207#[derive(Debug, Clone, Default)]
208pub struct OllamaOptions {
209    pub num_gpu: Option<i32>,
210    pub num_thread: Option<i32>,
211    pub num_ctx: Option<i32>,
212    /// Output token cap (`num_predict`). Ollama left output unbounded, so a
213    /// small `num_ctx` was the only stop condition. Derived in
214    /// `build_model_config` (see `ollama_sizing::default_ollama_num_predict`):
215    /// AUTO gets the full window room, an explicit `max_tokens` is exact.
216    pub num_predict: Option<i32>,
217    pub numa: Option<bool>,
218}
219
220/// Backend connection configuration
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct BackendConfig {
223    /// Ollama server URL (default: http://localhost:11434)
224    #[serde(default = "default_ollama_url")]
225    pub ollama_url: String,
226
227    /// Connection timeout in seconds
228    #[serde(default = "default_timeout")]
229    pub timeout_secs: u64,
230
231    /// Max idle connections per host
232    #[serde(default = "default_max_idle")]
233    pub max_idle_per_host: usize,
234
235    /// Auto-start a dead *local* Ollama server on connection failure
236    /// (`ollama::ensure_running`). Sourced from
237    /// `app::Config.ollama.auto_start`; only ever acts on loopback URLs.
238    #[serde(default = "default_ollama_autostart")]
239    pub ollama_autostart: bool,
240}
241
242impl Default for BackendConfig {
243    fn default() -> Self {
244        Self {
245            ollama_url: default_ollama_url(),
246            timeout_secs: default_timeout(),
247            max_idle_per_host: default_max_idle(),
248            ollama_autostart: default_ollama_autostart(),
249        }
250    }
251}
252
253// Default value functions
254fn default_temperature() -> f32 {
255    DEFAULT_TEMPERATURE
256}
257
258fn default_max_tokens() -> usize {
259    // 0 = AUTO: adapters size the output budget to the model (see
260    // `adapters::output_budget`). A positive value is an explicit hard cap.
261    0
262}
263
264fn default_ollama_url() -> String {
265    // Real callers always go through `the `providers::factory::ProviderFactory` path`,
266    // which reads `app::Config.ollama.host/port` (the single documented config
267    // path). This default only fires when constructing `BackendConfig::default`
268    // directly (no app config supplied) — primarily tests. Keep it static so
269    // the precedence is unambiguous; a `MERMAID_OLLAMA_HOST` env override
270    // would belong on `app::Config` loading instead, where it can be
271    // documented and surfaced in `mermaid status`.
272    "http://localhost:11434".to_string()
273}
274
275fn default_timeout() -> u64 {
276    10
277}
278
279fn default_ollama_autostart() -> bool {
280    true
281}
282
283fn default_max_idle() -> usize {
284    10
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    /// Serialized `BackendConfig`s from before the autostart knob lack the
292    /// key — it must default ON (reviving a dead local server is the
293    /// out-of-the-box behavior).
294    #[test]
295    fn backend_config_defaults_autostart_on_when_key_absent() {
296        let cfg: BackendConfig = serde_json::from_str(
297            r#"{"ollama_url":"http://localhost:11434","timeout_secs":5,"max_idle_per_host":2}"#,
298        )
299        .expect("parse");
300        assert!(cfg.ollama_autostart);
301        assert!(BackendConfig::default().ollama_autostart);
302    }
303
304    /// Step 4 wires `default_model.reasoning` from app config into the
305    /// per-call ModelConfig. Without this, the user's config-file choice
306    /// would silently revert to `Medium` on every session bootstrap.
307    #[test]
308    fn from_app_config_propagates_reasoning_from_settings() {
309        let mut cfg = crate::app::Config::default();
310        cfg.default_model.reasoning = ReasoningLevel::High;
311
312        let mc = ModelConfig::from_app_config(&cfg, "ollama/qwen3-coder:30b");
313        assert_eq!(mc.reasoning, ReasoningLevel::High);
314        assert_eq!(mc.model, "ollama/qwen3-coder:30b");
315    }
316
317    #[test]
318    fn from_app_config_uses_medium_default_when_unset() {
319        let cfg = crate::app::Config::default();
320        let mc = ModelConfig::from_app_config(&cfg, "ollama/qwen3-coder:30b");
321        assert_eq!(mc.reasoning, ReasoningLevel::Medium);
322    }
323
324    /// Per-model preference wins over the global default. This is the
325    /// Step 5b semantic: setting `/reasoning high` on Sonnet sticks for
326    /// Sonnet without affecting other models.
327    #[test]
328    fn from_app_config_uses_per_model_preference() {
329        let mut cfg = crate::app::Config::default();
330        cfg.default_model.reasoning = ReasoningLevel::Low;
331        cfg.reasoning_per_model.insert(
332            "anthropic/claude-sonnet-4-6".to_string(),
333            ReasoningLevel::High,
334        );
335
336        let mc_per_model = ModelConfig::from_app_config(&cfg, "anthropic/claude-sonnet-4-6");
337        assert_eq!(mc_per_model.reasoning, ReasoningLevel::High);
338
339        // Falls back to default for other models.
340        let mc_default = ModelConfig::from_app_config(&cfg, "ollama/foo");
341        assert_eq!(mc_default.reasoning, ReasoningLevel::Low);
342    }
343}