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