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 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}