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