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}