Skip to main content

tandem_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Map, Value};
7use tokio::fs;
8use tokio::sync::RwLock;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct ProviderConfig {
12    pub api_key: Option<String>,
13    pub url: Option<String>,
14    pub default_model: Option<String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct AppConfig {
19    #[serde(default)]
20    pub providers: HashMap<String, ProviderConfig>,
21    pub default_provider: Option<String>,
22}
23
24#[derive(Debug, Clone, Default)]
25struct ConfigLayers {
26    global: Value,
27    project: Value,
28    managed: Value,
29    env: Value,
30    runtime: Value,
31    cli: Value,
32}
33
34#[derive(Clone)]
35pub struct ConfigStore {
36    project_path: PathBuf,
37    global_path: PathBuf,
38    managed_path: PathBuf,
39    layers: Arc<RwLock<ConfigLayers>>,
40}
41
42impl ConfigStore {
43    pub async fn new(path: impl AsRef<Path>, cli_overrides: Option<Value>) -> anyhow::Result<Self> {
44        let project_path = path.as_ref().to_path_buf();
45        if let Some(parent) = project_path.parent() {
46            fs::create_dir_all(parent).await?;
47        }
48        let managed_path = project_path
49            .parent()
50            .unwrap_or_else(|| Path::new("."))
51            .join("managed_config.json");
52        let global_path = resolve_global_config_path().await?;
53
54        let mut global = read_json_file(&global_path)
55            .await
56            .unwrap_or_else(|_| empty_object());
57        let mut project = read_json_file(&project_path)
58            .await
59            .unwrap_or_else(|_| empty_object());
60        let mut managed = read_json_file(&managed_path)
61            .await
62            .unwrap_or_else(|_| empty_object());
63
64        scrub_persisted_secrets(&mut global, Some(&global_path)).await?;
65        scrub_persisted_secrets(&mut project, Some(&project_path)).await?;
66        scrub_persisted_secrets(&mut managed, Some(&managed_path)).await?;
67
68        let layers = ConfigLayers {
69            global,
70            project,
71            managed,
72            env: env_layer(),
73            runtime: empty_object(),
74            cli: cli_overrides.unwrap_or_else(empty_object),
75        };
76
77        let store = Self {
78            project_path,
79            global_path,
80            managed_path,
81            layers: Arc::new(RwLock::new(layers)),
82        };
83        store.save_project().await?;
84        store.save_global().await?;
85        Ok(store)
86    }
87
88    pub async fn get(&self) -> AppConfig {
89        let merged = self.get_effective_value().await;
90        serde_json::from_value(merged).unwrap_or_default()
91    }
92
93    pub async fn get_effective_value(&self) -> Value {
94        let layers = self.layers.read().await.clone();
95        let mut merged = empty_object();
96        deep_merge(&mut merged, &layers.global);
97        deep_merge(&mut merged, &layers.project);
98        deep_merge(&mut merged, &layers.managed);
99        deep_merge(&mut merged, &layers.env);
100        deep_merge(&mut merged, &layers.runtime);
101        deep_merge(&mut merged, &layers.cli);
102        merged
103    }
104
105    pub async fn get_project_value(&self) -> Value {
106        self.layers.read().await.project.clone()
107    }
108
109    pub async fn get_global_value(&self) -> Value {
110        self.layers.read().await.global.clone()
111    }
112
113    pub async fn get_layers_value(&self) -> Value {
114        let layers = self.layers.read().await;
115        json!({
116            "global": layers.global,
117            "project": layers.project,
118            "managed": layers.managed,
119            "env": layers.env,
120            "runtime": layers.runtime,
121            "cli": layers.cli
122        })
123    }
124
125    pub async fn set(&self, config: AppConfig) -> anyhow::Result<()> {
126        let value = serde_json::to_value(config)?;
127        self.set_project_value(value).await
128    }
129
130    pub async fn patch_project(&self, patch: Value) -> anyhow::Result<Value> {
131        {
132            let mut layers = self.layers.write().await;
133            deep_merge(&mut layers.project, &patch);
134        }
135        self.save_project().await?;
136        Ok(self.get_effective_value().await)
137    }
138
139    pub async fn patch_global(&self, patch: Value) -> anyhow::Result<Value> {
140        {
141            let mut layers = self.layers.write().await;
142            deep_merge(&mut layers.global, &patch);
143        }
144        self.save_global().await?;
145        Ok(self.get_effective_value().await)
146    }
147
148    pub async fn patch_runtime(&self, patch: Value) -> anyhow::Result<Value> {
149        {
150            let mut layers = self.layers.write().await;
151            deep_merge(&mut layers.runtime, &patch);
152        }
153        Ok(self.get_effective_value().await)
154    }
155
156    pub async fn replace_project_value(&self, value: Value) -> anyhow::Result<Value> {
157        self.set_project_value(value).await?;
158        Ok(self.get_effective_value().await)
159    }
160
161    pub async fn delete_runtime_provider_key(&self, provider_id: &str) -> anyhow::Result<Value> {
162        let provider = provider_id.trim().to_string();
163        {
164            let mut layers = self.layers.write().await;
165            let Some(root) = layers.runtime.as_object_mut() else {
166                return Ok(self.get_effective_value().await);
167            };
168            let Some(providers) = root.get_mut("providers").and_then(|v| v.as_object_mut()) else {
169                return Ok(self.get_effective_value().await);
170            };
171            let existing_key = providers
172                .keys()
173                .find(|k| k.eq_ignore_ascii_case(&provider))
174                .cloned();
175            let Some(existing_key) = existing_key else {
176                return Ok(self.get_effective_value().await);
177            };
178            let Some(cfg) = providers
179                .get_mut(&existing_key)
180                .and_then(|v| v.as_object_mut())
181            else {
182                return Ok(self.get_effective_value().await);
183            };
184            cfg.remove("api_key");
185            cfg.remove("apiKey");
186            if cfg.is_empty() {
187                providers.remove(&existing_key);
188            }
189        }
190        Ok(self.get_effective_value().await)
191    }
192
193    async fn set_project_value(&self, value: Value) -> anyhow::Result<()> {
194        self.layers.write().await.project = value;
195        self.save_project().await
196    }
197
198    async fn save_project(&self) -> anyhow::Result<()> {
199        let snapshot = self.layers.read().await.project.clone();
200        write_json_file(&self.project_path, &snapshot).await
201    }
202
203    async fn save_global(&self) -> anyhow::Result<()> {
204        let snapshot = self.layers.read().await.global.clone();
205        write_json_file(&self.global_path, &snapshot).await
206    }
207
208    #[allow(dead_code)]
209    async fn save_managed(&self) -> anyhow::Result<()> {
210        let snapshot = self.layers.read().await.managed.clone();
211        write_json_file(&self.managed_path, &snapshot).await
212    }
213}
214
215fn empty_object() -> Value {
216    Value::Object(Map::new())
217}
218
219async fn write_json_file(path: &Path, value: &Value) -> anyhow::Result<()> {
220    if let Some(parent) = path.parent() {
221        fs::create_dir_all(parent).await?;
222    }
223    let mut to_write = value.clone();
224    if !is_legacy_opencode_path(path) {
225        strip_persisted_secrets(&mut to_write);
226    }
227    let raw = serde_json::to_string_pretty(&to_write)?;
228    fs::write(path, raw).await?;
229    Ok(())
230}
231
232fn strip_persisted_secrets(value: &mut Value) {
233    if let Value::Object(root) = value {
234        if let Some(channels) = root.get_mut("channels").and_then(|v| v.as_object_mut()) {
235            for channel in ["telegram", "discord", "slack"] {
236                if let Some(cfg) = channels.get_mut(channel).and_then(|v| v.as_object_mut()) {
237                    if channel_has_runtime_secret(channel) {
238                        cfg.remove("bot_token");
239                        cfg.remove("botToken");
240                    }
241                }
242            }
243        }
244
245        let Some(providers) = root.get_mut("providers").and_then(|v| v.as_object_mut()) else {
246            return;
247        };
248        for (provider_id, provider_cfg) in providers.iter_mut() {
249            let Value::Object(cfg) = provider_cfg else {
250                continue;
251            };
252            if !cfg.contains_key("api_key") && !cfg.contains_key("apiKey") {
253                continue;
254            }
255            if provider_has_runtime_secret(provider_id) {
256                cfg.remove("api_key");
257                cfg.remove("apiKey");
258            }
259        }
260    }
261}
262
263fn channel_has_runtime_secret(channel_id: &str) -> bool {
264    let key = match channel_id {
265        "telegram" => "TANDEM_TELEGRAM_BOT_TOKEN",
266        "discord" => "TANDEM_DISCORD_BOT_TOKEN",
267        "slack" => "TANDEM_SLACK_BOT_TOKEN",
268        _ => return false,
269    };
270    std::env::var(key)
271        .map(|v| !v.trim().is_empty())
272        .unwrap_or(false)
273}
274
275async fn scrub_persisted_secrets(value: &mut Value, path: Option<&Path>) -> anyhow::Result<()> {
276    if let Some(target) = path {
277        if is_legacy_opencode_path(target) {
278            return Ok(());
279        }
280    }
281    let before = value.clone();
282    strip_persisted_secrets(value);
283    if *value != before {
284        if let Some(target) = path {
285            write_json_file(target, value).await?;
286        }
287    }
288    Ok(())
289}
290
291fn is_legacy_opencode_path(path: &Path) -> bool {
292    path.to_string_lossy()
293        .to_ascii_lowercase()
294        .contains("opencode")
295}
296
297fn provider_has_runtime_secret(provider_id: &str) -> bool {
298    provider_env_candidates(provider_id).into_iter().any(|key| {
299        std::env::var(&key)
300            .map(|v| !v.trim().is_empty())
301            .unwrap_or(false)
302    })
303}
304
305fn provider_env_candidates(provider_id: &str) -> Vec<String> {
306    let normalized = provider_id
307        .chars()
308        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
309        .collect::<String>()
310        .to_ascii_uppercase();
311
312    let mut out = vec![format!("{}_API_KEY", normalized)];
313
314    match provider_id.to_ascii_lowercase().as_str() {
315        "openai" => out.push("OPENAI_API_KEY".to_string()),
316        "openrouter" => out.push("OPENROUTER_API_KEY".to_string()),
317        "anthropic" => out.push("ANTHROPIC_API_KEY".to_string()),
318        "groq" => out.push("GROQ_API_KEY".to_string()),
319        "mistral" => out.push("MISTRAL_API_KEY".to_string()),
320        "together" => out.push("TOGETHER_API_KEY".to_string()),
321        "azure" => out.push("AZURE_OPENAI_API_KEY".to_string()),
322        "vertex" => out.push("VERTEX_API_KEY".to_string()),
323        "bedrock" => out.push("BEDROCK_API_KEY".to_string()),
324        "copilot" => out.push("GITHUB_TOKEN".to_string()),
325        "cohere" => out.push("COHERE_API_KEY".to_string()),
326        "zen" | "opencode_zen" | "opencodezen" => out.push("OPENCODE_ZEN_API_KEY".to_string()),
327        _ => {}
328    }
329
330    out.sort();
331    out.dedup();
332    out
333}
334
335async fn read_json_file(path: &Path) -> anyhow::Result<Value> {
336    if !path.exists() {
337        return Ok(empty_object());
338    }
339    let raw = fs::read_to_string(path).await?;
340    Ok(serde_json::from_str::<Value>(&raw).unwrap_or_else(|_| empty_object()))
341}
342
343async fn resolve_global_config_path() -> anyhow::Result<PathBuf> {
344    if let Ok(path) = std::env::var("TANDEM_GLOBAL_CONFIG") {
345        let path = PathBuf::from(path);
346        if let Some(parent) = path.parent() {
347            fs::create_dir_all(parent).await?;
348        }
349        return Ok(path);
350    }
351    if let Some(config_dir) = dirs::config_dir() {
352        let path = config_dir.join("tandem").join("config.json");
353        if let Some(parent) = path.parent() {
354            fs::create_dir_all(parent).await?;
355        }
356        return Ok(path);
357    }
358    Ok(PathBuf::from(".tandem/global_config.json"))
359}
360
361fn env_layer() -> Value {
362    let mut root = empty_object();
363
364    if let Ok(enabled) = std::env::var("TANDEM_WEB_UI") {
365        if let Some(v) = parse_bool_like(&enabled) {
366            deep_merge(&mut root, &json!({ "web_ui": { "enabled": v } }));
367        }
368    }
369    if let Ok(prefix) = std::env::var("TANDEM_WEB_UI_PREFIX") {
370        if !prefix.trim().is_empty() {
371            deep_merge(&mut root, &json!({ "web_ui": { "path_prefix": prefix } }));
372        }
373    }
374    if let Ok(token) = std::env::var("TANDEM_TELEGRAM_BOT_TOKEN") {
375        if !token.trim().is_empty() {
376            let allowed = std::env::var("TANDEM_TELEGRAM_ALLOWED_USERS")
377                .map(|s| parse_csv(&s))
378                .unwrap_or_else(|_| vec!["*".to_string()]);
379            let mention_only = std::env::var("TANDEM_TELEGRAM_MENTION_ONLY")
380                .ok()
381                .and_then(|v| parse_bool_like(&v))
382                .unwrap_or(false);
383            deep_merge(
384                &mut root,
385                &json!({
386                    "channels": {
387                        "telegram": {
388                            "bot_token": token,
389                            "allowed_users": allowed,
390                            "mention_only": mention_only
391                        }
392                    }
393                }),
394            );
395        }
396    }
397    if let Ok(token) = std::env::var("TANDEM_DISCORD_BOT_TOKEN") {
398        if !token.trim().is_empty() {
399            let allowed = std::env::var("TANDEM_DISCORD_ALLOWED_USERS")
400                .map(|s| parse_csv(&s))
401                .unwrap_or_else(|_| vec!["*".to_string()]);
402            let mention_only = std::env::var("TANDEM_DISCORD_MENTION_ONLY")
403                .ok()
404                .and_then(|v| parse_bool_like(&v))
405                .unwrap_or(true);
406            let guild_id = std::env::var("TANDEM_DISCORD_GUILD_ID").ok();
407            deep_merge(
408                &mut root,
409                &json!({
410                    "channels": {
411                        "discord": {
412                            "bot_token": token,
413                            "guild_id": guild_id,
414                            "allowed_users": allowed,
415                            "mention_only": mention_only
416                        }
417                    }
418                }),
419            );
420        }
421    }
422    if let Ok(token) = std::env::var("TANDEM_SLACK_BOT_TOKEN") {
423        if !token.trim().is_empty() {
424            if let Ok(channel_id) = std::env::var("TANDEM_SLACK_CHANNEL_ID") {
425                if !channel_id.trim().is_empty() {
426                    let allowed = std::env::var("TANDEM_SLACK_ALLOWED_USERS")
427                        .map(|s| parse_csv(&s))
428                        .unwrap_or_else(|_| vec!["*".to_string()]);
429                    deep_merge(
430                        &mut root,
431                        &json!({
432                            "channels": {
433                                "slack": {
434                                    "bot_token": token,
435                                    "channel_id": channel_id,
436                                    "allowed_users": allowed
437                                }
438                            }
439                        }),
440                    );
441                }
442            }
443        }
444    }
445
446    if let Ok(api_key) = std::env::var("OPENAI_API_KEY") {
447        deep_merge(
448            &mut root,
449            &json!({
450                "providers": {
451                    "openai": {
452                        "api_key": api_key,
453                        "url": "https://api.openai.com/v1",
454                        "default_model": "gpt-5.2"
455                    }
456                }
457            }),
458        );
459    }
460    add_openai_env(
461        &mut root,
462        "openrouter",
463        "OPENROUTER_API_KEY",
464        "https://openrouter.ai/api/v1",
465        "openai/gpt-4o-mini",
466    );
467    add_openai_env(
468        &mut root,
469        "groq",
470        "GROQ_API_KEY",
471        "https://api.groq.com/openai/v1",
472        "llama-3.1-8b-instant",
473    );
474    add_openai_env(
475        &mut root,
476        "mistral",
477        "MISTRAL_API_KEY",
478        "https://api.mistral.ai/v1",
479        "mistral-small-latest",
480    );
481    add_openai_env(
482        &mut root,
483        "together",
484        "TOGETHER_API_KEY",
485        "https://api.together.xyz/v1",
486        "meta-llama/Llama-3.1-8B-Instruct-Turbo",
487    );
488    add_openai_env(
489        &mut root,
490        "azure",
491        "AZURE_OPENAI_API_KEY",
492        "https://example.openai.azure.com/openai/deployments/default",
493        "gpt-4o-mini",
494    );
495    add_openai_env(
496        &mut root,
497        "vertex",
498        "VERTEX_API_KEY",
499        "https://aiplatform.googleapis.com/v1",
500        "gemini-1.5-flash",
501    );
502    add_openai_env(
503        &mut root,
504        "bedrock",
505        "BEDROCK_API_KEY",
506        "https://bedrock-runtime.us-east-1.amazonaws.com",
507        "anthropic.claude-3-5-sonnet-20240620-v1:0",
508    );
509    add_openai_env(
510        &mut root,
511        "copilot",
512        "GITHUB_TOKEN",
513        "https://api.githubcopilot.com",
514        "gpt-4o-mini",
515    );
516    add_openai_env(
517        &mut root,
518        "cohere",
519        "COHERE_API_KEY",
520        "https://api.cohere.com/v2",
521        "command-r-plus",
522    );
523    if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
524        deep_merge(
525            &mut root,
526            &json!({
527                "providers": {
528                    "anthropic": {
529                        "api_key": api_key,
530                        "url": "https://api.anthropic.com/v1",
531                        "default_model": "claude-sonnet-4-6"
532                    }
533                }
534            }),
535        );
536    }
537    if let Ok(ollama_url) = std::env::var("OLLAMA_URL") {
538        deep_merge(
539            &mut root,
540            &json!({
541                "providers": {
542                    "ollama": {
543                        "url": ollama_url,
544                        "default_model": "llama3.1:8b"
545                    }
546                }
547            }),
548        );
549    } else if std::net::TcpStream::connect("127.0.0.1:11434").is_ok() {
550        deep_merge(
551            &mut root,
552            &json!({
553                "providers": {
554                    "ollama": {
555                        "url": "http://127.0.0.1:11434/v1",
556                        "default_model": "llama3.1:8b"
557                    }
558                }
559            }),
560        );
561    }
562
563    root
564}
565
566fn parse_bool_like(raw: &str) -> Option<bool> {
567    match raw.trim().to_ascii_lowercase().as_str() {
568        "1" | "true" | "yes" | "on" => Some(true),
569        "0" | "false" | "no" | "off" => Some(false),
570        _ => None,
571    }
572}
573
574fn parse_csv(raw: &str) -> Vec<String> {
575    if raw.trim() == "*" {
576        return vec!["*".to_string()];
577    }
578    raw.split(',')
579        .map(|s| s.trim().to_string())
580        .filter(|s| !s.is_empty())
581        .collect()
582}
583
584fn first_nonempty_env(keys: &[String]) -> Option<String> {
585    keys.iter().find_map(|key| {
586        std::env::var(key).ok().and_then(|value| {
587            let trimmed = value.trim();
588            if trimmed.is_empty() {
589                None
590            } else {
591                Some(trimmed.to_string())
592            }
593        })
594    })
595}
596
597fn add_openai_env(root: &mut Value, provider: &str, key_env: &str, default_url: &str, model: &str) {
598    let Ok(api_key) = std::env::var(key_env) else {
599        return;
600    };
601
602    let api_key = api_key.trim().to_string();
603    if api_key.is_empty() {
604        return;
605    }
606
607    let mut provider_cfg = json!({
608        "api_key": api_key,
609        "url": default_url,
610    });
611
612    // Preserve explicit model selection from config by default.
613    // Only apply env-layer default_model when an explicit model env is provided.
614    let provider_upper = provider.to_ascii_uppercase();
615    let inferred_model_key = key_env.replace("API_KEY", "MODEL");
616    let model_keys = vec![
617        format!("{provider_upper}_MODEL"),
618        format!("{provider_upper}_DEFAULT_MODEL"),
619        inferred_model_key,
620    ];
621    let explicit_model = first_nonempty_env(&model_keys).unwrap_or_else(|| model.to_string());
622    if model_keys.iter().any(|key| {
623        std::env::var(key)
624            .ok()
625            .is_some_and(|v| !v.trim().is_empty())
626    }) {
627        provider_cfg["default_model"] = Value::String(explicit_model);
628    }
629
630    deep_merge(
631        root,
632        &json!({
633            "providers": {
634                provider: provider_cfg
635            }
636        }),
637    );
638}
639
640fn deep_merge(base: &mut Value, overlay: &Value) {
641    if overlay.is_null() {
642        return;
643    }
644    match (base, overlay) {
645        (Value::Object(base_map), Value::Object(overlay_map)) => {
646            for (key, value) in overlay_map {
647                if value.is_null() {
648                    continue;
649                }
650                match base_map.get_mut(key) {
651                    Some(existing) => deep_merge(existing, value),
652                    None => {
653                        base_map.insert(key.clone(), value.clone());
654                    }
655                }
656            }
657        }
658        (base_value, overlay_value) => {
659            *base_value = overlay_value.clone();
660        }
661    }
662}
663
664impl From<ProviderConfig> for tandem_providers::ProviderConfig {
665    fn from(value: ProviderConfig) -> Self {
666        Self {
667            api_key: value.api_key,
668            url: value.url,
669            default_model: value.default_model,
670        }
671    }
672}
673
674impl From<AppConfig> for tandem_providers::AppConfig {
675    fn from(value: AppConfig) -> Self {
676        Self {
677            providers: value
678                .providers
679                .into_iter()
680                .map(|(k, v)| (k, v.into()))
681                .collect(),
682            default_provider: value.default_provider,
683        }
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use std::time::{SystemTime, UNIX_EPOCH};
691
692    fn unique_temp_file(name: &str) -> PathBuf {
693        let mut path = std::env::temp_dir();
694        let ts = SystemTime::now()
695            .duration_since(UNIX_EPOCH)
696            .map(|d| d.as_nanos())
697            .unwrap_or(0);
698        path.push(format!("tandem-core-config-{name}-{ts}.json"));
699        path
700    }
701
702    #[test]
703    fn strip_persisted_secrets_keeps_channel_bot_tokens_without_runtime_env() {
704        let mut value = json!({
705            "channels": {
706                "telegram": {
707                    "bot_token": "tg-secret",
708                    "allowed_users": ["*"]
709                },
710                "discord": {
711                    "botToken": "dc-secret",
712                    "allowed_users": ["*"],
713                    "mention_only": true
714                },
715                "slack": {
716                    "bot_token": "sl-secret",
717                    "channel_id": "C123"
718                }
719            },
720            "providers": {}
721        });
722
723        strip_persisted_secrets(&mut value);
724
725        assert!(value
726            .get("channels")
727            .and_then(|v| v.get("telegram"))
728            .and_then(Value::as_object)
729            .is_some_and(|obj| obj.contains_key("bot_token")));
730        assert!(value
731            .get("channels")
732            .and_then(|v| v.get("discord"))
733            .and_then(Value::as_object)
734            .is_some_and(|obj| obj.contains_key("botToken")));
735        assert!(value
736            .get("channels")
737            .and_then(|v| v.get("slack"))
738            .and_then(Value::as_object)
739            .is_some_and(|obj| obj.contains_key("bot_token")));
740    }
741
742    #[tokio::test]
743    async fn scrub_persisted_secrets_keeps_channel_tokens_on_disk_without_runtime_env() {
744        let path = unique_temp_file("scrub");
745        let original = json!({
746            "channels": {
747                "telegram": {
748                    "bot_token": "tg-secret",
749                    "allowed_users": ["@alice"]
750                }
751            },
752            "providers": {}
753        });
754        let raw = serde_json::to_string_pretty(&original).expect("serialize");
755        fs::write(&path, raw).await.expect("write");
756
757        let mut loaded =
758            serde_json::from_str::<Value>(&fs::read_to_string(&path).await.expect("read before"))
759                .expect("parse");
760
761        scrub_persisted_secrets(&mut loaded, Some(&path))
762            .await
763            .expect("scrub");
764
765        let persisted =
766            serde_json::from_str::<Value>(&fs::read_to_string(&path).await.expect("read after"))
767                .expect("parse persisted");
768        assert!(persisted
769            .get("channels")
770            .and_then(|v| v.get("telegram"))
771            .and_then(Value::as_object)
772            .is_some_and(|obj| obj.contains_key("bot_token")));
773
774        let _ = fs::remove_file(&path).await;
775    }
776
777    #[test]
778    fn strip_persisted_secrets_removes_channel_bot_tokens_with_runtime_env() {
779        std::env::set_var("TANDEM_TELEGRAM_BOT_TOKEN", "runtime-secret");
780        std::env::set_var("TANDEM_DISCORD_BOT_TOKEN", "runtime-secret");
781        std::env::set_var("TANDEM_SLACK_BOT_TOKEN", "runtime-secret");
782
783        let mut value = json!({
784            "channels": {
785                "telegram": {
786                    "bot_token": "tg-secret"
787                },
788                "discord": {
789                    "botToken": "dc-secret"
790                },
791                "slack": {
792                    "bot_token": "sl-secret"
793                }
794            }
795        });
796
797        strip_persisted_secrets(&mut value);
798
799        assert!(value
800            .get("channels")
801            .and_then(|v| v.get("telegram"))
802            .and_then(Value::as_object)
803            .is_some_and(|obj| !obj.contains_key("bot_token")));
804        assert!(value
805            .get("channels")
806            .and_then(|v| v.get("discord"))
807            .and_then(Value::as_object)
808            .is_some_and(|obj| !obj.contains_key("botToken")));
809        assert!(value
810            .get("channels")
811            .and_then(|v| v.get("slack"))
812            .and_then(Value::as_object)
813            .is_some_and(|obj| !obj.contains_key("bot_token")));
814
815        std::env::remove_var("TANDEM_TELEGRAM_BOT_TOKEN");
816        std::env::remove_var("TANDEM_DISCORD_BOT_TOKEN");
817        std::env::remove_var("TANDEM_SLACK_BOT_TOKEN");
818    }
819
820    #[test]
821    fn openrouter_api_key_env_does_not_override_default_model_without_model_env() {
822        std::env::set_var("OPENROUTER_API_KEY", "sk-test");
823        std::env::remove_var("OPENROUTER_MODEL");
824        std::env::remove_var("OPENROUTER_DEFAULT_MODEL");
825
826        let env_layer: Value = env_layer();
827        let default_model = env_layer
828            .get("providers")
829            .and_then(|v| v.get("openrouter"))
830            .and_then(|v| v.get("default_model"));
831        assert!(default_model.is_none());
832
833        std::env::remove_var("OPENROUTER_API_KEY");
834    }
835
836    #[test]
837    fn openrouter_model_env_overrides_default_model_when_explicitly_set() {
838        std::env::set_var("OPENROUTER_API_KEY", "sk-test");
839        std::env::set_var("OPENROUTER_MODEL", "z-ai/glm-5");
840
841        let env_layer: Value = env_layer();
842        let default_model = env_layer
843            .get("providers")
844            .and_then(|v| v.get("openrouter"))
845            .and_then(|v| v.get("default_model"))
846            .and_then(Value::as_str);
847        assert_eq!(default_model, Some("z-ai/glm-5"));
848
849        std::env::remove_var("OPENROUTER_API_KEY");
850        std::env::remove_var("OPENROUTER_MODEL");
851    }
852}