Skip to main content

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}