1use serde_json::{Map, Value};
7
8use crate::Config;
9
10pub fn is_masked_api_key(value: &str) -> bool {
19 let v = value.trim();
20 !v.is_empty() && v.chars().all(|c| c == '*' || c == '.')
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct ProviderApiKeyIntents {
29 pub providers: std::collections::BTreeSet<String>,
30 pub provider_instances: std::collections::BTreeSet<String>,
31}
32
33pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
34 let mut intents = ProviderApiKeyIntents::default();
35
36 if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
37 for (provider_name, provider_patch) in root.iter() {
38 let Some(obj) = provider_patch.as_object() else {
39 continue;
40 };
41 let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
42 continue;
43 };
44 if is_masked_api_key(api_key) {
45 continue;
46 }
47 intents.providers.insert(provider_name.clone());
48 }
49 }
50
51 if let Some(root) = patch_obj
52 .get("provider_instances")
53 .and_then(|v| v.as_object())
54 {
55 for (instance_id, instance_patch) in root.iter() {
56 let Some(obj) = instance_patch.as_object() else {
57 continue;
58 };
59 let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
60 continue;
61 };
62 if is_masked_api_key(api_key) {
63 continue;
64 }
65 intents.provider_instances.insert(instance_id.clone());
66 }
67 }
68
69 intents
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ReloadMode {
75 None,
76 BestEffort,
78 Strict,
80}
81
82#[derive(Debug, Clone, Copy)]
84pub struct PatchEffects {
85 pub reload_provider: ReloadMode,
86 pub reconcile_mcp: bool,
87}
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91pub struct DomainChanges {
92 pub provider: bool,
93 pub proxy: bool,
94 pub setup: bool,
95 pub mcp: bool,
96 pub keyword_masking: bool,
97 pub hooks: bool,
98 pub model_mapping: bool,
99}
100
101pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
103 let mut changes = DomainChanges::default();
104
105 for key in patch_obj.keys() {
106 match key.as_str() {
107 "provider"
109 | "providers"
110 | "provider_instances"
111 | "default_provider_instance"
112 | "model"
113 | "defaults"
114 | "features" => changes.provider = true,
115
116 "http_proxy"
118 | "https_proxy"
119 | "proxy_auth"
120 | "proxy_auth_encrypted"
121 | "http_proxy_auth_encrypted"
122 | "https_proxy_auth_encrypted" => changes.proxy = true,
123
124 "setup" => changes.setup = true,
126
127 "mcp" | "mcpServers" => changes.mcp = true,
129
130 "keyword_masking" => changes.keyword_masking = true,
132 "hooks" => changes.hooks = true,
133 "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
134
135 _ => {}
136 }
137 }
138
139 changes
140}
141
142pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
144 let domains = domains_for_root_patch(patch_obj);
145
146 let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
147 let touches_proxy = domains.proxy;
148 let touches_mcp = domains.mcp;
149
150 PatchEffects {
151 reload_provider: if touches_provider || touches_proxy {
152 ReloadMode::BestEffort
153 } else {
154 ReloadMode::None
155 },
156 reconcile_mcp: touches_mcp || touches_proxy,
159 }
160}
161
162pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
167 patch_obj.remove("proxy_auth");
169 patch_obj.remove("proxy_auth_encrypted");
170 patch_obj.remove("http_proxy_auth_encrypted");
172 patch_obj.remove("https_proxy_auth_encrypted");
173 patch_obj.remove("data_dir");
174
175 if let Some(providers) = patch_obj
177 .get_mut("providers")
178 .and_then(|v| v.as_object_mut())
179 {
180 for (_provider_name, provider_cfg) in providers.iter_mut() {
181 let Some(obj) = provider_cfg.as_object_mut() else {
182 continue;
183 };
184 obj.remove("api_key_encrypted");
185 }
186 }
187
188 if let Some(provider_instances) = patch_obj
189 .get_mut("provider_instances")
190 .and_then(|v| v.as_object_mut())
191 {
192 for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
193 let Some(obj) = instance_cfg.as_object_mut() else {
194 continue;
195 };
196 obj.remove("api_key_encrypted");
197 }
198 }
199
200 if let Some(mcp_servers) = patch_obj
205 .get_mut("mcpServers")
206 .and_then(|v| v.as_object_mut())
207 {
208 for (_id, server) in mcp_servers.iter_mut() {
209 let Some(server_obj) = server.as_object_mut() else {
210 continue;
211 };
212 server_obj.remove("env_encrypted");
213 if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
214 for header in headers.iter_mut() {
215 let Some(header_obj) = header.as_object_mut() else {
216 continue;
217 };
218 header_obj.remove("value_encrypted");
219 }
220 }
221 }
222 }
223
224 if let Some(servers) = patch_obj
227 .get_mut("mcp")
228 .and_then(|m| m.get_mut("servers"))
229 .and_then(|v| v.as_array_mut())
230 {
231 for server in servers.iter_mut() {
232 let Some(server_obj) = server.as_object_mut() else {
233 continue;
234 };
235 let Some(transport) = server_obj
236 .get_mut("transport")
237 .and_then(|v| v.as_object_mut())
238 else {
239 continue;
240 };
241
242 match transport.get("type").and_then(|v| v.as_str()) {
243 Some("stdio") => {
244 transport.remove("env_encrypted");
245 }
246 Some("sse") => {
247 if let Some(headers) =
248 transport.get_mut("headers").and_then(|v| v.as_array_mut())
249 {
250 for header in headers.iter_mut() {
251 let Some(header_obj) = header.as_object_mut() else {
252 continue;
253 };
254 header_obj.remove("value_encrypted");
255 }
256 }
257 }
258 _ => {}
259 }
260 }
261 }
262}
263
264pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
269 if let Some(patch_providers) = patch_obj
270 .get_mut("providers")
271 .and_then(|v| v.as_object_mut())
272 {
273 for (provider_name, provider_patch) in patch_providers.iter_mut() {
274 let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
275 continue;
276 };
277
278 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
279 continue;
280 };
281 if !is_masked_api_key(api_key) {
282 continue;
283 }
284
285 let existing_plain = match provider_name.as_str() {
286 "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
287 "anthropic" => current
288 .providers
289 .anthropic
290 .as_ref()
291 .map(|c| c.api_key.clone()),
292 "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
293 "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
294 _ => None,
295 };
296
297 if let Some(existing_plain) = existing_plain {
298 if !existing_plain.trim().is_empty() {
299 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
300 } else {
301 patch_cfg_obj.remove("api_key");
302 }
303 } else {
304 patch_cfg_obj.remove("api_key");
305 }
306 }
307 }
308
309 if let Some(patch_instances) = patch_obj
310 .get_mut("provider_instances")
311 .and_then(|v| v.as_object_mut())
312 {
313 for (instance_id, instance_patch) in patch_instances.iter_mut() {
314 let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
315 continue;
316 };
317
318 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
319 continue;
320 };
321 if !is_masked_api_key(api_key) {
322 continue;
323 }
324
325 let existing_plain = current
326 .provider_instances
327 .get(instance_id)
328 .map(|instance| instance.api_key.clone());
329
330 if let Some(existing_plain) = existing_plain {
331 if !existing_plain.trim().is_empty() {
332 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
333 } else {
334 patch_cfg_obj.remove("api_key");
335 }
336 } else {
337 patch_cfg_obj.remove("api_key");
338 }
339 }
340 }
341}
342
343pub fn deep_merge_json(dst: &mut Value, src: Value) {
345 match (dst, src) {
346 (Value::Object(dst_map), Value::Object(src_map)) => {
347 for (key, value) in src_map {
348 match dst_map.get_mut(&key) {
349 Some(existing) => deep_merge_json(existing, value),
350 None => {
351 dst_map.insert(key, value);
352 }
353 }
354 }
355 }
356 (dst_slot, src_value) => {
357 *dst_slot = src_value;
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use serde_json::json;
366
367 #[test]
368 fn domains_for_root_patch_detects_proxy_and_provider() {
369 let patch = json!({
370 "provider": "openai",
371 "http_proxy": "http://proxy:8080",
372 "setup": { "completed": false },
373 "mcpServers": {}
374 });
375
376 let domains = domains_for_root_patch(patch.as_object().unwrap());
377 assert!(domains.provider);
378 assert!(domains.proxy);
379 assert!(domains.setup);
380 assert!(domains.mcp);
381 }
382
383 #[test]
384 fn domains_for_root_patch_detects_provider_instances() {
385 let patch = json!({
386 "provider_instances": {
387 "openai-work": { "provider_type": "openai" }
388 },
389 "default_provider_instance": "openai-work",
390 "defaults": {
391 "chat": { "provider": "openai-work", "model": "gpt-4o" }
392 },
393 "features": {
394 "provider_model_ref": true
395 }
396 });
397
398 let domains = domains_for_root_patch(patch.as_object().unwrap());
399 assert!(domains.provider);
400 }
401
402 #[test]
403 fn provider_api_key_intents_ignores_masked_placeholders() {
404 let patch = json!({
405 "providers": {
406 "openai": { "api_key": "****...****" },
407 "gemini": { "api_key": "sk-real" }
408 },
409 "provider_instances": {
410 "work-openai": { "api_key": "****...****" },
411 "personal-openai": { "api_key": "sk-live" }
412 }
413 });
414 let intents = provider_api_key_intents(patch.as_object().unwrap());
415 assert!(intents.providers.contains("gemini"));
416 assert!(!intents.providers.contains("openai"));
417 assert!(intents.provider_instances.contains("personal-openai"));
418 assert!(!intents.provider_instances.contains("work-openai"));
419 }
420
421 #[test]
422 fn is_masked_api_key_requires_placeholder_only_values() {
423 assert!(is_masked_api_key("****...****"));
425 assert!(is_masked_api_key("********"));
426 assert!(is_masked_api_key(" ****...**** "));
427
428 assert!(!is_masked_api_key(""));
430 assert!(!is_masked_api_key(" "));
431
432 assert!(!is_masked_api_key("****...****sk-newkey123"));
435 assert!(!is_masked_api_key("sk-newkey123****...****"));
436
437 assert!(!is_masked_api_key("id.secret...suffix"));
439 assert!(!is_masked_api_key("sk-live-abc"));
440 }
441}