Skip to main content

opendev_runtime/
session_model.rs

1//! Per-session model configuration overlay.
2//!
3//! Stores a sparse map in session metadata with only the slots the user
4//! explicitly set. Missing keys fall through to global config.
5//!
6//! Precedence: session-model > project config > global config > defaults
7//!
8//! Ported from `opendev/core/runtime/session_model.py`.
9
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13/// The set of field names that are valid session-model overlay keys.
14pub static SESSION_MODEL_FIELDS: &[&str] = &[
15    "model",
16    "model_provider",
17    "model_thinking",
18    "model_thinking_provider",
19    "model_vlm",
20    "model_vlm_provider",
21];
22
23/// A session-model overlay: sparse key-value map of config overrides.
24pub type SessionOverlay = HashMap<String, String>;
25
26/// Manages the session-model overlay lifecycle.
27///
28/// Tracks original config values so we can:
29/// - Restore before save_config() to prevent leaking overlay to settings.json
30/// - Revert on /session-model clear or /clear
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SessionModelManager {
33    /// Original values that were overridden.
34    originals: HashMap<String, String>,
35    /// Currently active overlay (None = no overlay).
36    active_overlay: Option<SessionOverlay>,
37}
38
39impl SessionModelManager {
40    /// Create a new manager with no active overlay.
41    pub fn new() -> Self {
42        Self {
43            originals: HashMap::new(),
44            active_overlay: None,
45        }
46    }
47
48    /// Whether a session-model overlay is currently active.
49    pub fn is_active(&self) -> bool {
50        self.active_overlay.as_ref().is_some_and(|o| !o.is_empty())
51    }
52
53    /// Apply an overlay, recording the original values from the provided config getter.
54    ///
55    /// The `get_config_value` closure retrieves the current config value for a given key.
56    /// The `set_config_value` closure applies the override.
57    pub fn apply<G, S>(
58        &mut self,
59        overlay: &SessionOverlay,
60        get_config_value: G,
61        set_config_value: S,
62    ) where
63        G: Fn(&str) -> Option<String>,
64        S: Fn(&str, &str),
65    {
66        if overlay.is_empty() {
67            return;
68        }
69
70        let valid_fields: HashSet<&str> = SESSION_MODEL_FIELDS.iter().copied().collect();
71
72        self.active_overlay = Some(overlay.clone());
73        self.originals.clear();
74
75        for (key, value) in overlay {
76            if !valid_fields.contains(key.as_str()) {
77                continue;
78            }
79            if let Some(original) = get_config_value(key) {
80                self.originals.insert(key.clone(), original);
81            }
82            set_config_value(key, value);
83        }
84    }
85
86    /// Restore original config values, removing the overlay.
87    ///
88    /// The `set_config_value` closure applies each restored value.
89    pub fn restore<S>(&mut self, set_config_value: S)
90    where
91        S: Fn(&str, &str),
92    {
93        for (key, value) in &self.originals {
94            set_config_value(key, value);
95        }
96        self.originals.clear();
97        self.active_overlay = None;
98    }
99
100    /// Return the current overlay dict (for persistence).
101    pub fn get_overlay(&self) -> Option<&SessionOverlay> {
102        self.active_overlay.as_ref()
103    }
104}
105
106impl Default for SessionModelManager {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112/// Read session-model overlay from session metadata.
113pub fn get_session_model(metadata: &serde_json::Value) -> Option<SessionOverlay> {
114    metadata
115        .get("session_model")
116        .and_then(|v| serde_json::from_value::<SessionOverlay>(v.clone()).ok())
117}
118
119/// Write session-model overlay to session metadata.
120pub fn set_session_model(metadata: &mut serde_json::Value, overlay: &SessionOverlay) {
121    if let Some(obj) = metadata.as_object_mut() {
122        obj.insert(
123            "session_model".to_string(),
124            serde_json::to_value(overlay).unwrap_or_default(),
125        );
126    }
127}
128
129/// Remove session-model overlay from session metadata.
130pub fn clear_session_model(metadata: &mut serde_json::Value) {
131    if let Some(obj) = metadata.as_object_mut() {
132        obj.remove("session_model");
133    }
134}
135
136/// Validate overlay entries against valid field names.
137///
138/// Returns `(valid_overlay, warnings)`.
139pub fn validate_session_model(overlay: &SessionOverlay) -> (SessionOverlay, Vec<String>) {
140    if overlay.is_empty() {
141        return (HashMap::new(), Vec::new());
142    }
143
144    let valid_fields: HashSet<&str> = SESSION_MODEL_FIELDS.iter().copied().collect();
145    let mut valid = HashMap::new();
146    let mut warnings = Vec::new();
147
148    for (key, value) in overlay {
149        if valid_fields.contains(key.as_str()) {
150            valid.insert(key.clone(), value.clone());
151        } else {
152            warnings.push(format!("Unknown session-model field '{}', ignored", key));
153        }
154    }
155
156    (valid, warnings)
157}
158
159#[cfg(test)]
160#[path = "session_model_tests.rs"]
161mod tests;