返回 CodeWhale
tests.rs
根目录 / crates / tui / src / config / tests.rs
1 use super::*;
2 use crate::test_support::{EnvVarGuard, lock_test_env};
3 use std::collections::HashMap;
4 use std::env;
5 use std::ffi::OsString;
6 #[cfg(unix)]
7 use std::os::unix::fs::PermissionsExt;
8 use std::sync::mpsc;
9 use std::time::Duration;
10 use std::time::{SystemTime, UNIX_EPOCH};
11
12 #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
13 struct HeaderItemsTestConfig {
14 #[serde(default, deserialize_with = "deser_header_items")]
15 header_items: Option<Vec<HeaderItem>>,
16 }
17
18 #[test]
19 fn parses_header_tokens_item() {
20 let config: HeaderItemsTestConfig = toml::from_str(
21 r#"
22 header_items = ["tokens"]
23 "#,
24 )
25 .expect("header_items should parse");
26
27 assert_eq!(config.header_items, Some(vec![HeaderItem::Tokens]));
28 }
29
30 #[test]
31 fn ignores_unknown_header_items() {
32 let config: HeaderItemsTestConfig = toml::from_str(
33 r#"
34 header_items = ["tokens", "future_item"]
35 "#,
36 )
37 .expect("unknown header items should not reject the config");
38
39 assert_eq!(config.header_items, Some(vec![HeaderItem::Tokens]));
40 }
41
42 #[test]
43 fn header_items_round_trip() {
44 let original = HeaderItemsTestConfig {
45 header_items: Some(vec![HeaderItem::Tokens]),
46 };
47
48 let serialized = toml::to_string(&original).expect("config should serialize");
49 let decoded: HeaderItemsTestConfig =
50 toml::from_str(&serialized).expect("serialized config should parse");
51
52 assert_eq!(decoded, original);
53 }
54
55 #[test]
56 fn header_items_are_opt_in_by_default() {
57 assert!(HeaderItem::default_header().is_empty());
58 }
59
60 #[test]
61 fn malformed_config_error_omits_secret_contents_and_keys() {
62 let dir = tempfile::tempdir().expect("tempdir");
63 let path = dir.path().join("config.toml");
64 let secret = "cw-secret-tui-config-4507";
65 fs::write(
66 &path,
67 format!("[providers.xai]\napi_key = \"{secret}\" trailing-junk\n"),
68 )
69 .expect("write malformed config");
70
71 let error = Config::load(Some(path), None).expect_err("malformed config must fail");
72 let diagnostic = format!("{error:#}");
73 assert!(!diagnostic.contains(secret), "{diagnostic}");
74 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
75 assert!(
76 diagnostic.contains("file contents were omitted"),
77 "{diagnostic}"
78 );
79 }
80
81 #[test]
82 fn api_provider_metadata_helpers_follow_config_provider_metadata() {
83 let sorted = ApiProvider::sorted_for_display();
84 let expected_sorted: Vec<ApiProvider> =
85 codewhale_config::provider::providers_sorted_for_display()
86 .iter()
87 .map(|provider| ApiProvider::from_kind(provider.kind()))
88 .collect();
89 assert_eq!(sorted, expected_sorted);
90
91 for kind in codewhale_config::ProviderKind::ALL {
92 let provider = ApiProvider::from_kind(kind);
93 let metadata = provider.metadata().expect("metadata-backed provider");
94 assert_eq!(metadata.kind(), kind);
95 assert_eq!(provider.env_vars(), kind.provider().env_vars());
96 assert_eq!(
97 provider.default_base_url(),
98 kind.provider().default_base_url()
99 );
100 }
101
102 assert_eq!(ApiProvider::DeepseekCN.metadata().map(|p| p.kind()), None);
103 assert_eq!(
104 ApiProvider::DeepseekCN.env_vars(),
105 codewhale_config::ProviderKind::Deepseek
106 .provider()
107 .env_vars()
108 );
109 assert_eq!(
110 ApiProvider::DeepseekCN.default_base_url(),
111 DEFAULT_DEEPSEEKCN_BASE_URL
112 );
113 }
114
115 #[test]
116 fn every_api_provider_variant_resolves_base_url_without_panicking() {
117 // Guard against the historical `.expect("ApiProvider variant missing
118 // ProviderKind metadata")` in `default_base_url()`: a provider variant
119 // added without KIND_LOOKUP metadata used to hard-panic at startup or
120 // render. Every variant must resolve a non-empty base URL through the
121 // DeepSeek fallback when it has no registered metadata.
122 let mut constructed = 0usize;
123 for provider in ApiProvider::all() {
124 let url = provider.default_base_url();
125 assert!(!url.is_empty(), "{provider:?} default_base_url is empty");
126 constructed += 1;
127 }
128 // DeepseekCN is intentionally absent from `all()` (TUI-only legacy alias
129 // with its own config table) — cover it explicitly.
130 let url = ApiProvider::DeepseekCN.default_base_url();
131 assert!(!url.is_empty(), "DeepseekCN default_base_url is empty");
132 constructed += 1;
133
134 // Every variant of the enum must have been constructed above. If this
135 // assertion fails, a new variant was added without extending the lookup
136 // tables — extend `all()`/KIND_LOOKUP and re-run.
137 assert_eq!(
138 constructed,
139 ApiProvider::all().len() + 1,
140 "unconstructed ApiProvider variant"
141 );
142 }
143
144 #[test]
145 fn provider_config_key_follows_config_provider_metadata() {
146 for kind in codewhale_config::ProviderKind::ALL
147 .into_iter()
148 .filter(|kind| *kind != codewhale_config::ProviderKind::Deepseek)
149 {
150 let provider = ApiProvider::from_kind(kind);
151 assert_eq!(
152 provider_config_key(provider).expect("metadata-backed config key"),
153 kind.provider().provider_config_key()
154 );
155 }
156
157 assert!(provider_config_key(ApiProvider::Deepseek).is_err());
158 assert!(provider_config_key(ApiProvider::DeepseekCN).is_err());
159 }
160
161 #[test]
162 fn deepseek_api_key_reads_metadata_env_vars_for_newer_providers() -> Result<()> {
163 let _lock = lock_test_env();
164 let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
165 let cases = [
166 (ApiProvider::Zai, "ZAI_API_KEY", "zai-env-key"),
167 (ApiProvider::Stepfun, "STEPFUN_API_KEY", "stepfun-env-key"),
168 (ApiProvider::Minimax, "MINIMAX_API_KEY", "minimax-env-key"),
169 (
170 ApiProvider::MinimaxAnthropic,
171 "MINIMAX_API_KEY",
172 "minimax-env-key",
173 ),
174 (
175 ApiProvider::Deepinfra,
176 "DEEPINFRA_API_KEY",
177 "deepinfra-env-key",
178 ),
179 (ApiProvider::Sakana, "FUGU_API_KEY", "fugu-env-key"),
180 (
181 ApiProvider::Together,
182 "TOGETHER_API_KEY",
183 "together-env-key",
184 ),
185 (ApiProvider::Qianfan, "QIANFAN_API_KEY", "qianfan-env-key"),
186 (
187 ApiProvider::OpencodeGo,
188 "OPENCODE_GO_API_KEY",
189 "opencode-go-env-key",
190 ),
191 ];
192 let _env_guards: Vec<_> = cases
193 .iter()
194 .map(|(_, var, value)| EnvVarGuard::set(var, value))
195 .collect();
196
197 for (provider, _, expected_key) in cases {
198 let config = Config {
199 provider: Some(provider.as_str().to_string()),
200 ..Config::default()
201 };
202
203 assert_eq!(config.deepseek_api_key()?, expected_key);
204 }
205
206 Ok(())
207 }
208
209 #[test]
210 fn goal_max_continuations_loads_from_goal_table() -> Result<()> {
211 // Absent table → generous built-in default (#5052).
212 let config: Config = toml::from_str("")?;
213 assert_eq!(
214 config.goal_max_continuations(),
215 crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS
216 );
217
218 // Explicit backstop override.
219 let config: Config = toml::from_str(
220 r#"
221 [goal]
222 max_continuations = 25
223 "#,
224 )?;
225 assert_eq!(config.goal_max_continuations(), 25);
226
227 // 0 = unlimited-with-budget-stops.
228 let config: Config = toml::from_str(
229 r#"
230 [goal]
231 max_continuations = 0
232 "#,
233 )?;
234 assert_eq!(config.goal_max_continuations(), 0);
235
236 Ok(())
237 }
238
239 #[test]
240 fn modelstudio_coding_plan_mode_resolves_the_official_chat_base_url() {
241 // The picker represents Coding Plan as the primary Model Studio provider
242 // plus a mode, rather than switching to the legacy Coding Plan identity.
243 // Keep this config-resolution seam covered: chat-route reasoning support
244 // relies on receiving this exact official URL downstream.
245 let config: Config = toml::from_str(
246 r#"
247 provider = "modelstudio-token-plan"
248
249 [providers.modelstudio_token_plan]
250 mode = "coding-plan"
251 "#,
252 )
253 .expect("Coding Plan mode should parse");
254
255 assert_eq!(config.api_provider(), ApiProvider::ModelstudioTokenPlan);
256 assert_eq!(
257 config.deepseek_base_url(),
258 DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL
259 );
260 }
261
262 #[test]
263 fn provider_context_window_loads_from_provider_table() -> Result<()> {
264 let config: Config = toml::from_str(
265 r#"
266 provider = "openai"
267
268 [providers.openai]
269 model = "qwen3.7"
270 context_window = 1000000
271 "#,
272 )?;
273
274 config.validate()?;
275 assert_eq!(
276 config.context_window_for_provider_config(ApiProvider::Openai),
277 Some(1_000_000)
278 );
279
280 Ok(())
281 }
282
283 #[test]
284 fn provider_context_window_zero_is_invalid() {
285 let config: Config = toml::from_str(
286 r#"
287 [providers.openai]
288 context_window = 0
289 "#,
290 )
291 .expect("zero is syntactically valid TOML");
292
293 let err = config
294 .validate()
295 .expect_err("zero context_window should be rejected");
296 assert!(err.to_string().contains("providers.openai.context_window"));
297 }
298
299 #[test]
300 fn opencode_go_context_window_zero_is_invalid() {
301 let config: Config = toml::from_str(
302 r#"
303 [providers.opencode_go]
304 context_window = 0
305 "#,
306 )
307 .expect("zero is syntactically valid TOML");
308
309 let err = config
310 .validate()
311 .expect_err("zero OpenCode Go context_window should be rejected");
312 assert!(
313 err.to_string()
314 .contains("providers.opencode_go.context_window")
315 );
316 }
317
318 #[test]
319 fn missing_provider_api_key_message_uses_provider_metadata() -> Result<()> {
320 let message = missing_provider_api_key_message(ApiProvider::Zai)?;
321
322 assert!(message.contains("Zhipu AI / Z.ai API key not found"));
323 assert!(message.contains("https://z.ai/model-api"));
324 assert!(message.contains("ZAI_API_KEY / Z_AI_API_KEY"));
325 assert!(message.contains("[providers.zai] api_key"));
326
327 Ok(())
328 }
329
330 #[test]
331 fn opencode_zen_missing_credentials_never_mentions_codex_oauth() -> Result<()> {
332 let message = missing_provider_api_key_message(ApiProvider::OpencodeZen)?;
333 assert!(message.contains("OpenCode Zen API key not found"));
334 assert!(message.contains("OPENCODE_ZEN_API_KEY"));
335 assert!(message.contains("OPENCODE_API_KEY"));
336 assert!(message.contains("[providers.opencode_zen]"));
337 assert!(!message.contains("codex login"));
338 assert!(!message.contains("ChatGPT"));
339 assert!(!message.contains("auth.json"));
340 Ok(())
341 }
342
343 // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.
344 #[test]
345 fn allow_shell_defaults_to_false_when_unset() {
346 let config = Config::default();
347 assert_eq!(config.allow_shell, None, "default Config has no opt-in set");
348 assert!(
349 !config.allow_shell(),
350 "Config::allow_shell() must default to false when no opt-in is recorded"
351 );
352 }
353
354 // The interactive default is shell-on (approval-gated). Both interactive
355 // startup and the durable Agent permission baseline (app.rs) read this single
356 // method so the default cannot drift between launch modes; an explicit opt-out
357 // is still honored.
358 #[test]
359 fn interactive_allow_shell_defaults_to_true_but_honors_explicit_opt_out() {
360 let default_config = Config::default();
361 assert!(
362 default_config.interactive_allow_shell(),
363 "interactive Agent sessions expose shell by default so approvals can gate commands"
364 );
365
366 let opted_out = Config {
367 allow_shell: Some(false),
368 ..Config::default()
369 };
370 assert!(
371 !opted_out.interactive_allow_shell(),
372 "explicit allow_shell = false still hides shell in interactive sessions"
373 );
374
375 let opted_in = Config {
376 allow_shell: Some(true),
377 ..Config::default()
378 };
379 assert!(opted_in.interactive_allow_shell());
380 }
381
382 #[test]
383 fn prompt_suggestion_defaults_to_false() {
384 let config = Config::default();
385 assert_eq!(
386 config.prompt_suggestion, None,
387 "default Config must not opt in"
388 );
389 assert!(
390 !config.prompt_suggestion_enabled(),
391 "prompt_suggestion must be opt-in (default off)"
392 );
393 }
394
395 #[test]
396 fn prompt_suggestion_enabled_when_set_true() {
397 let config = Config {
398 prompt_suggestion: Some(true),
399 ..Default::default()
400 };
401 assert!(config.prompt_suggestion_enabled());
402 }
403
404 #[test]
405 fn auto_review_config_builds_runtime_policy() -> Result<()> {
406 let config: Config = toml::from_str(
407 r#"
408 [auto_review]
409 guidance = "Prefer review before remote side effects."
410
411 [[auto_review.block]]
412 id = "block-shell"
413 action_kind = "shell"
414 reason = "shell requires maintainer review"
415
416 [[auto_review.allow]]
417 id = "allow-read-file"
418 tool = "read_file"
419 reason = "read_file is allowed"
420 "#,
421 )?;
422 config.validate()?;
423
424 let policy = config.auto_review_policy();
425 assert_eq!(
426 policy.natural_language_guidance.as_deref(),
427 Some("Prefer review before remote side effects.")
428 );
429
430 let shell_context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
431 "exec_shell",
432 &serde_json::json!({"command": "cargo test"}),
433 crate::tui::auto_review::RunOrigin::Interactive,
434 crate::tui::approval::ApprovalMode::Auto,
435 Some("run tests"),
436 true,
437 false,
438 );
439 let shell_decision = policy.evaluate(&shell_context);
440 assert_eq!(
441 shell_decision.action,
442 crate::tui::auto_review::AutoReviewAction::Block
443 );
444 assert_eq!(shell_decision.rule_id.as_deref(), Some("block-shell"));
445
446 let read_context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
447 "read_file",
448 &serde_json::json!({"path": "README.md"}),
449 crate::tui::auto_review::RunOrigin::Interactive,
450 crate::tui::approval::ApprovalMode::Auto,
451 Some("read the docs"),
452 true,
453 false,
454 );
455 let read_decision = policy.evaluate(&read_context);
456 assert_eq!(
457 read_decision.action,
458 crate::tui::auto_review::AutoReviewAction::Allow
459 );
460 assert_eq!(read_decision.rule_id.as_deref(), Some("allow-read-file"));
461
462 Ok(())
463 }
464
465 #[test]
466 fn auto_review_profile_overrides_base_policy() -> Result<()> {
467 let parsed: ConfigFile = toml::from_str(
468 r#"
469 [auto_review]
470 guidance = "base"
471
472 [[auto_review.block]]
473 action_kind = "shell"
474
475 [profiles.strict.auto_review]
476 guidance = "strict"
477
478 [[profiles.strict.auto_review.block]]
479 action_kind = "network"
480 "#,
481 )?;
482
483 let merged = apply_profile(parsed, Some("strict"))?;
484 let policy = merged.auto_review_policy();
485
486 assert_eq!(policy.natural_language_guidance.as_deref(), Some("strict"));
487 assert_eq!(policy.block_rules.len(), 1);
488 assert_eq!(
489 policy.block_rules[0].action_kind,
490 Some(crate::tui::auto_review::ToolActionKind::Network)
491 );
492
493 Ok(())
494 }
495
496 #[test]
497 fn auto_review_config_rejects_invalid_rule_shapes() {
498 let invalid_kind: Config = toml::from_str(
499 r#"
500 [[auto_review.block]]
501 action_kind = "teleport"
502 "#,
503 )
504 .expect("parse config");
505 let err = invalid_kind.validate().expect_err("invalid kind");
506 assert!(
507 err.to_string()
508 .contains("Invalid auto_review.block[0].action_kind")
509 );
510
511 let global_allow: Config = toml::from_str(
512 r#"
513 [[auto_review.allow]]
514 reason = "too broad"
515 "#,
516 )
517 .expect("parse config");
518 let err = global_allow.validate().expect_err("missing matcher");
519 assert!(err.to_string().contains("set at least one of tool"));
520 }
521
522 #[test]
523 fn config_loads_sibling_permissions_into_exec_policy_engine() {
524 let dir = tempfile::tempdir().expect("tempdir");
525 let config_path = dir.path().join("config.toml");
526 fs::write(&config_path, "model = \"deepseek-v4-pro\"\n").expect("write config");
527 fs::write(
528 dir.path().join(codewhale_config::PERMISSIONS_FILE_NAME),
529 r#"
530 [[rules]]
531 tool = "exec_shell"
532 command = "cargo test"
533 "#,
534 )
535 .expect("write permissions");
536
537 let config = Config::load(Some(config_path), None).expect("load config");
538 let decision = config
539 .exec_policy_engine
540 .check(codewhale_execpolicy::ExecPolicyContext {
541 command: "cargo test --workspace",
542 cwd: dir.path().to_string_lossy().as_ref(),
543 tool: Some("exec_shell"),
544 path: None,
545 ask_for_approval: codewhale_execpolicy::AskForApproval::OnFailure,
546 sandbox_mode: None,
547 })
548 .expect("check permission");
549
550 assert!(decision.allow);
551 assert!(decision.requires_approval);
552 assert_eq!(
553 decision.matched_rule.as_deref(),
554 Some("tool=exec_shell command=cargo test")
555 );
556 }
557
558 #[test]
559 fn config_loads_sibling_permissions_when_config_file_is_absent() {
560 let dir = tempfile::tempdir().expect("tempdir");
561 let config_path = dir.path().join("config.toml");
562 fs::write(
563 dir.path().join(codewhale_config::PERMISSIONS_FILE_NAME),
564 r#"
565 [[rules]]
566 tool = "exec_shell"
567 command = "npm test"
568 "#,
569 )
570 .expect("write permissions");
571
572 let config = Config::load(Some(config_path), None).expect("load config");
573 let decision = config
574 .exec_policy_engine
575 .check(codewhale_execpolicy::ExecPolicyContext {
576 command: "npm test -- --runInBand",
577 cwd: dir.path().to_string_lossy().as_ref(),
578 tool: Some("exec_shell"),
579 path: None,
580 ask_for_approval: codewhale_execpolicy::AskForApproval::OnFailure,
581 sandbox_mode: None,
582 })
583 .expect("check permission");
584
585 assert!(decision.requires_approval);
586 assert_eq!(
587 decision.matched_rule.as_deref(),
588 Some("tool=exec_shell command=npm test")
589 );
590 }
591
592 #[test]
593 fn warns_when_allow_shell_nested_under_general_section() {
594 // #2589: the reporter's config nested top-level keys under sections that
595 // do not exist, so they were silently dropped and shell tools vanished.
596 let raw = "[general]\nallow_shell = true\n\n[sandbox]\nsandbox_mode = \"danger-full-access\"\n";
597 let warning =
598 warn_on_misplaced_top_level_keys(raw).expect("misplaced keys should produce a warning");
599 assert!(warning.contains("general.allow_shell"));
600 assert!(warning.contains("sandbox.sandbox_mode"));
601 assert!(warning.contains("#2589"));
602
603 // Correctly placed top-level keys produce no warning.
604 let ok = "allow_shell = true\nsandbox_mode = \"danger-full-access\"\n";
605 assert!(warn_on_misplaced_top_level_keys(ok).is_none());
606
607 // A parsed config from the correct placement actually enables shell.
608 let parsed: ConfigFile = toml::from_str(ok).expect("parse top-level config");
609 assert!(parsed.base.allow_shell());
610 }
611
612 #[test]
613 fn load_honors_codewhale_home_for_primary_config_path() -> Result<()> {
614 let _lock = lock_test_env();
615 let dir = tempfile::tempdir()?;
616 let codewhale_home = dir.path().join("isolated-codewhale");
617 fs::create_dir_all(&codewhale_home)?;
618 fs::write(codewhale_home.join("config.toml"), "provider = \"zai\"\n")?;
619 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
620 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
621 let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
622
623 let expected = codewhale_home.join("config.toml");
624 assert_eq!(default_config_path()?, expected);
625 let config = Config::load(None, None)?;
626
627 assert_eq!(config.provider.as_deref(), Some("zai"));
628 Ok(())
629 }
630
631 #[test]
632 fn load_accepts_dispatcher_written_camel_case_config_shape() -> Result<()> {
633 let _lock = lock_test_env();
634 let dir = tempfile::tempdir()?;
635 let codewhale_home = dir.path().join("isolated-codewhale");
636 fs::create_dir_all(&codewhale_home)?;
637 fs::write(
638 codewhale_home.join("config.toml"),
639 r#"
640 provider = "zai"
641 fallbackProviders = []
642 apiKey = "deepseek-test-key"
643 defaultTextModel = "deepseek-v4-pro"
644 authMode = "api_key"
645
646 [providers.zai]
647 apiKey = "zai-test-key"
648 authMode = "api_key"
649
650 [providers.zai.httpHeaders]
651
652 [providers.xiaomiMimo]
653 baseUrl = "https://token-plan-sgp.xiaomimimo.com/v1"
654
655 [features.enabled]
656 shell_tool = true
657 subagents = true
658 web_search = true
659 "#,
660 )?;
661 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
662 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
663 let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
664
665 let config = Config::load(None, None)?;
666
667 assert_eq!(config.provider.as_deref(), Some("zai"));
668 assert_eq!(config.api_key.as_deref(), Some("deepseek-test-key"));
669 assert_eq!(
670 config.default_text_model.as_deref(),
671 Some("deepseek-v4-pro")
672 );
673 assert_eq!(config.auth_mode.as_deref(), Some("api_key"));
674 let providers = config.providers.as_ref().expect("provider table");
675 assert_eq!(providers.zai.api_key.as_deref(), Some("zai-test-key"));
676 assert_eq!(providers.zai.auth_mode.as_deref(), Some("api_key"));
677 assert_eq!(
678 providers.xiaomi_mimo.base_url.as_deref(),
679 Some("https://token-plan-sgp.xiaomimimo.com/v1")
680 );
681 let features = config.features();
682 assert!(features.enabled(crate::features::Feature::ShellTool));
683 assert!(features.enabled(crate::features::Feature::Subagents));
684 assert!(features.enabled(crate::features::Feature::WebSearch));
685 Ok(())
686 }
687
688 #[test]
689 fn tui_config_parses_hotbar_bindings() {
690 let raw = r#"
691 [[hotbar]]
692 slot = 1
693 label = "Plan"
694 action = "mode.plan"
695
696 [[hotbar]]
697 slot = 2
698 action = "session.compact"
699 "#;
700 let parsed: ConfigFile = toml::from_str(raw).expect("parse hotbar config");
701
702 let resolved = parsed
703 .base
704 .resolve_hotbar_bindings(&["mode.plan", "session.compact"]);
705
706 assert_eq!(resolved.warnings, Vec::new());
707 assert_eq!(
708 resolved
709 .bindings
710 .iter()
711 .map(|binding| (
712 binding.slot,
713 binding.action.as_str(),
714 binding.label.as_deref()
715 ))
716 .collect::<Vec<_>>(),
717 vec![(1, "mode.plan", Some("Plan")), (2, "session.compact", None),]
718 );
719 }
720
721 #[test]
722 fn tui_config_empty_hotbar_array_disables_defaults() {
723 let parsed: ConfigFile = toml::from_str("hotbar = []\n").expect("parse empty hotbar");
724
725 let resolved = parsed
726 .base
727 .resolve_hotbar_bindings(&["mode.plan", "session.compact"]);
728
729 assert_eq!(resolved.warnings, Vec::new());
730 assert_eq!(resolved.bindings, Vec::new());
731 }
732
733 #[test]
734 fn profile_hotbar_override_replaces_entire_user_list() {
735 let mut profiles = HashMap::new();
736 profiles.insert(
737 "compact".to_string(),
738 Config {
739 hotbar: Some(vec![codewhale_config::HotbarBindingToml {
740 slot: 2,
741 action: "session.compact".to_string(),
742 label: Some("Compact".to_string()),
743 }]),
744 ..Config::default()
745 },
746 );
747 let config = ConfigFile {
748 base: Config {
749 hotbar: Some(vec![codewhale_config::HotbarBindingToml {
750 slot: 1,
751 action: "mode.plan".to_string(),
752 label: Some("Plan".to_string()),
753 }]),
754 ..Config::default()
755 },
756 profiles: Some(profiles),
757 };
758
759 let merged = apply_profile(config, Some("compact")).expect("profile");
760
761 assert_eq!(
762 merged.hotbar,
763 Some(vec![codewhale_config::HotbarBindingToml {
764 slot: 2,
765 action: "session.compact".to_string(),
766 label: Some("Compact".to_string()),
767 }])
768 );
769 }
770
771 #[test]
772 fn profile_without_hotbar_keeps_base_hotbar() {
773 let mut profiles = HashMap::new();
774 profiles.insert("work".to_string(), Config::default());
775 let config = ConfigFile {
776 base: Config {
777 hotbar: Some(vec![codewhale_config::HotbarBindingToml {
778 slot: 1,
779 action: "mode.plan".to_string(),
780 label: None,
781 }]),
782 ..Config::default()
783 },
784 profiles: Some(profiles),
785 };
786
787 let merged = apply_profile(config, Some("work")).expect("profile");
788
789 assert_eq!(
790 merged.hotbar,
791 Some(vec![codewhale_config::HotbarBindingToml {
792 slot: 1,
793 action: "mode.plan".to_string(),
794 label: None,
795 }])
796 );
797 }
798
799 #[test]
800 fn update_config_defaults_to_enabled_without_uri() {
801 let config = Config::default();
802 assert_eq!(config.update, None);
803 assert_eq!(config.update_config(), UpdateConfig::default());
804 assert!(config.update_config().check_for_updates);
805 assert_eq!(config.update_config().update_uri(), None);
806 }
807
808 #[test]
809 fn update_config_deserializes_disable_and_custom_uri() {
810 let config: Config = toml::from_str(
811 r#"
812 [update]
813 check_for_updates = false
814 update_uri = "https://mirror.example/releases/latest"
815 "#,
816 )
817 .expect("update config");
818
819 let update = config.update_config();
820 assert!(!update.check_for_updates);
821 assert_eq!(
822 update.update_uri(),
823 Some("https://mirror.example/releases/latest")
824 );
825 }
826
827 #[test]
828 fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {
829 let policy: NetworkPolicyToml = toml::from_str(
830 r#"
831 default = "allow"
832 proxy = ["github.com", ".githubusercontent.com"]
833 proxy_fake_ip_cidrs = ["198.18.0.0/15"]
834 "#,
835 )
836 .expect("network policy toml");
837
838 let runtime = policy.into_runtime();
839
840 assert_eq!(runtime.proxy, ["github.com", ".githubusercontent.com"]);
841 assert_eq!(runtime.proxy_fake_ip_cidrs, ["198.18.0.0/15"]);
842 assert!(runtime.trusts_proxy_fakeip_host("github.com"));
843 assert!(runtime.trusts_proxy_fakeip_host("raw.githubusercontent.com"));
844 }
845
846 #[test]
847 fn verifier_config_parses_hunt_policy_and_merges_overrides() {
848 let config: Config = toml::from_str(
849 r#"
850 [verifier]
851 enabled = true
852 verdict_policy = "hunt"
853 "#,
854 )
855 .expect("parse verifier config");
856
857 let verifier = config.verifier.expect("verifier table");
858 assert!(verifier.enabled);
859 assert_eq!(
860 verifier.verdict_policy,
861 codewhale_config::VerifierVerdictPolicy::Hunt
862 );
863
864 let merged = merge_config(
865 Config {
866 verifier: Some(codewhale_config::VerifierConfigToml {
867 enabled: false,
868 verdict_policy: codewhale_config::VerifierVerdictPolicy::Hunt,
869 }),
870 ..Config::default()
871 },
872 Config {
873 verifier: Some(codewhale_config::VerifierConfigToml {
874 enabled: true,
875 verdict_policy: codewhale_config::VerifierVerdictPolicy::Hunt,
876 }),
877 ..Config::default()
878 },
879 );
880
881 assert!(merged.verifier.expect("merged verifier").enabled);
882 }
883
884 #[test]
885 fn workflow_config_defaults_when_omitted_and_overrides_round_trip() {
886 // #4128: omitted `[workflow]` resolves through the accessor to product
887 // defaults; explicit overrides load and survive serialize → parse.
888 let omitted: Config = toml::from_str("").expect("empty config");
889 assert!(omitted.workflow.is_none());
890 assert_eq!(
891 omitted.workflow_config(),
892 codewhale_config::WorkflowConfigToml::default()
893 );
894
895 let config: Config = toml::from_str(
896 r#"
897 [workflow]
898 automatic = false
899 auto_start_read_only = false
900 require_approval_for_writes = true
901 auto_start_child_limit = 4
902 max_children = 32
903 max_depth = 1
904 default_token_budget = 90000
905 max_parallel_writes_without_worktree = 1
906 persist_completed_activity = false
907 persist_completed_across_restarts = false
908 "#,
909 )
910 .expect("parse workflow config");
911
912 let workflow = config.workflow.clone().expect("workflow table");
913 assert!(!workflow.automatic);
914 assert!(!workflow.auto_start_read_only);
915 assert!(workflow.require_approval_for_writes);
916 assert_eq!(workflow.auto_start_child_limit, 4);
917 assert_eq!(workflow.max_children, 32);
918 assert_eq!(workflow.max_depth, 1);
919 assert_eq!(workflow.default_token_budget, 90_000);
920 assert_eq!(workflow.max_parallel_writes_without_worktree, 1);
921 assert!(!workflow.persist_completed_activity);
922 assert!(!workflow.persist_completed_across_restarts);
923 assert_eq!(config.workflow_config(), workflow);
924
925 let serialized = toml::to_string_pretty(&workflow).expect("serialize workflow");
926 let round_tripped: codewhale_config::WorkflowConfigToml =
927 toml::from_str(&serialized).expect("round-trip parse");
928 assert_eq!(round_tripped, workflow);
929
930 // Profile/project overlays replace the whole table when present.
931 let merged = merge_config(
932 Config {
933 workflow: Some(codewhale_config::WorkflowConfigToml::default()),
934 ..Config::default()
935 },
936 Config {
937 workflow: Some(workflow.clone()),
938 ..Config::default()
939 },
940 );
941 assert_eq!(merged.workflow_config(), workflow);
942 }
943
944 #[test]
945 fn search_provider_defaults_to_duckduckgo() {
946 assert_eq!(SearchProvider::default(), SearchProvider::DuckDuckGo);
947 }
948
949 #[test]
950 fn tools_always_load_parses_and_trims_names() {
951 let parsed: ConfigFile = toml::from_str(
952 r#"
953 [tools]
954 always_load = ["git_show", " notify ", ""]
955 "#,
956 )
957 .expect("tools config");
958
959 let names = parsed.base.tools_always_load();
960
961 assert!(names.contains("git_show"));
962 assert!(names.contains("notify"));
963 assert!(!names.contains(""));
964 }
965
966 #[test]
967 fn explicit_duckduckgo_search_provider_is_preserved() {
968 let config: Config = toml::from_str(
969 r#"
970 [search]
971 provider = "duckduckgo"
972 "#,
973 )
974 .expect("search config");
975
976 assert_eq!(
977 config.search.and_then(|search| search.provider),
978 Some(SearchProvider::DuckDuckGo)
979 );
980 }
981
982 #[test]
983 fn search_config_preserves_custom_base_url() {
984 let config: Config = toml::from_str(
985 r#"
986 [search]
987 provider = "duckduckgo"
988 base_url = "https://search.internal.example/html/"
989 "#,
990 )
991 .expect("search config");
992
993 let search = config.search.expect("search table");
994 assert_eq!(search.provider, Some(SearchProvider::DuckDuckGo));
995 assert_eq!(
996 search.base_url.as_deref(),
997 Some("https://search.internal.example/html/")
998 );
999 }
1000
1001 #[test]
1002 fn explicit_searxng_search_provider_is_preserved() {
1003 let config: Config = toml::from_str(
1004 r#"
1005 [search]
1006 provider = "searxng"
1007 base_url = "https://search.internal.example/"
1008 "#,
1009 )
1010 .expect("search config");
1011
1012 let search = config.search.expect("search table");
1013 assert_eq!(search.provider, Some(SearchProvider::Searxng));
1014 assert_eq!(
1015 search.base_url.as_deref(),
1016 Some("https://search.internal.example/")
1017 );
1018 }
1019
1020 #[test]
1021 fn searxng_search_provider_aliases_parse_and_round_trip() {
1022 assert_eq!(
1023 SearchProvider::parse("searxng"),
1024 Some(SearchProvider::Searxng)
1025 );
1026 assert_eq!(
1027 SearchProvider::parse("searx-ng"),
1028 Some(SearchProvider::Searxng)
1029 );
1030 assert_eq!(
1031 SearchProvider::parse("searx_ng"),
1032 Some(SearchProvider::Searxng)
1033 );
1034 assert_eq!(
1035 SearchProvider::parse("searx"),
1036 Some(SearchProvider::Searxng)
1037 );
1038 assert_eq!(SearchProvider::Searxng.as_str(), "searxng");
1039 }
1040
1041 #[test]
1042 fn explicit_baidu_search_provider_is_preserved() {
1043 let config: Config = toml::from_str(
1044 r#"
1045 [search]
1046 provider = "baidu"
1047 "#,
1048 )
1049 .expect("search config");
1050
1051 assert_eq!(
1052 config.search.and_then(|search| search.provider),
1053 Some(SearchProvider::Baidu)
1054 );
1055 }
1056
1057 #[test]
1058 fn baidu_search_provider_aliases_parse() {
1059 assert_eq!(SearchProvider::parse("baidu"), Some(SearchProvider::Baidu));
1060 assert_eq!(
1061 SearchProvider::parse("baidu-search"),
1062 Some(SearchProvider::Baidu)
1063 );
1064 assert_eq!(
1065 SearchProvider::parse("baidu_ai_search"),
1066 Some(SearchProvider::Baidu)
1067 );
1068 }
1069
1070 #[test]
1071 fn volcengine_search_provider_aliases_parse_and_deserialize() {
1072 assert_eq!(
1073 SearchProvider::parse("volcengine"),
1074 Some(SearchProvider::Volcengine)
1075 );
1076 assert_eq!(
1077 SearchProvider::parse("volcengine-ark"),
1078 Some(SearchProvider::Volcengine)
1079 );
1080
1081 let config: Config = toml::from_str(
1082 r#"
1083 [search]
1084 provider = "volcengine-ark"
1085 "#,
1086 )
1087 .expect("volcengine search config");
1088
1089 assert_eq!(
1090 config.search.and_then(|search| search.provider),
1091 Some(SearchProvider::Volcengine)
1092 );
1093 }
1094
1095 #[test]
1096 fn explicit_sofya_search_provider_is_preserved() {
1097 let config: Config = toml::from_str(
1098 r#"
1099 [search]
1100 provider = "sofya"
1101 "#,
1102 )
1103 .expect("sofya search config");
1104
1105 assert_eq!(
1106 config.search.and_then(|search| search.provider),
1107 Some(SearchProvider::Sofya)
1108 );
1109 }
1110
1111 #[test]
1112 fn sofya_search_provider_parses_and_round_trips() {
1113 assert_eq!(SearchProvider::parse("sofya"), Some(SearchProvider::Sofya));
1114 assert_eq!(SearchProvider::parse("Sofya"), Some(SearchProvider::Sofya));
1115 assert_eq!(SearchProvider::Sofya.as_str(), "sofya");
1116 }
1117
1118 #[test]
1119 fn search_provider_resolution_reports_default_source() {
1120 let _guard = lock_test_env();
1121 let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
1122 unsafe { env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
1123
1124 let resolution = Config::default().search_provider_resolution();
1125
1126 unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
1127 assert_eq!(resolution.provider, SearchProvider::DuckDuckGo);
1128 assert_eq!(resolution.source, SearchProviderSource::Default);
1129 }
1130
1131 #[test]
1132 fn search_provider_resolution_reports_config_source() {
1133 let _guard = lock_test_env();
1134 let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
1135 unsafe { env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
1136 let config: Config = toml::from_str(
1137 r#"
1138 [search]
1139 provider = "tavily"
1140 "#,
1141 )
1142 .expect("search config");
1143
1144 let resolution = config.search_provider_resolution();
1145
1146 unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
1147 assert_eq!(resolution.provider, SearchProvider::Tavily);
1148 assert_eq!(resolution.source, SearchProviderSource::Config);
1149 }
1150
1151 #[test]
1152 fn search_provider_resolution_reports_env_override_source() {
1153 let _guard = lock_test_env();
1154 let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
1155 unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "bocha") };
1156 let config: Config = toml::from_str(
1157 r#"
1158 [search]
1159 provider = "duckduckgo"
1160 "#,
1161 )
1162 .expect("search config");
1163
1164 let resolution = config.search_provider_resolution();
1165
1166 unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
1167 assert_eq!(resolution.provider, SearchProvider::Bocha);
1168 assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
1169 }
1170
1171 #[test]
1172 fn search_provider_env_override_accepts_baidu() {
1173 let _guard = lock_test_env();
1174 let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
1175 unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "baidu") };
1176 let config: Config = toml::from_str(
1177 r#"
1178 [search]
1179 provider = "duckduckgo"
1180 "#,
1181 )
1182 .expect("search config");
1183
1184 let resolution = config.search_provider_resolution();
1185
1186 unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
1187 assert_eq!(resolution.provider, SearchProvider::Baidu);
1188 assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
1189 }
1190
1191 #[test]
1192 fn apply_env_overrides_sets_search_api_key() {
1193 let _guard = lock_test_env();
1194 let prev = env::var_os("DEEPSEEK_SEARCH_API_KEY");
1195 unsafe { env::set_var("DEEPSEEK_SEARCH_API_KEY", "search-env-key") };
1196 let mut config = Config::default();
1197
1198 apply_env_overrides(&mut config, ConfigEnvironmentPolicy::Runtime);
1199
1200 unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_API_KEY", prev) };
1201 assert_eq!(
1202 config.search.and_then(|search| search.api_key),
1203 Some("search-env-key".to_string())
1204 );
1205 }
1206
1207 #[test]
1208 fn structural_config_load_keeps_safe_environment_overrides_but_omits_secret_values() {
1209 let _guard = lock_test_env();
1210 let temp = tempfile::tempdir().expect("tempdir");
1211 let config_path = temp.path().join("config.toml");
1212 fs::write(&config_path, "").expect("empty config");
1213 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path().join("home"));
1214 let _profile = EnvVarGuard::remove("CODEWHALE_PROFILE");
1215 let _legacy_profile = EnvVarGuard::remove("DEEPSEEK_PROFILE");
1216 let _managed = EnvVarGuard::remove("CODEWHALE_MANAGED_CONFIG_PATH");
1217 let _legacy_managed = EnvVarGuard::remove("DEEPSEEK_MANAGED_CONFIG_PATH");
1218 let _requirements = EnvVarGuard::remove("CODEWHALE_REQUIREMENTS_PATH");
1219 let _legacy_requirements = EnvVarGuard::remove("DEEPSEEK_REQUIREMENTS_PATH");
1220 let _headers = EnvVarGuard::set(
1221 "CODEWHALE_HTTP_HEADERS",
1222 "Authorization=structural-header-secret",
1223 );
1224 let _legacy_headers = EnvVarGuard::set(
1225 "DEEPSEEK_HTTP_HEADERS",
1226 "Authorization=legacy-structural-header-secret",
1227 );
1228 let _sandbox_key = EnvVarGuard::set("CODEWHALE_SANDBOX_API_KEY", "structural-sandbox-secret");
1229 let _legacy_sandbox_key = EnvVarGuard::set(
1230 "DEEPSEEK_SANDBOX_API_KEY",
1231 "legacy-structural-sandbox-secret",
1232 );
1233 let _search_key = EnvVarGuard::set("CODEWHALE_SEARCH_API_KEY", "structural-search-secret");
1234 let _legacy_search_key =
1235 EnvVarGuard::set("DEEPSEEK_SEARCH_API_KEY", "legacy-structural-search-secret");
1236 let _base_url = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://safe.example:8443/v1");
1237 let _allow_shell = EnvVarGuard::set("CODEWHALE_ALLOW_SHELL", "false");
1238
1239 let runtime = Config::load(Some(config_path.clone()), None).expect("runtime config");
1240 assert!(runtime.http_headers.is_some());
1241 assert_eq!(
1242 runtime.sandbox_api_key.as_deref(),
1243 Some("structural-sandbox-secret")
1244 );
1245 assert_eq!(
1246 runtime
1247 .search
1248 .as_ref()
1249 .and_then(|search| search.api_key.as_deref()),
1250 Some("structural-search-secret")
1251 );
1252
1253 let structural = Config::load_structural(Some(config_path), None).expect("structural config");
1254 assert!(structural.http_headers.is_none());
1255 assert!(structural.sandbox_api_key.is_none());
1256 assert!(
1257 structural
1258 .search
1259 .as_ref()
1260 .and_then(|search| search.api_key.as_deref())
1261 .is_none()
1262 );
1263 assert_eq!(
1264 structural.base_url.as_deref(),
1265 Some("https://safe.example:8443/v1")
1266 );
1267 assert_eq!(structural.allow_shell, Some(false));
1268
1269 let rendered = format!("{structural:?}");
1270 for sentinel in [
1271 "structural-header-secret",
1272 "legacy-structural-header-secret",
1273 "structural-sandbox-secret",
1274 "legacy-structural-sandbox-secret",
1275 "structural-search-secret",
1276 "legacy-structural-search-secret",
1277 ] {
1278 assert!(
1279 !rendered.contains(sentinel),
1280 "structural config retained {sentinel}"
1281 );
1282 }
1283 }
1284
1285 #[test]
1286 fn apply_env_overrides_sets_search_base_url() {
1287 let _guard = lock_test_env();
1288 let prev_codewhale = env::var_os("CODEWHALE_SEARCH_BASE_URL");
1289 let prev_deepseek = env::var_os("DEEPSEEK_SEARCH_BASE_URL");
1290 unsafe {
1291 env::remove_var("CODEWHALE_SEARCH_BASE_URL");
1292 env::set_var(
1293 "DEEPSEEK_SEARCH_BASE_URL",
1294 "https://search.internal.example/html/",
1295 )
1296 };
1297 let mut config = Config::default();
1298
1299 apply_env_overrides(&mut config, ConfigEnvironmentPolicy::Runtime);
1300
1301 unsafe {
1302 EnvGuard::restore_var("CODEWHALE_SEARCH_BASE_URL", prev_codewhale);
1303 EnvGuard::restore_var("DEEPSEEK_SEARCH_BASE_URL", prev_deepseek);
1304 }
1305 assert_eq!(
1306 config.search.and_then(|search| search.base_url),
1307 Some("https://search.internal.example/html/".to_string())
1308 );
1309 }
1310
1311 #[test]
1312 fn codewhale_search_base_url_env_wins_over_legacy_alias() {
1313 let _guard = lock_test_env();
1314 let prev_codewhale = env::var_os("CODEWHALE_SEARCH_BASE_URL");
1315 let prev_deepseek = env::var_os("DEEPSEEK_SEARCH_BASE_URL");
1316 unsafe {
1317 env::set_var(
1318 "CODEWHALE_SEARCH_BASE_URL",
1319 "https://codewhale-search.example/html/",
1320 );
1321 env::set_var(
1322 "DEEPSEEK_SEARCH_BASE_URL",
1323 "https://legacy-search.example/html/",
1324 );
1325 }
1326 let mut config = Config::default();
1327
1328 apply_env_overrides(&mut config, ConfigEnvironmentPolicy::Runtime);
1329
1330 unsafe {
1331 EnvGuard::restore_var("CODEWHALE_SEARCH_BASE_URL", prev_codewhale);
1332 EnvGuard::restore_var("DEEPSEEK_SEARCH_BASE_URL", prev_deepseek);
1333 }
1334 assert_eq!(
1335 config.search.and_then(|search| search.base_url),
1336 Some("https://codewhale-search.example/html/".to_string())
1337 );
1338 }
1339
1340 #[test]
1341 fn search_provider_resolution_ignores_invalid_env_override() {
1342 let _guard = lock_test_env();
1343 let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
1344 unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "not-a-provider") };
1345 let config: Config = toml::from_str(
1346 r#"
1347 [search]
1348 provider = "tavily"
1349 "#,
1350 )
1351 .expect("search config");
1352
1353 let resolution = config.search_provider_resolution();
1354
1355 unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
1356 assert_eq!(resolution.provider, SearchProvider::Tavily);
1357 assert_eq!(resolution.source, SearchProviderSource::Config);
1358 }
1359
1360 struct EnvGuard {
1361 home: Option<OsString>,
1362 userprofile: Option<OsString>,
1363 codewhale_home: Option<OsString>,
1364 codewhale_config_path: Option<OsString>,
1365 deepseek_config_path: Option<OsString>,
1366 codewhale_secret_backend: Option<OsString>,
1367 deepseek_secret_backend: Option<OsString>,
1368 deepseek_provider: Option<OsString>,
1369 deepseek_api_key: Option<OsString>,
1370 deepseek_base_url: Option<OsString>,
1371 deepseek_http_headers: Option<OsString>,
1372 deepseek_model: Option<OsString>,
1373 deepseek_default_text_model: Option<OsString>,
1374 codewhale_provider: Option<OsString>,
1375 codewhale_model: Option<OsString>,
1376 codewhale_base_url: Option<OsString>,
1377 nvidia_api_key: Option<OsString>,
1378 nvidia_nim_api_key: Option<OsString>,
1379 nim_base_url: Option<OsString>,
1380 nvidia_base_url: Option<OsString>,
1381 nvidia_nim_base_url: Option<OsString>,
1382 nvidia_nim_model: Option<OsString>,
1383 openai_api_key: Option<OsString>,
1384 openai_base_url: Option<OsString>,
1385 openai_model: Option<OsString>,
1386 atlascloud_api_key: Option<OsString>,
1387 atlascloud_base_url: Option<OsString>,
1388 atlascloud_model: Option<OsString>,
1389 wanjie_ark_api_key: Option<OsString>,
1390 wanjie_api_key: Option<OsString>,
1391 wanjie_maas_api_key: Option<OsString>,
1392 wanjie_ark_base_url: Option<OsString>,
1393 wanjie_base_url: Option<OsString>,
1394 wanjie_maas_base_url: Option<OsString>,
1395 wanjie_ark_model: Option<OsString>,
1396 wanjie_model: Option<OsString>,
1397 wanjie_maas_model: Option<OsString>,
1398 openrouter_api_key: Option<OsString>,
1399 openrouter_base_url: Option<OsString>,
1400 openrouter_model: Option<OsString>,
1401 volcengine_api_key: Option<OsString>,
1402 volcengine_ark_api_key: Option<OsString>,
1403 ark_api_key: Option<OsString>,
1404 volcengine_base_url: Option<OsString>,
1405 volcengine_ark_base_url: Option<OsString>,
1406 ark_base_url: Option<OsString>,
1407 volcengine_model: Option<OsString>,
1408 volcengine_ark_model: Option<OsString>,
1409 xiaomi_mimo_token_plan_api_key: Option<OsString>,
1410 mimo_token_plan_api_key: Option<OsString>,
1411 xiaomi_mimo_api_key: Option<OsString>,
1412 xiaomi_api_key: Option<OsString>,
1413 mimo_api_key: Option<OsString>,
1414 xiaomi_mimo_base_url: Option<OsString>,
1415 mimo_base_url: Option<OsString>,
1416 xiaomi_mimo_model: Option<OsString>,
1417 mimo_model: Option<OsString>,
1418 xiaomi_mimo_mode: Option<OsString>,
1419 mimo_mode: Option<OsString>,
1420 novita_api_key: Option<OsString>,
1421 novita_base_url: Option<OsString>,
1422 novita_model: Option<OsString>,
1423 fireworks_api_key: Option<OsString>,
1424 fireworks_base_url: Option<OsString>,
1425 fireworks_model: Option<OsString>,
1426 siliconflow_api_key: Option<OsString>,
1427 siliconflow_base_url: Option<OsString>,
1428 siliconflow_model: Option<OsString>,
1429 arcee_api_key: Option<OsString>,
1430 arcee_base_url: Option<OsString>,
1431 arcee_model: Option<OsString>,
1432 moonshot_api_key: Option<OsString>,
1433 moonshot_base_url: Option<OsString>,
1434 moonshot_model: Option<OsString>,
1435 kimi_api_key: Option<OsString>,
1436 kimi_base_url: Option<OsString>,
1437 kimi_model: Option<OsString>,
1438 kimi_model_name: Option<OsString>,
1439 kimi_code_home: Option<OsString>,
1440 kimi_share_dir: Option<OsString>,
1441 sglang_api_key: Option<OsString>,
1442 sglang_base_url: Option<OsString>,
1443 sglang_model: Option<OsString>,
1444 vllm_api_key: Option<OsString>,
1445 vllm_base_url: Option<OsString>,
1446 vllm_model: Option<OsString>,
1447 ollama_api_key: Option<OsString>,
1448 ollama_base_url: Option<OsString>,
1449 ollama_model: Option<OsString>,
1450 huggingface_api_key: Option<OsString>,
1451 huggingface_token: Option<OsString>,
1452 huggingface_base_url: Option<OsString>,
1453 hf_base_url: Option<OsString>,
1454 huggingface_model: Option<OsString>,
1455 hf_model: Option<OsString>,
1456 }
1457
1458 impl EnvGuard {
1459 fn new(home: &Path) -> Self {
1460 let home_str = OsString::from(home.as_os_str());
1461 let config_path = home.join(".deepseek").join("config.toml");
1462 let config_str = OsString::from(config_path.as_os_str());
1463 let home_prev = env::var_os("HOME");
1464 let userprofile_prev = env::var_os("USERPROFILE");
1465 let codewhale_home_prev = env::var_os("CODEWHALE_HOME");
1466 let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH");
1467 let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
1468 let codewhale_secret_backend_prev = env::var_os("CODEWHALE_SECRET_BACKEND");
1469 let deepseek_secret_backend_prev = env::var_os("DEEPSEEK_SECRET_BACKEND");
1470 let deepseek_provider_prev = env::var_os("DEEPSEEK_PROVIDER");
1471 let api_key_prev = env::var_os("DEEPSEEK_API_KEY");
1472 let base_url_prev = env::var_os("DEEPSEEK_BASE_URL");
1473 let http_headers_prev = env::var_os("DEEPSEEK_HTTP_HEADERS");
1474 let model_prev = env::var_os("DEEPSEEK_MODEL");
1475 let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL");
1476 let codewhale_provider_prev = env::var_os("CODEWHALE_PROVIDER");
1477 let codewhale_model_prev = env::var_os("CODEWHALE_MODEL");
1478 let codewhale_base_url_prev = env::var_os("CODEWHALE_BASE_URL");
1479 let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY");
1480 let nvidia_nim_api_key_prev = env::var_os("NVIDIA_NIM_API_KEY");
1481 let nim_base_url_prev = env::var_os("NIM_BASE_URL");
1482 let nvidia_base_url_prev = env::var_os("NVIDIA_BASE_URL");
1483 let nvidia_nim_base_url_prev = env::var_os("NVIDIA_NIM_BASE_URL");
1484 let nvidia_nim_model_prev = env::var_os("NVIDIA_NIM_MODEL");
1485 let openai_api_key_prev = env::var_os("OPENAI_API_KEY");
1486 let openai_base_url_prev = env::var_os("OPENAI_BASE_URL");
1487 let openai_model_prev = env::var_os("OPENAI_MODEL");
1488 let atlascloud_api_key_prev = env::var_os("ATLASCLOUD_API_KEY");
1489 let atlascloud_base_url_prev = env::var_os("ATLASCLOUD_BASE_URL");
1490 let atlascloud_model_prev = env::var_os("ATLASCLOUD_MODEL");
1491 let wanjie_ark_api_key_prev = env::var_os("WANJIE_ARK_API_KEY");
1492 let wanjie_api_key_prev = env::var_os("WANJIE_API_KEY");
1493 let wanjie_maas_api_key_prev = env::var_os("WANJIE_MAAS_API_KEY");
1494 let wanjie_ark_base_url_prev = env::var_os("WANJIE_ARK_BASE_URL");
1495 let wanjie_base_url_prev = env::var_os("WANJIE_BASE_URL");
1496 let wanjie_maas_base_url_prev = env::var_os("WANJIE_MAAS_BASE_URL");
1497 let wanjie_ark_model_prev = env::var_os("WANJIE_ARK_MODEL");
1498 let wanjie_model_prev = env::var_os("WANJIE_MODEL");
1499 let wanjie_maas_model_prev = env::var_os("WANJIE_MAAS_MODEL");
1500 let openrouter_api_key_prev = env::var_os("OPENROUTER_API_KEY");
1501 let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL");
1502 let openrouter_model_prev = env::var_os("OPENROUTER_MODEL");
1503 let volcengine_api_key_prev = env::var_os("VOLCENGINE_API_KEY");
1504 let volcengine_ark_api_key_prev = env::var_os("VOLCENGINE_ARK_API_KEY");
1505 let ark_api_key_prev = env::var_os("ARK_API_KEY");
1506 let volcengine_base_url_prev = env::var_os("VOLCENGINE_BASE_URL");
1507 let volcengine_ark_base_url_prev = env::var_os("VOLCENGINE_ARK_BASE_URL");
1508 let ark_base_url_prev = env::var_os("ARK_BASE_URL");
1509 let volcengine_model_prev = env::var_os("VOLCENGINE_MODEL");
1510 let volcengine_ark_model_prev = env::var_os("VOLCENGINE_ARK_MODEL");
1511 let xiaomi_mimo_token_plan_api_key_prev = env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
1512 let mimo_token_plan_api_key_prev = env::var_os("MIMO_TOKEN_PLAN_API_KEY");
1513 let xiaomi_mimo_api_key_prev = env::var_os("XIAOMI_MIMO_API_KEY");
1514 let xiaomi_api_key_prev = env::var_os("XIAOMI_API_KEY");
1515 let mimo_api_key_prev = env::var_os("MIMO_API_KEY");
1516 let xiaomi_mimo_base_url_prev = env::var_os("XIAOMI_MIMO_BASE_URL");
1517 let mimo_base_url_prev = env::var_os("MIMO_BASE_URL");
1518 let xiaomi_mimo_model_prev = env::var_os("XIAOMI_MIMO_MODEL");
1519 let mimo_model_prev = env::var_os("MIMO_MODEL");
1520 let xiaomi_mimo_mode_prev = env::var_os("XIAOMI_MIMO_MODE");
1521 let mimo_mode_prev = env::var_os("MIMO_MODE");
1522 let novita_api_key_prev = env::var_os("NOVITA_API_KEY");
1523 let novita_base_url_prev = env::var_os("NOVITA_BASE_URL");
1524 let novita_model_prev = env::var_os("NOVITA_MODEL");
1525 let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY");
1526 let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL");
1527 let fireworks_model_prev = env::var_os("FIREWORKS_MODEL");
1528 let siliconflow_api_key_prev = env::var_os("SILICONFLOW_API_KEY");
1529 let siliconflow_base_url_prev = env::var_os("SILICONFLOW_BASE_URL");
1530 let siliconflow_model_prev = env::var_os("SILICONFLOW_MODEL");
1531 let arcee_api_key_prev = env::var_os("ARCEE_API_KEY");
1532 let arcee_base_url_prev = env::var_os("ARCEE_BASE_URL");
1533 let arcee_model_prev = env::var_os("ARCEE_MODEL");
1534 let moonshot_api_key_prev = env::var_os("MOONSHOT_API_KEY");
1535 let moonshot_base_url_prev = env::var_os("MOONSHOT_BASE_URL");
1536 let moonshot_model_prev = env::var_os("MOONSHOT_MODEL");
1537 let kimi_api_key_prev = env::var_os("KIMI_API_KEY");
1538 let kimi_base_url_prev = env::var_os("KIMI_BASE_URL");
1539 let kimi_model_prev = env::var_os("KIMI_MODEL");
1540 let kimi_model_name_prev = env::var_os("KIMI_MODEL_NAME");
1541 let kimi_code_home_prev = env::var_os("KIMI_CODE_HOME");
1542 let kimi_share_dir_prev = env::var_os("KIMI_SHARE_DIR");
1543 let sglang_api_key_prev = env::var_os("SGLANG_API_KEY");
1544 let sglang_base_url_prev = env::var_os("SGLANG_BASE_URL");
1545 let sglang_model_prev = env::var_os("SGLANG_MODEL");
1546 let vllm_api_key_prev = env::var_os("VLLM_API_KEY");
1547 let vllm_base_url_prev = env::var_os("VLLM_BASE_URL");
1548 let vllm_model_prev = env::var_os("VLLM_MODEL");
1549 let ollama_api_key_prev = env::var_os("OLLAMA_API_KEY");
1550 let ollama_base_url_prev = env::var_os("OLLAMA_BASE_URL");
1551 let ollama_model_prev = env::var_os("OLLAMA_MODEL");
1552 let huggingface_api_key_prev = env::var_os("HUGGINGFACE_API_KEY");
1553 let huggingface_token_prev = env::var_os("HF_TOKEN");
1554 let huggingface_base_url_prev = env::var_os("HUGGINGFACE_BASE_URL");
1555 let hf_base_url_prev = env::var_os("HF_BASE_URL");
1556 let huggingface_model_prev = env::var_os("HUGGINGFACE_MODEL");
1557 let hf_model_prev = env::var_os("HF_MODEL");
1558 // Safety: test-only environment mutation guarded by a global mutex.
1559 unsafe {
1560 env::set_var("HOME", &home_str);
1561 env::set_var("USERPROFILE", &home_str);
1562 env::remove_var("CODEWHALE_HOME");
1563 env::remove_var("CODEWHALE_CONFIG_PATH");
1564 env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
1565 env::remove_var("CODEWHALE_SECRET_BACKEND");
1566 env::remove_var("DEEPSEEK_SECRET_BACKEND");
1567 env::remove_var("DEEPSEEK_PROVIDER");
1568 env::remove_var("DEEPSEEK_API_KEY");
1569 env::remove_var("DEEPSEEK_BASE_URL");
1570 env::remove_var("DEEPSEEK_HTTP_HEADERS");
1571 env::remove_var("DEEPSEEK_MODEL");
1572 env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
1573 env::remove_var("CODEWHALE_PROVIDER");
1574 env::remove_var("CODEWHALE_MODEL");
1575 env::remove_var("CODEWHALE_BASE_URL");
1576 env::remove_var("NVIDIA_API_KEY");
1577 env::remove_var("NVIDIA_NIM_API_KEY");
1578 env::remove_var("NIM_BASE_URL");
1579 env::remove_var("NVIDIA_BASE_URL");
1580 env::remove_var("NVIDIA_NIM_BASE_URL");
1581 env::remove_var("NVIDIA_NIM_MODEL");
1582 env::remove_var("OPENAI_API_KEY");
1583 env::remove_var("OPENAI_BASE_URL");
1584 env::remove_var("OPENAI_MODEL");
1585 env::remove_var("ATLASCLOUD_API_KEY");
1586 env::remove_var("ATLASCLOUD_BASE_URL");
1587 env::remove_var("ATLASCLOUD_MODEL");
1588 env::remove_var("WANJIE_ARK_API_KEY");
1589 env::remove_var("WANJIE_API_KEY");
1590 env::remove_var("WANJIE_MAAS_API_KEY");
1591 env::remove_var("WANJIE_ARK_BASE_URL");
1592 env::remove_var("WANJIE_BASE_URL");
1593 env::remove_var("WANJIE_MAAS_BASE_URL");
1594 env::remove_var("WANJIE_ARK_MODEL");
1595 env::remove_var("WANJIE_MODEL");
1596 env::remove_var("WANJIE_MAAS_MODEL");
1597 env::remove_var("OPENROUTER_API_KEY");
1598 env::remove_var("OPENROUTER_BASE_URL");
1599 env::remove_var("OPENROUTER_MODEL");
1600 env::remove_var("VOLCENGINE_API_KEY");
1601 env::remove_var("VOLCENGINE_ARK_API_KEY");
1602 env::remove_var("ARK_API_KEY");
1603 env::remove_var("VOLCENGINE_BASE_URL");
1604 env::remove_var("VOLCENGINE_ARK_BASE_URL");
1605 env::remove_var("ARK_BASE_URL");
1606 env::remove_var("VOLCENGINE_MODEL");
1607 env::remove_var("VOLCENGINE_ARK_MODEL");
1608 env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
1609 env::remove_var("MIMO_TOKEN_PLAN_API_KEY");
1610 env::remove_var("XIAOMI_MIMO_API_KEY");
1611 env::remove_var("XIAOMI_API_KEY");
1612 env::remove_var("MIMO_API_KEY");
1613 env::remove_var("XIAOMI_MIMO_BASE_URL");
1614 env::remove_var("MIMO_BASE_URL");
1615 env::remove_var("XIAOMI_MIMO_MODEL");
1616 env::remove_var("MIMO_MODEL");
1617 env::remove_var("XIAOMI_MIMO_MODE");
1618 env::remove_var("MIMO_MODE");
1619 env::remove_var("NOVITA_API_KEY");
1620 env::remove_var("NOVITA_BASE_URL");
1621 env::remove_var("NOVITA_MODEL");
1622 env::remove_var("FIREWORKS_API_KEY");
1623 env::remove_var("FIREWORKS_BASE_URL");
1624 env::remove_var("FIREWORKS_MODEL");
1625 env::remove_var("SILICONFLOW_API_KEY");
1626 env::remove_var("SILICONFLOW_BASE_URL");
1627 env::remove_var("SILICONFLOW_MODEL");
1628 env::remove_var("ARCEE_API_KEY");
1629 env::remove_var("ARCEE_BASE_URL");
1630 env::remove_var("ARCEE_MODEL");
1631 env::remove_var("MOONSHOT_API_KEY");
1632 env::remove_var("MOONSHOT_BASE_URL");
1633 env::remove_var("MOONSHOT_MODEL");
1634 env::remove_var("KIMI_API_KEY");
1635 env::remove_var("KIMI_BASE_URL");
1636 env::remove_var("KIMI_MODEL");
1637 env::remove_var("KIMI_MODEL_NAME");
1638 env::remove_var("KIMI_CODE_HOME");
1639 env::remove_var("KIMI_SHARE_DIR");
1640 env::remove_var("SGLANG_API_KEY");
1641 env::remove_var("SGLANG_BASE_URL");
1642 env::remove_var("SGLANG_MODEL");
1643 env::remove_var("VLLM_API_KEY");
1644 env::remove_var("VLLM_BASE_URL");
1645 env::remove_var("VLLM_MODEL");
1646 env::remove_var("OLLAMA_API_KEY");
1647 env::remove_var("OLLAMA_BASE_URL");
1648 env::remove_var("OLLAMA_MODEL");
1649 env::remove_var("HUGGINGFACE_API_KEY");
1650 env::remove_var("HF_TOKEN");
1651 env::remove_var("HUGGINGFACE_BASE_URL");
1652 env::remove_var("HF_BASE_URL");
1653 env::remove_var("HUGGINGFACE_MODEL");
1654 env::remove_var("HF_MODEL");
1655 }
1656 Self {
1657 home: home_prev,
1658 userprofile: userprofile_prev,
1659 codewhale_home: codewhale_home_prev,
1660 codewhale_config_path: codewhale_config_prev,
1661 deepseek_config_path: deepseek_config_prev,
1662 codewhale_secret_backend: codewhale_secret_backend_prev,
1663 deepseek_secret_backend: deepseek_secret_backend_prev,
1664 deepseek_provider: deepseek_provider_prev,
1665 deepseek_api_key: api_key_prev,
1666 deepseek_base_url: base_url_prev,
1667 deepseek_http_headers: http_headers_prev,
1668 deepseek_model: model_prev,
1669 deepseek_default_text_model: default_text_model_prev,
1670 codewhale_provider: codewhale_provider_prev,
1671 codewhale_model: codewhale_model_prev,
1672 codewhale_base_url: codewhale_base_url_prev,
1673 nvidia_api_key: nvidia_api_key_prev,
1674 nvidia_nim_api_key: nvidia_nim_api_key_prev,
1675 nim_base_url: nim_base_url_prev,
1676 nvidia_base_url: nvidia_base_url_prev,
1677 nvidia_nim_base_url: nvidia_nim_base_url_prev,
1678 nvidia_nim_model: nvidia_nim_model_prev,
1679 openai_api_key: openai_api_key_prev,
1680 openai_base_url: openai_base_url_prev,
1681 openai_model: openai_model_prev,
1682 atlascloud_api_key: atlascloud_api_key_prev,
1683 atlascloud_base_url: atlascloud_base_url_prev,
1684 atlascloud_model: atlascloud_model_prev,
1685 wanjie_ark_api_key: wanjie_ark_api_key_prev,
1686 wanjie_api_key: wanjie_api_key_prev,
1687 wanjie_maas_api_key: wanjie_maas_api_key_prev,
1688 wanjie_ark_base_url: wanjie_ark_base_url_prev,
1689 wanjie_base_url: wanjie_base_url_prev,
1690 wanjie_maas_base_url: wanjie_maas_base_url_prev,
1691 wanjie_ark_model: wanjie_ark_model_prev,
1692 wanjie_model: wanjie_model_prev,
1693 wanjie_maas_model: wanjie_maas_model_prev,
1694 openrouter_api_key: openrouter_api_key_prev,
1695 openrouter_base_url: openrouter_base_url_prev,
1696 openrouter_model: openrouter_model_prev,
1697 volcengine_api_key: volcengine_api_key_prev,
1698 volcengine_ark_api_key: volcengine_ark_api_key_prev,
1699 ark_api_key: ark_api_key_prev,
1700 volcengine_base_url: volcengine_base_url_prev,
1701 volcengine_ark_base_url: volcengine_ark_base_url_prev,
1702 ark_base_url: ark_base_url_prev,
1703 volcengine_model: volcengine_model_prev,
1704 volcengine_ark_model: volcengine_ark_model_prev,
1705 xiaomi_mimo_token_plan_api_key: xiaomi_mimo_token_plan_api_key_prev,
1706 mimo_token_plan_api_key: mimo_token_plan_api_key_prev,
1707 xiaomi_mimo_api_key: xiaomi_mimo_api_key_prev,
1708 xiaomi_api_key: xiaomi_api_key_prev,
1709 mimo_api_key: mimo_api_key_prev,
1710 xiaomi_mimo_base_url: xiaomi_mimo_base_url_prev,
1711 mimo_base_url: mimo_base_url_prev,
1712 xiaomi_mimo_model: xiaomi_mimo_model_prev,
1713 mimo_model: mimo_model_prev,
1714 xiaomi_mimo_mode: xiaomi_mimo_mode_prev,
1715 mimo_mode: mimo_mode_prev,
1716 novita_api_key: novita_api_key_prev,
1717 novita_base_url: novita_base_url_prev,
1718 novita_model: novita_model_prev,
1719 fireworks_api_key: fireworks_api_key_prev,
1720 fireworks_base_url: fireworks_base_url_prev,
1721 fireworks_model: fireworks_model_prev,
1722 siliconflow_api_key: siliconflow_api_key_prev,
1723 siliconflow_base_url: siliconflow_base_url_prev,
1724 siliconflow_model: siliconflow_model_prev,
1725 arcee_api_key: arcee_api_key_prev,
1726 arcee_base_url: arcee_base_url_prev,
1727 arcee_model: arcee_model_prev,
1728 moonshot_api_key: moonshot_api_key_prev,
1729 moonshot_base_url: moonshot_base_url_prev,
1730 moonshot_model: moonshot_model_prev,
1731 kimi_api_key: kimi_api_key_prev,
1732 kimi_base_url: kimi_base_url_prev,
1733 kimi_model: kimi_model_prev,
1734 kimi_model_name: kimi_model_name_prev,
1735 kimi_code_home: kimi_code_home_prev,
1736 kimi_share_dir: kimi_share_dir_prev,
1737 sglang_api_key: sglang_api_key_prev,
1738 sglang_base_url: sglang_base_url_prev,
1739 sglang_model: sglang_model_prev,
1740 vllm_api_key: vllm_api_key_prev,
1741 vllm_base_url: vllm_base_url_prev,
1742 vllm_model: vllm_model_prev,
1743 ollama_api_key: ollama_api_key_prev,
1744 ollama_base_url: ollama_base_url_prev,
1745 ollama_model: ollama_model_prev,
1746 huggingface_api_key: huggingface_api_key_prev,
1747 huggingface_token: huggingface_token_prev,
1748 huggingface_base_url: huggingface_base_url_prev,
1749 hf_base_url: hf_base_url_prev,
1750 huggingface_model: huggingface_model_prev,
1751 hf_model: hf_model_prev,
1752 }
1753 }
1754 }
1755
1756 impl Drop for EnvGuard {
1757 fn drop(&mut self) {
1758 // Safety: test-only environment mutation guarded by a global mutex.
1759 unsafe {
1760 Self::restore_var("HOME", self.home.take());
1761 Self::restore_var("USERPROFILE", self.userprofile.take());
1762 Self::restore_var("CODEWHALE_HOME", self.codewhale_home.take());
1763 Self::restore_var("CODEWHALE_CONFIG_PATH", self.codewhale_config_path.take());
1764 Self::restore_var("DEEPSEEK_CONFIG_PATH", self.deepseek_config_path.take());
1765 Self::restore_var(
1766 "CODEWHALE_SECRET_BACKEND",
1767 self.codewhale_secret_backend.take(),
1768 );
1769 Self::restore_var(
1770 "DEEPSEEK_SECRET_BACKEND",
1771 self.deepseek_secret_backend.take(),
1772 );
1773 Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
1774 Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
1775 Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
1776 Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
1777 Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
1778 Self::restore_var(
1779 "DEEPSEEK_DEFAULT_TEXT_MODEL",
1780 self.deepseek_default_text_model.take(),
1781 );
1782 Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
1783 Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
1784 Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
1785 Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
1786 Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
1787 Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
1788 Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
1789 Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
1790 Self::restore_var("NVIDIA_NIM_MODEL", self.nvidia_nim_model.take());
1791 Self::restore_var("OPENAI_API_KEY", self.openai_api_key.take());
1792 Self::restore_var("OPENAI_BASE_URL", self.openai_base_url.take());
1793 Self::restore_var("OPENAI_MODEL", self.openai_model.take());
1794 Self::restore_var("ATLASCLOUD_API_KEY", self.atlascloud_api_key.take());
1795 Self::restore_var("ATLASCLOUD_BASE_URL", self.atlascloud_base_url.take());
1796 Self::restore_var("ATLASCLOUD_MODEL", self.atlascloud_model.take());
1797 Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
1798 Self::restore_var("WANJIE_API_KEY", self.wanjie_api_key.take());
1799 Self::restore_var("WANJIE_MAAS_API_KEY", self.wanjie_maas_api_key.take());
1800 Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
1801 Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
1802 Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
1803 Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
1804 Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
1805 Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
1806 Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
1807 Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
1808 Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take());
1809 Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take());
1810 Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take());
1811 Self::restore_var("ARK_API_KEY", self.ark_api_key.take());
1812 Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take());
1813 Self::restore_var(
1814 "VOLCENGINE_ARK_BASE_URL",
1815 self.volcengine_ark_base_url.take(),
1816 );
1817 Self::restore_var("ARK_BASE_URL", self.ark_base_url.take());
1818 Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
1819 Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take());
1820 Self::restore_var(
1821 "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
1822 self.xiaomi_mimo_token_plan_api_key.take(),
1823 );
1824 Self::restore_var(
1825 "MIMO_TOKEN_PLAN_API_KEY",
1826 self.mimo_token_plan_api_key.take(),
1827 );
1828 Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
1829 Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
1830 Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
1831 Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
1832 Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
1833 Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
1834 Self::restore_var("MIMO_MODEL", self.mimo_model.take());
1835 Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take());
1836 Self::restore_var("MIMO_MODE", self.mimo_mode.take());
1837 Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
1838 Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
1839 Self::restore_var("NOVITA_MODEL", self.novita_model.take());
1840 Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
1841 Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
1842 Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take());
1843 Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
1844 Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
1845 Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
1846 Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take());
1847 Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take());
1848 Self::restore_var("ARCEE_MODEL", self.arcee_model.take());
1849 Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
1850 Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
1851 Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
1852 Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
1853 Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
1854 Self::restore_var("KIMI_MODEL", self.kimi_model.take());
1855 Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
1856 Self::restore_var("KIMI_CODE_HOME", self.kimi_code_home.take());
1857 Self::restore_var("KIMI_SHARE_DIR", self.kimi_share_dir.take());
1858 Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
1859 Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
1860 Self::restore_var("SGLANG_MODEL", self.sglang_model.take());
1861 Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
1862 Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
1863 Self::restore_var("VLLM_MODEL", self.vllm_model.take());
1864 Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
1865 Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
1866 Self::restore_var("OLLAMA_MODEL", self.ollama_model.take());
1867 Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
1868 Self::restore_var("HF_TOKEN", self.huggingface_token.take());
1869 Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take());
1870 Self::restore_var("HF_BASE_URL", self.hf_base_url.take());
1871 Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take());
1872 Self::restore_var("HF_MODEL", self.hf_model.take());
1873 }
1874 }
1875 }
1876
1877 impl EnvGuard {
1878 /// Restore an env var to its prior value (or remove it if it was unset).
1879 ///
1880 /// # Safety
1881 /// Must only be called from test code guarded by a global mutex.
1882 unsafe fn restore_var(key: &str, prev: Option<OsString>) {
1883 if let Some(value) = prev {
1884 unsafe { env::set_var(key, value) };
1885 } else {
1886 unsafe { env::remove_var(key) };
1887 }
1888 }
1889 }
1890
1891 #[test]
1892 fn max_subagents_defaults_to_default_limit() {
1893 assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);
1894 assert_eq!(DEFAULT_MAX_SUBAGENTS, 64);
1895 }
1896
1897 #[test]
1898 fn launch_concurrency_defaults_and_clamps_to_max_subagents() {
1899 // Unset launch_concurrency now defaults to the full resolved cap.
1900 assert_eq!(
1901 Config::default().launch_concurrency(),
1902 Config::default().max_subagents()
1903 );
1904
1905 let mut config = Config {
1906 subagents: Some(SubagentsConfig {
1907 launch_concurrency: Some(50),
1908 ..SubagentsConfig::default()
1909 }),
1910 ..Config::default()
1911 };
1912 assert_eq!(config.launch_concurrency(), 50);
1913
1914 config.subagents = Some(SubagentsConfig {
1915 launch_concurrency: Some(DEFAULT_MAX_SUBAGENTS + 10),
1916 ..SubagentsConfig::default()
1917 });
1918 assert_eq!(config.launch_concurrency(), config.max_subagents());
1919
1920 config.subagents = Some(SubagentsConfig {
1921 launch_concurrency: Some(0),
1922 ..SubagentsConfig::default()
1923 });
1924 assert_eq!(config.launch_concurrency(), 1);
1925
1926 config.subagents = Some(SubagentsConfig {
1927 launch_concurrency: Some(2),
1928 ..SubagentsConfig::default()
1929 });
1930 assert_eq!(config.launch_concurrency(), 2);
1931 }
1932
1933 #[test]
1934 fn launch_concurrency_honors_deprecated_interactive_max_launch_alias() {
1935 // The old TOML key `interactive_max_launch` still deserializes, via
1936 // #[serde(rename)], into the hidden legacy field, and the resolver
1937 // honors it when the new key is unset.
1938 let cfg: SubagentsConfig =
1939 toml::from_str("interactive_max_launch = 5").expect("parse legacy key");
1940 assert_eq!(cfg.interactive_max_launch_legacy, Some(5));
1941 assert_eq!(cfg.launch_concurrency, None);
1942
1943 let config = Config {
1944 subagents: Some(cfg),
1945 ..Config::default()
1946 };
1947 assert_eq!(config.launch_concurrency(), 5);
1948 }
1949
1950 #[test]
1951 fn launch_concurrency_new_key_wins_over_deprecated_alias() {
1952 // When both keys are present the new `launch_concurrency` wins
1953 // deterministically, regardless of document order.
1954 let cfg: SubagentsConfig = toml::from_str("launch_concurrency = 3\ninteractive_max_launch = 7")
1955 .expect("parse both keys");
1956 assert_eq!(cfg.launch_concurrency, Some(3));
1957 assert_eq!(cfg.interactive_max_launch_legacy, Some(7));
1958
1959 let config = Config {
1960 subagents: Some(cfg),
1961 ..Config::default()
1962 };
1963 assert_eq!(config.launch_concurrency(), 3);
1964 }
1965
1966 #[test]
1967 fn fleet_role_model_keys_accept_canonical_and_legacy_names() {
1968 let canonical: Config = toml::from_str(
1969 r#"
1970 [subagents]
1971 scout_model = "scout-model"
1972 planner_model = "planner-model"
1973 reviewer_model = "reviewer-model"
1974 "#,
1975 )
1976 .expect("parse canonical Fleet role keys");
1977 let overrides = canonical.subagent_model_overrides();
1978 assert_eq!(
1979 overrides.get("scout").map(String::as_str),
1980 Some("scout-model")
1981 );
1982 assert_eq!(
1983 overrides.get("planner").map(String::as_str),
1984 Some("planner-model")
1985 );
1986 assert_eq!(
1987 overrides.get("reviewer").map(String::as_str),
1988 Some("reviewer-model")
1989 );
1990
1991 let legacy: Config = toml::from_str(
1992 r#"
1993 [subagents]
1994 explorer_model = "legacy-scout"
1995 awaiter_model = "legacy-planner"
1996 review_model = "legacy-reviewer"
1997 "#,
1998 )
1999 .expect("parse v0.9.x role aliases");
2000 let overrides = legacy.subagent_model_overrides();
2001 assert_eq!(
2002 overrides.get("scout").map(String::as_str),
2003 Some("legacy-scout")
2004 );
2005 assert_eq!(
2006 overrides.get("planner").map(String::as_str),
2007 Some("legacy-planner")
2008 );
2009 assert_eq!(
2010 overrides.get("reviewer").map(String::as_str),
2011 Some("legacy-reviewer")
2012 );
2013 }
2014
2015 #[test]
2016 fn subagent_token_budget_is_optional_and_zero_disables() {
2017 assert_eq!(Config::default().subagent_token_budget(), None);
2018
2019 let disabled = Config {
2020 subagents: Some(SubagentsConfig {
2021 token_budget: Some(0),
2022 ..SubagentsConfig::default()
2023 }),
2024 ..Config::default()
2025 };
2026 assert_eq!(disabled.subagent_token_budget(), None);
2027
2028 let configured = Config {
2029 subagents: Some(SubagentsConfig {
2030 token_budget: Some(50_000),
2031 ..SubagentsConfig::default()
2032 }),
2033 ..Config::default()
2034 };
2035 assert_eq!(configured.subagent_token_budget(), Some(50_000));
2036 }
2037
2038 #[test]
2039 fn subagent_admission_limit_defaults_and_clamps() {
2040 assert_eq!(
2041 Config::default().max_admitted_subagents(),
2042 MAX_SUBAGENT_ADMISSION
2043 );
2044
2045 let configured = Config {
2046 subagents: Some(SubagentsConfig {
2047 max_concurrent: Some(4),
2048 max_admitted: Some(80),
2049 ..SubagentsConfig::default()
2050 }),
2051 ..Config::default()
2052 };
2053 assert_eq!(configured.max_subagents(), 4);
2054 assert_eq!(configured.max_admitted_subagents(), 80);
2055
2056 let low = Config {
2057 subagents: Some(SubagentsConfig {
2058 max_concurrent: Some(4),
2059 max_admitted: Some(1),
2060 ..SubagentsConfig::default()
2061 }),
2062 ..Config::default()
2063 };
2064 assert_eq!(low.max_admitted_subagents(), 4);
2065
2066 let high = Config {
2067 subagents: Some(SubagentsConfig {
2068 max_admitted: Some(MAX_SUBAGENT_ADMISSION + 1),
2069 ..SubagentsConfig::default()
2070 }),
2071 ..Config::default()
2072 };
2073 assert_eq!(high.max_admitted_subagents(), MAX_SUBAGENT_ADMISSION);
2074
2075 let alias_cfg: SubagentsConfig =
2076 toml::from_str("admission_limit = 80").expect("parse admission alias");
2077 assert_eq!(alias_cfg.max_admitted, Some(80));
2078 }
2079
2080 #[test]
2081 fn provider_subagent_profiles_override_global_limits_with_aliases() {
2082 let config: Config = toml::from_str(
2083 r#"
2084 provider = "zai"
2085
2086 [subagents]
2087 max_concurrent = 20
2088 launch_concurrency = 20
2089 max_admitted = 200
2090 max_depth = 6
2091 token_budget = 100000
2092 api_timeout_secs = 900
2093 heartbeat_timeout_secs = 1200
2094
2095 [subagents.providers.glm]
2096 max_concurrent = 4
2097 launch_concurrency = 3
2098 max_admitted = 12
2099 max_depth = 2
2100 token_budget = 25000
2101 api_timeout_secs = 180
2102 heartbeat_timeout_secs = 240
2103 "#,
2104 )
2105 .expect("parse provider subagent profile");
2106
2107 assert_eq!(config.api_provider(), ApiProvider::Zai);
2108 assert_eq!(config.max_subagents(), 20);
2109 assert_eq!(config.max_subagents_for_provider(ApiProvider::Zai), 4);
2110 assert_eq!(config.launch_concurrency_for_provider(ApiProvider::Zai), 3);
2111 assert_eq!(
2112 config.max_admitted_subagents_for_provider(ApiProvider::Zai),
2113 12
2114 );
2115 assert_eq!(
2116 config.subagent_max_spawn_depth_for_provider(ApiProvider::Zai),
2117 2
2118 );
2119 assert_eq!(
2120 config.subagent_token_budget_for_provider(ApiProvider::Zai),
2121 Some(25_000)
2122 );
2123 assert_eq!(
2124 config.subagent_api_timeout_secs_for_provider(ApiProvider::Zai),
2125 180
2126 );
2127 // The explicit 240s provider override sits below the 300s tool timeout,
2128 // and a heartbeat under the tool timeout kills children mid-legitimate-
2129 // tool (activity is only recorded at step boundaries). The tool-timeout
2130 // floor lifts the resolved value above the override
2131 // (2026-08-04 sub-agent hunt, finding 4).
2132 assert_eq!(
2133 config.subagent_heartbeat_timeout_secs_for_provider(ApiProvider::Zai),
2134 DEFAULT_SUBAGENT_TOOL_TIMEOUT_SECS + 30
2135 );
2136 }
2137
2138 #[test]
2139 fn provider_request_concurrency_defaults_to_zai_and_can_be_overridden() {
2140 let default_zai: Config = toml::from_str(
2141 r#"
2142 provider = "zai"
2143 "#,
2144 )
2145 .expect("parse zai provider config");
2146 assert_eq!(
2147 default_zai.provider_max_concurrency(ApiProvider::Zai),
2148 Some(DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
2149 );
2150 assert_eq!(
2151 default_zai.provider_max_concurrency(ApiProvider::Deepseek),
2152 None
2153 );
2154
2155 let configured: Config = toml::from_str(
2156 r#"
2157 provider = "zai"
2158
2159 [providers.zhipu]
2160 max-concurrency = 10
2161 "#,
2162 )
2163 .expect("parse zhipu concurrency alias");
2164 assert_eq!(
2165 configured.provider_max_concurrency(ApiProvider::Zai),
2166 Some(10)
2167 );
2168
2169 let disabled: Config = toml::from_str(
2170 r#"
2171 provider = "zai"
2172
2173 [providers.zai]
2174 maxConcurrency = 0
2175 "#,
2176 )
2177 .expect("parse disabled concurrency cap");
2178 assert_eq!(disabled.provider_max_concurrency(ApiProvider::Zai), None);
2179
2180 let clamped: Config = toml::from_str(
2181 r#"
2182 [providers.openai]
2183 concurrency = 999
2184 "#,
2185 )
2186 .expect("parse openai concurrency alias");
2187 assert_eq!(
2188 clamped.provider_max_concurrency(ApiProvider::Openai),
2189 Some(MAX_PROVIDER_REQUEST_CONCURRENCY)
2190 );
2191 }
2192
2193 #[test]
2194 fn provider_subagent_profiles_inherit_and_clamp_against_provider_max() {
2195 let config: Config = toml::from_str(
2196 r#"
2197 [subagents]
2198 max_concurrent = 12
2199 launch_concurrency = 8
2200 max_depth = 5
2201 api_timeout_secs = 300
2202
2203 [subagents.providers.deepseek_api]
2204 max_concurrent = 30
2205 launch_concurrency = 30
2206 max_admitted = 1
2207
2208 [subagents.providers.anthropic]
2209 enabled = false
2210 "#,
2211 )
2212 .expect("parse inherited provider subagent profile");
2213
2214 assert_eq!(config.max_subagents_for_provider(ApiProvider::Deepseek), 30);
2215 assert_eq!(
2216 config.launch_concurrency_for_provider(ApiProvider::Deepseek),
2217 30
2218 );
2219 assert_eq!(
2220 config.max_admitted_subagents_for_provider(ApiProvider::Deepseek),
2221 30
2222 );
2223 assert_eq!(
2224 config.subagent_max_spawn_depth_for_provider(ApiProvider::Deepseek),
2225 5
2226 );
2227 assert_eq!(
2228 config.subagent_api_timeout_secs_for_provider(ApiProvider::Deepseek),
2229 300
2230 );
2231 assert!(config.subagents_enabled_for_provider(ApiProvider::Deepseek));
2232 assert!(!config.subagents_enabled_for_provider(ApiProvider::Anthropic));
2233 }
2234
2235 #[test]
2236 fn subagents_max_concurrent_overrides_top_level_cap() {
2237 let config = Config {
2238 max_subagents: Some(3),
2239 subagents: Some(SubagentsConfig {
2240 max_concurrent: Some(12),
2241 ..SubagentsConfig::default()
2242 }),
2243 ..Config::default()
2244 };
2245
2246 assert_eq!(config.max_subagents(), 12);
2247 }
2248
2249 #[test]
2250 fn max_subagents_clamps_subagents_max_concurrent() {
2251 let low = Config {
2252 subagents: Some(SubagentsConfig {
2253 max_concurrent: Some(0),
2254 ..SubagentsConfig::default()
2255 }),
2256 ..Config::default()
2257 };
2258 assert_eq!(low.max_subagents(), 1);
2259
2260 let high = Config {
2261 subagents: Some(SubagentsConfig {
2262 max_concurrent: Some(MAX_SUBAGENTS + 10),
2263 ..SubagentsConfig::default()
2264 }),
2265 ..Config::default()
2266 };
2267 assert_eq!(high.max_subagents(), MAX_SUBAGENTS);
2268 }
2269
2270 #[test]
2271 fn subagents_enabled_reports_disable_precedence() {
2272 assert!(Config::default().subagents_enabled());
2273
2274 let mut feature_disabled = Config::default();
2275 feature_disabled
2276 .set_feature("subagents", false)
2277 .expect("known feature");
2278 assert!(!feature_disabled.subagents_enabled());
2279 assert_eq!(
2280 feature_disabled.subagents_disabled_reason(),
2281 Some("features.subagents=false")
2282 );
2283
2284 let explicit_disabled = Config {
2285 subagents: Some(SubagentsConfig {
2286 enabled: Some(false),
2287 max_concurrent: Some(0),
2288 max_depth: Some(0),
2289 ..SubagentsConfig::default()
2290 }),
2291 ..Config::default()
2292 };
2293 assert!(!explicit_disabled.subagents_enabled());
2294 assert_eq!(
2295 explicit_disabled.subagents_disabled_reason(),
2296 Some("subagents.enabled=false")
2297 );
2298
2299 let zero_concurrency = Config {
2300 subagents: Some(SubagentsConfig {
2301 enabled: Some(true),
2302 max_concurrent: Some(0),
2303 max_depth: Some(1),
2304 ..SubagentsConfig::default()
2305 }),
2306 ..Config::default()
2307 };
2308 assert_eq!(
2309 zero_concurrency.subagents_disabled_reason(),
2310 Some("subagents.max_concurrent=0")
2311 );
2312
2313 let zero_depth = Config {
2314 subagents: Some(SubagentsConfig {
2315 enabled: Some(true),
2316 max_concurrent: Some(1),
2317 max_depth: Some(0),
2318 ..SubagentsConfig::default()
2319 }),
2320 ..Config::default()
2321 };
2322 assert_eq!(
2323 zero_depth.subagents_disabled_reason(),
2324 Some("subagents.max_depth=0")
2325 );
2326 }
2327
2328 #[test]
2329 fn subagent_max_spawn_depth_defaults_allows_zero_and_clamps() {
2330 assert_eq!(
2331 Config::default().subagent_max_spawn_depth(),
2332 codewhale_config::DEFAULT_SPAWN_DEPTH
2333 );
2334
2335 let disabled = Config {
2336 subagents: Some(SubagentsConfig {
2337 max_depth: Some(0),
2338 ..SubagentsConfig::default()
2339 }),
2340 ..Config::default()
2341 };
2342 assert_eq!(disabled.subagent_max_spawn_depth(), 0);
2343
2344 let high = Config {
2345 subagents: Some(SubagentsConfig {
2346 max_depth: Some(codewhale_config::MAX_SPAWN_DEPTH_CEILING + 10),
2347 ..SubagentsConfig::default()
2348 }),
2349 ..Config::default()
2350 };
2351 assert_eq!(
2352 high.subagent_max_spawn_depth(),
2353 codewhale_config::MAX_SPAWN_DEPTH_CEILING
2354 );
2355 }
2356
2357 #[test]
2358 fn subagent_api_timeout_defaults_and_clamps() {
2359 assert_eq!(
2360 Config::default().subagent_api_timeout_secs(),
2361 DEFAULT_SUBAGENT_API_TIMEOUT_SECS
2362 );
2363 assert_eq!(DEFAULT_SUBAGENT_API_TIMEOUT_SECS, 600);
2364
2365 let zero = Config {
2366 subagents: Some(SubagentsConfig {
2367 api_timeout_secs: Some(0),
2368 ..SubagentsConfig::default()
2369 }),
2370 ..Config::default()
2371 };
2372 assert_eq!(
2373 zero.subagent_api_timeout_secs(),
2374 DEFAULT_SUBAGENT_API_TIMEOUT_SECS
2375 );
2376
2377 let explicit_min = Config {
2378 subagents: Some(SubagentsConfig {
2379 api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),
2380 ..SubagentsConfig::default()
2381 }),
2382 ..Config::default()
2383 };
2384 assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);
2385
2386 let explicit_max = Config {
2387 subagents: Some(SubagentsConfig {
2388 api_timeout_secs: Some(3600),
2389 ..SubagentsConfig::default()
2390 }),
2391 ..Config::default()
2392 };
2393 assert_eq!(explicit_max.subagent_api_timeout_secs(), 3600);
2394
2395 let beyond_max = Config {
2396 subagents: Some(SubagentsConfig {
2397 api_timeout_secs: Some(3601),
2398 ..SubagentsConfig::default()
2399 }),
2400 ..Config::default()
2401 };
2402 assert_eq!(beyond_max.subagent_api_timeout_secs(), 3600);
2403
2404 let high = Config {
2405 subagents: Some(SubagentsConfig {
2406 api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),
2407 ..SubagentsConfig::default()
2408 }),
2409 ..Config::default()
2410 };
2411 assert_eq!(
2412 high.subagent_api_timeout_secs(),
2413 MAX_SUBAGENT_API_TIMEOUT_SECS
2414 );
2415 }
2416
2417 #[test]
2418 fn subagent_heartbeat_timeout_defaults_clamps_and_respects_api_timeout() {
2419 // With the 600s default API timeout, the heartbeat floor (api + 30s)
2420 // lifts the resolved default above the raw 300s constant. The tool
2421 // timeout floor (tool + 30s) also participates but sits below the API
2422 // floor at default settings.
2423 let resolved_default = DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS
2424 .max(DEFAULT_SUBAGENT_API_TIMEOUT_SECS + 30)
2425 .max(DEFAULT_SUBAGENT_TOOL_TIMEOUT_SECS + 30);
2426 assert_eq!(
2427 Config::default().subagent_heartbeat_timeout_secs(),
2428 resolved_default
2429 );
2430
2431 let zero = Config {
2432 subagents: Some(SubagentsConfig {
2433 heartbeat_timeout_secs: Some(0),
2434 ..SubagentsConfig::default()
2435 }),
2436 ..Config::default()
2437 };
2438 assert_eq!(zero.subagent_heartbeat_timeout_secs(), resolved_default);
2439
2440 // With a tiny API timeout the tool-timeout floor dominates: a single
2441 // tool execution can run the full tool timeout without touching the
2442 // heartbeat, so cleanup must not fire before tool_timeout + 30s even
2443 // though the API floor alone would be 31s (2026-08-04 sub-agent hunt,
2444 // finding 4 — this case resolved to 31 before the fix, which let
2445 // cleanup kill a child mid-legitimate-tool).
2446 let low = Config {
2447 subagents: Some(SubagentsConfig {
2448 api_timeout_secs: Some(1),
2449 heartbeat_timeout_secs: Some(1),
2450 ..SubagentsConfig::default()
2451 }),
2452 ..Config::default()
2453 };
2454 assert_eq!(
2455 low.subagent_heartbeat_timeout_secs(),
2456 DEFAULT_SUBAGENT_TOOL_TIMEOUT_SECS + 30
2457 );
2458
2459 let follows_long_api_timeout = Config {
2460 subagents: Some(SubagentsConfig {
2461 api_timeout_secs: Some(900),
2462 heartbeat_timeout_secs: Some(300),
2463 ..SubagentsConfig::default()
2464 }),
2465 ..Config::default()
2466 };
2467 assert_eq!(
2468 follows_long_api_timeout.subagent_heartbeat_timeout_secs(),
2469 930
2470 );
2471
2472 let high = Config {
2473 subagents: Some(SubagentsConfig {
2474 heartbeat_timeout_secs: Some(MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS + 60),
2475 ..SubagentsConfig::default()
2476 }),
2477 ..Config::default()
2478 };
2479 assert_eq!(
2480 high.subagent_heartbeat_timeout_secs(),
2481 MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS
2482 );
2483 }
2484
2485 #[test]
2486 fn subagent_heartbeat_floor_never_drops_below_tool_timeout_plus_margin() {
2487 // The safety property behind finding 4: for EVERY accepted combination of
2488 // `[subagents] api_timeout_secs` and `heartbeat_timeout_secs`, the
2489 // resolved heartbeat timeout stays above the tool timeout, because a tool
2490 // running up to `tool_timeout` produces no heartbeat activity. Corners
2491 // cover the smallest legal API timeout and heartbeat against both the
2492 // global and provider-specific resolvers.
2493 let corners: [Option<u64>; 4] = [
2494 Some(MIN_SUBAGENT_API_TIMEOUT_SECS),
2495 Some(1),
2496 None,
2497 Some(MAX_SUBAGENT_API_TIMEOUT_SECS),
2498 ];
2499 let heartbeats: [Option<u64>; 4] = [
2500 Some(MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS),
2501 Some(1),
2502 None,
2503 Some(0),
2504 ];
2505 for api in corners {
2506 for heartbeat in heartbeats {
2507 let cfg = Config {
2508 subagents: Some(SubagentsConfig {
2509 api_timeout_secs: api,
2510 heartbeat_timeout_secs: heartbeat,
2511 ..SubagentsConfig::default()
2512 }),
2513 ..Config::default()
2514 };
2515 let floor = DEFAULT_SUBAGENT_TOOL_TIMEOUT_SECS + 30;
2516 assert!(
2517 cfg.subagent_heartbeat_timeout_secs() >= floor,
2518 "global resolver: api={api:?} heartbeat={heartbeat:?} resolved {} < {floor}",
2519 cfg.subagent_heartbeat_timeout_secs()
2520 );
2521 assert!(
2522 cfg.subagent_heartbeat_timeout_secs_for_provider(ApiProvider::Deepseek) >= floor,
2523 "provider resolver: api={api:?} heartbeat={heartbeat:?} resolved {} < {floor}",
2524 cfg.subagent_heartbeat_timeout_secs_for_provider(ApiProvider::Deepseek)
2525 );
2526 }
2527 }
2528 }
2529
2530 #[test]
2531 fn tui_stream_chunk_timeout_defaults_env_and_clamps() {
2532 let _lock = lock_test_env();
2533 let previous = env::var_os(STREAM_CHUNK_TIMEOUT_ENV);
2534 unsafe {
2535 env::remove_var(STREAM_CHUNK_TIMEOUT_ENV);
2536 }
2537
2538 assert_eq!(
2539 Config::default().stream_chunk_timeout_secs(),
2540 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2541 );
2542
2543 let zero = Config {
2544 tui: Some(TuiConfig {
2545 stream_chunk_timeout_secs: Some(0),
2546 ..TuiConfig::default()
2547 }),
2548 ..Config::default()
2549 };
2550 assert_eq!(
2551 zero.stream_chunk_timeout_secs(),
2552 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2553 );
2554
2555 let explicit_min = Config {
2556 tui: Some(TuiConfig {
2557 stream_chunk_timeout_secs: Some(MIN_STREAM_CHUNK_TIMEOUT_SECS),
2558 ..TuiConfig::default()
2559 }),
2560 ..Config::default()
2561 };
2562 assert_eq!(
2563 explicit_min.stream_chunk_timeout_secs(),
2564 MIN_STREAM_CHUNK_TIMEOUT_SECS
2565 );
2566
2567 let high = Config {
2568 tui: Some(TuiConfig {
2569 stream_chunk_timeout_secs: Some(MAX_STREAM_CHUNK_TIMEOUT_SECS + 1),
2570 ..TuiConfig::default()
2571 }),
2572 ..Config::default()
2573 };
2574 assert_eq!(
2575 high.stream_chunk_timeout_secs(),
2576 MAX_STREAM_CHUNK_TIMEOUT_SECS
2577 );
2578
2579 unsafe {
2580 env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "123");
2581 }
2582 assert_eq!(Config::default().stream_chunk_timeout_secs(), 123);
2583
2584 unsafe {
2585 env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "0");
2586 }
2587 assert_eq!(
2588 Config::default().stream_chunk_timeout_secs(),
2589 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2590 );
2591
2592 unsafe {
2593 match previous {
2594 Some(value) => env::set_var(STREAM_CHUNK_TIMEOUT_ENV, value),
2595 None => env::remove_var(STREAM_CHUNK_TIMEOUT_ENV),
2596 }
2597 }
2598 }
2599
2600 #[test]
2601 fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {
2602 // `save_api_key` writes to the shared user config file. This
2603 // pins the boring v0.8.8 setup path and avoids platform
2604 // credential prompts during onboarding.
2605 let _lock = lock_test_env();
2606 let nanos = SystemTime::now()
2607 .duration_since(UNIX_EPOCH)
2608 .unwrap()
2609 .as_nanos();
2610 let temp_root = env::temp_dir().join(format!(
2611 "codewhale-tui-test-{}-{}",
2612 std::process::id(),
2613 nanos
2614 ));
2615 fs::create_dir_all(&temp_root)?;
2616 let _guard = EnvGuard::new(&temp_root);
2617
2618 let saved = save_api_key("test-key")?;
2619 let expected = temp_root.join(".deepseek").join("config.toml");
2620 assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));
2621 assert_eq!(saved.describe(), expected.display().to_string());
2622
2623 let contents = fs::read_to_string(&expected)?;
2624 assert!(contents.contains("api_key = \""));
2625
2626 #[cfg(unix)]
2627 {
2628 assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);
2629 let parent = expected.parent().expect("config has parent dir");
2630 assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);
2631
2632 fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;
2633 save_api_key("second-test-key")?;
2634 assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);
2635 }
2636 Ok(())
2637 }
2638
2639 #[test]
2640 fn policy_control_waits_for_foreign_test_env_overrides_to_restore() {
2641 let temp = tempfile::tempdir().expect("tempdir");
2642 let config_path = temp.path().join("config.toml");
2643 let workspace = temp.path().join("workspace");
2644 let managed_config_path = temp.path().join("missing-managed.toml");
2645 let requirements_path = temp.path().join("missing-requirements.toml");
2646 let (started_tx, started_rx) = mpsc::channel();
2647 let (tx, rx) = mpsc::channel();
2648
2649 let reader = {
2650 let lock = lock_test_env();
2651 let shell_override = EnvVarGuard::set("CODEWHALE_ALLOW_SHELL", "false");
2652 let approval_override = EnvVarGuard::set("DEEPSEEK_APPROVAL_POLICY", "never");
2653 let reader = std::thread::spawn(move || {
2654 started_tx.send(()).expect("signal policy read start");
2655 let config = Config {
2656 managed_config_path: Some(managed_config_path.display().to_string()),
2657 requirements_path: Some(requirements_path.display().to_string()),
2658 ..Config::default()
2659 };
2660 tx.send((
2661 config.allow_shell_control(Some(&config_path), None, &workspace),
2662 config.approval_policy_control(Some(&config_path), None, &workspace),
2663 ))
2664 .expect("send policy controls");
2665 });
2666
2667 started_rx
2668 .recv_timeout(Duration::from_secs(2))
2669 .expect("reader reached policy read");
2670 assert!(
2671 rx.recv_timeout(Duration::from_millis(50)).is_err(),
2672 "a foreign reader observed another test's temporary policy overrides"
2673 );
2674 drop(approval_override);
2675 drop(shell_override);
2676 drop(lock);
2677 reader
2678 };
2679
2680 let (shell, approval) = rx
2681 .recv_timeout(Duration::from_secs(2))
2682 .expect("reader resumed after policy overrides were restored");
2683 reader.join().expect("reader thread");
2684 assert_eq!(shell, ShellAccessControl::Unset);
2685 assert_eq!(approval, ApprovalPolicyControl::Unset);
2686 }
2687
2688 #[test]
2689 fn base_url_reads_wait_for_foreign_test_env_overrides_to_restore() {
2690 let (started_tx, started_rx) = mpsc::channel();
2691 let (tx, rx) = mpsc::channel();
2692
2693 let reader = {
2694 let lock = lock_test_env();
2695 let expected_after_restore = env_base_url_override();
2696 let override_guard =
2697 EnvVarGuard::set("CODEWHALE_BASE_URL", "https://temporary.test.invalid/v1");
2698 let reader = std::thread::spawn(move || {
2699 started_tx.send(()).expect("signal base URL read start");
2700 tx.send(env_base_url_override())
2701 .expect("send resolved base URL override");
2702 });
2703
2704 started_rx
2705 .recv_timeout(Duration::from_secs(2))
2706 .expect("reader reached base URL read");
2707 assert!(
2708 rx.recv_timeout(Duration::from_millis(50)).is_err(),
2709 "a foreign reader observed another test's temporary base URL override"
2710 );
2711 drop(override_guard);
2712 drop(lock);
2713 (reader, expected_after_restore)
2714 };
2715
2716 let observed = rx
2717 .recv_timeout(Duration::from_secs(2))
2718 .expect("reader resumed after base URL override was restored");
2719 reader.0.join().expect("reader thread");
2720 assert_eq!(observed, reader.1);
2721 }
2722
2723 #[test]
2724 fn save_api_key_onboarding_routes_openrouter_key_to_provider_table() -> Result<()> {
2725 let _lock = lock_test_env();
2726 let nanos = SystemTime::now()
2727 .duration_since(UNIX_EPOCH)
2728 .unwrap()
2729 .as_nanos();
2730 let temp_root = env::temp_dir().join(format!(
2731 "codewhale-tui-onboarding-provider-{}-{}",
2732 std::process::id(),
2733 nanos
2734 ));
2735 fs::create_dir_all(&temp_root)?;
2736 let _guard = EnvGuard::new(&temp_root);
2737
2738 let path = save_api_key_for(ApiProvider::Openrouter, "onboarding-openrouter-key")?;
2739 let contents = fs::read_to_string(&path)?;
2740 assert!(
2741 contents.contains("openrouter"),
2742 "expected OpenRouter provider table, got: {contents}"
2743 );
2744 assert!(contents.contains("onboarding-openrouter-key"));
2745 Ok(())
2746 }
2747
2748 #[test]
2749 fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {
2750 let _lock = lock_test_env();
2751 let nanos = SystemTime::now()
2752 .duration_since(UNIX_EPOCH)
2753 .unwrap()
2754 .as_nanos();
2755 let temp_root = env::temp_dir().join(format!(
2756 "codewhale-tui-first-run-config-{}-{}",
2757 std::process::id(),
2758 nanos
2759 ));
2760 fs::create_dir_all(&temp_root)?;
2761 let _guard = EnvGuard::new(&temp_root);
2762
2763 let created = ensure_config_file_exists(None)?.expect("should create config");
2764 let content = fs::read_to_string(&created)?;
2765
2766 assert_eq!(created, temp_root.join(".deepseek").join("config.toml"));
2767 assert!(content.contains("default_text_model = \"deepseek-v4-pro\""));
2768 assert!(content.contains("reasoning_effort = \"auto\""));
2769 assert!(!content.contains("api_key ="));
2770 assert!(ensure_config_file_exists(None)?.is_none());
2771 Ok(())
2772 }
2773
2774 #[test]
2775 fn workspace_trust_round_trips_through_global_config() -> Result<()> {
2776 let _lock = lock_test_env();
2777 let nanos = SystemTime::now()
2778 .duration_since(UNIX_EPOCH)
2779 .unwrap()
2780 .as_nanos();
2781 let temp_root = env::temp_dir().join(format!(
2782 "codewhale-tui-workspace-trust-{}-{}",
2783 std::process::id(),
2784 nanos
2785 ));
2786 fs::create_dir_all(&temp_root)?;
2787 let _guard = EnvGuard::new(&temp_root);
2788 let workspace = temp_root.join("project");
2789 fs::create_dir_all(&workspace)?;
2790
2791 assert!(!is_workspace_trusted(&workspace));
2792 let saved = save_workspace_trust(&workspace)?;
2793
2794 assert_eq!(saved, temp_root.join(".deepseek").join("config.toml"));
2795 assert!(is_workspace_trusted(&workspace));
2796 assert!(!crate::tui::onboarding::needs_trust(&workspace));
2797 assert!(
2798 !workspace.join(".deepseek").exists(),
2799 "trust persistence must not create a project-local .deepseek directory"
2800 );
2801
2802 let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;
2803 assert_eq!(
2804 workspace_trust_level_from_doc(&parsed, &workspace),
2805 Some("trusted")
2806 );
2807 Ok(())
2808 }
2809
2810 #[test]
2811 fn workspace_trust_reads_existing_projects_table() -> Result<()> {
2812 let _lock = lock_test_env();
2813 let nanos = SystemTime::now()
2814 .duration_since(UNIX_EPOCH)
2815 .unwrap()
2816 .as_nanos();
2817 let temp_root = env::temp_dir().join(format!(
2818 "codewhale-tui-existing-project-trust-{}-{}",
2819 std::process::id(),
2820 nanos
2821 ));
2822 fs::create_dir_all(&temp_root)?;
2823 let _guard = EnvGuard::new(&temp_root);
2824 let workspace = temp_root.join("project");
2825 fs::create_dir_all(&workspace)?;
2826 let config_path = temp_root.join(".deepseek").join("config.toml");
2827 fs::create_dir_all(config_path.parent().unwrap())?;
2828 fs::write(
2829 &config_path,
2830 format!(
2831 "[projects.\"{}\"]\ntrust_level = \"trusted\"\n",
2832 workspace_config_key(&workspace)
2833 .replace('\\', "\\\\")
2834 .replace('"', "\\\"")
2835 ),
2836 )?;
2837
2838 assert!(is_workspace_trusted(&workspace));
2839 assert!(!crate::tui::onboarding::needs_trust(&workspace));
2840 Ok(())
2841 }
2842
2843 #[test]
2844 fn save_api_key_rejects_empty_input() {
2845 let _lock = lock_test_env();
2846 let err = save_api_key(" ").expect_err("empty should bail");
2847 assert!(
2848 err.to_string().contains("empty"),
2849 "expected error to mention empty, got: {err}"
2850 );
2851 }
2852
2853 #[test]
2854 fn saved_credential_describe_returns_config_file_path() {
2855 let cf = SavedCredential::ConfigFile(PathBuf::from("/tmp/x.toml"));
2856 assert_eq!(cf.describe(), "/tmp/x.toml");
2857 }
2858
2859 /// The durable-store outcome makes it explicit that config contains metadata,
2860 /// not a second plaintext credential copy.
2861 #[test]
2862 fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {
2863 let dual = SavedCredential::KeyringAndConfigFile {
2864 backend: "system keyring".to_string(),
2865 path: PathBuf::from("/tmp/x.toml"),
2866 };
2867 assert_eq!(
2868 dual.describe(),
2869 "secret store (system keyring); credential-free config metadata in /tmp/x.toml"
2870 );
2871 }
2872
2873 #[test]
2874 fn save_deepseek_key_uses_isolated_file_store_without_plaintext_config() -> Result<()> {
2875 let _lock = lock_test_env();
2876 let temp_root = tempfile::tempdir()?;
2877 let _guard = EnvGuard::new(temp_root.path());
2878 let codewhale_home = temp_root.path().join("codewhale-home");
2879 let config_path = codewhale_home.join("config.toml");
2880 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
2881 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
2882 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
2883
2884 let saved = save_api_key("deepseek-test-credential")?;
2885 assert!(matches!(
2886 saved,
2887 SavedCredential::KeyringAndConfigFile { .. }
2888 ));
2889
2890 let config = fs::read_to_string(&config_path)?;
2891 assert!(!config.contains("deepseek-test-credential"), "{config}");
2892 assert!(
2893 !config
2894 .lines()
2895 .any(|line| line.trim_start().starts_with("api_key ="))
2896 );
2897 assert!(config.contains("auth_mode = \"api_key\""));
2898 assert_eq!(
2899 codewhale_secrets::Secrets::auto_detect().get("deepseek")?,
2900 Some("deepseek-test-credential".to_string())
2901 );
2902 Ok(())
2903 }
2904
2905 /// #5196: logout must remove the durable credential, not just the config
2906 /// file entry. After save + `clear_api_key()`, the whole read chain —
2907 /// secret-store slot first, config file second — must find nothing.
2908 #[test]
2909 fn full_logout_clears_secret_store_slot_and_config_document() -> Result<()> {
2910 let _lock = lock_test_env();
2911 let temp_root = tempfile::tempdir()?;
2912 // Canonicalize: the xAI credential walker opens each path component with
2913 // O_NOFOLLOW, so the lexical `/var` symlink in macOS tempdirs fails.
2914 let temp_root = temp_root.path().canonicalize()?;
2915 let _guard = EnvGuard::new(&temp_root);
2916 let codewhale_home = temp_root.join("codewhale-home");
2917 let config_path = codewhale_home.join("config.toml");
2918 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
2919 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
2920 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
2921
2922 let saved = save_api_key("logout-credential")?;
2923 assert!(matches!(
2924 saved,
2925 SavedCredential::KeyringAndConfigFile { .. }
2926 ));
2927 assert_eq!(
2928 codewhale_secrets::Secrets::auto_detect().get("deepseek")?,
2929 Some("logout-credential".to_string())
2930 );
2931
2932 clear_api_key()?;
2933
2934 assert_eq!(
2935 codewhale_secrets::Secrets::auto_detect().get("deepseek")?,
2936 None,
2937 "logout must delete the durable secret-store slot"
2938 );
2939 assert_eq!(
2940 provider_secret_store_api_key(&Config::default(), ApiProvider::Deepseek),
2941 None,
2942 "the read chain must not find a cleared credential"
2943 );
2944 let config = fs::read_to_string(&config_path)?;
2945 assert!(!config.contains("logout-credential"), "{config}");
2946 assert!(
2947 !config
2948 .lines()
2949 .any(|line| line.trim_start().starts_with("api_key =")),
2950 "{config}"
2951 );
2952 Ok(())
2953 }
2954
2955 /// #5196: the single-provider clear used by TUI `/logout` must delete that
2956 /// provider's secret-store slot as well as its config-file entry.
2957 #[test]
2958 fn single_provider_logout_clears_secret_store_slot() -> Result<()> {
2959 let _lock = lock_test_env();
2960 let temp_root = tempfile::tempdir()?;
2961 let temp_root = temp_root.path().canonicalize()?;
2962 let _guard = EnvGuard::new(&temp_root);
2963 let codewhale_home = temp_root.join("codewhale-home");
2964 let config_path = codewhale_home.join("config.toml");
2965 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
2966 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
2967 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
2968
2969 save_api_key_for(ApiProvider::Openrouter, "openrouter-logout-credential")?;
2970 assert_eq!(
2971 codewhale_secrets::Secrets::auto_detect().get("openrouter")?,
2972 Some("openrouter-logout-credential".to_string())
2973 );
2974
2975 clear_active_provider_api_key("openrouter")?;
2976
2977 assert_eq!(
2978 codewhale_secrets::Secrets::auto_detect().get("openrouter")?,
2979 None,
2980 "single-provider logout must delete the durable secret-store slot"
2981 );
2982 assert_eq!(
2983 provider_secret_store_api_key(&Config::default(), ApiProvider::Openrouter),
2984 None,
2985 "the read chain must not find a cleared credential"
2986 );
2987 Ok(())
2988 }
2989
2990 /// #5194: when both a config-file api_key and the provider's secret-store
2991 /// slot hold a credential, the shadowing warning names both sources, says
2992 /// which won, and hands over the resolve command.
2993 #[test]
2994 fn config_api_key_shadow_warning_names_sources_winner_and_resolution() -> Result<()> {
2995 let _lock = lock_test_env();
2996 let temp_root = tempfile::tempdir()?;
2997 let _guard = EnvGuard::new(temp_root.path());
2998 let codewhale_home = temp_root.path().join("codewhale-home");
2999 let config_path = codewhale_home.join("config.toml");
3000 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
3001 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
3002 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3003 codewhale_secrets::Secrets::auto_detect().set("openrouter", "store-key")?;
3004
3005 let mut config = Config::default();
3006 config
3007 .provider_config_for_mut(ApiProvider::Openrouter)
3008 .api_key = Some("plaintext-config-key".to_string());
3009
3010 let warning = config_api_key_shadow_warning(
3011 &config,
3012 ApiProvider::Openrouter,
3013 "`providers.openrouter` api_key",
3014 )
3015 .expect("a live secret-store slot shadowed by a config key must warn");
3016 assert!(
3017 warning.contains("`providers.openrouter` api_key"),
3018 "warning must name the config-file source: {warning}"
3019 );
3020 assert!(
3021 warning.contains("secret-store slot \"openrouter\""),
3022 "warning must name the secret-store source: {warning}"
3023 );
3024 assert!(
3025 warning.contains("the config-file key won"),
3026 "warning must say which source won: {warning}"
3027 );
3028 assert!(
3029 warning.contains("codewhale auth set --provider openrouter"),
3030 "warning must name the resolve command: {warning}"
3031 );
3032 Ok(())
3033 }
3034
3035 /// #5194: no secret-store credential, no shadow, no warning.
3036 #[test]
3037 fn config_api_key_shadow_warning_stays_quiet_without_a_store_slot() -> Result<()> {
3038 let _lock = lock_test_env();
3039 let temp_root = tempfile::tempdir()?;
3040 let _guard = EnvGuard::new(temp_root.path());
3041 let codewhale_home = temp_root.path().join("codewhale-home");
3042 let config_path = codewhale_home.join("config.toml");
3043 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
3044 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
3045 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3046
3047 let mut config = Config::default();
3048 config
3049 .provider_config_for_mut(ApiProvider::Openrouter)
3050 .api_key = Some("plaintext-config-key".to_string());
3051
3052 assert_eq!(
3053 config_api_key_shadow_warning(
3054 &config,
3055 ApiProvider::Openrouter,
3056 "`providers.openrouter` api_key"
3057 ),
3058 None,
3059 "a config key with no secret-store slot behind it is not a shadow"
3060 );
3061 Ok(())
3062 }
3063
3064 #[test]
3065 fn whitespace_codewhale_home_never_opens_ambient_file_secret_store() -> Result<()> {
3066 let _lock = lock_test_env();
3067 let temp_root = tempfile::tempdir()?;
3068 let ambient_home = temp_root.path().join("ambient-home");
3069 let config_path = temp_root.path().join("isolated-config.toml");
3070 fs::create_dir_all(&ambient_home)?;
3071 let _home = EnvVarGuard::set("HOME", &ambient_home);
3072 let _userprofile = EnvVarGuard::set("USERPROFILE", &ambient_home);
3073 let _codewhale_home_unset = EnvVarGuard::remove("CODEWHALE_HOME");
3074 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3075 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
3076 let ambient_store = codewhale_secrets::Secrets::file_backed();
3077 ambient_store.set("deepseek", "ambient-secret-sentinel")?;
3078 let ambient_secret_path = ambient_home
3079 .join(".codewhale")
3080 .join("secrets")
3081 .join("secrets.json");
3082 let before = fs::read(&ambient_secret_path)?;
3083 let _whitespace_home = EnvVarGuard::set("CODEWHALE_HOME", " \t ");
3084 let resolved_config_path = codewhale_config::resolve_config_path(None)?;
3085
3086 let read = provider_secret_store_api_key(&Config::default(), ApiProvider::Deepseek);
3087 let saved = save_api_key("replacement-secret-sentinel")?;
3088 let after = fs::read(&ambient_secret_path)?;
3089
3090 assert_eq!(
3091 read, None,
3092 "whitespace must not opt tests into ambient reads"
3093 );
3094 assert_eq!(saved, SavedCredential::ConfigFile(resolved_config_path));
3095 assert_eq!(after, before, "ambient file secret store was modified");
3096 Ok(())
3097 }
3098
3099 #[test]
3100 fn save_non_deepseek_key_uses_isolated_file_store_without_plaintext_config() -> Result<()> {
3101 let _lock = lock_test_env();
3102 let temp_root = tempfile::tempdir()?;
3103 let _guard = EnvGuard::new(temp_root.path());
3104 let codewhale_home = temp_root.path().join("codewhale-home");
3105 let config_path = codewhale_home.join("config.toml");
3106 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
3107 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
3108 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3109
3110 save_api_key_for(ApiProvider::Openrouter, "openrouter-test-credential")?;
3111
3112 let config = fs::read_to_string(&config_path)?;
3113 assert!(!config.contains("openrouter-test-credential"), "{config}");
3114 let parsed: toml::Value = toml::from_str(&config)?;
3115 let openrouter = parsed
3116 .get("providers")
3117 .and_then(|providers| providers.get("openrouter"))
3118 .expect("openrouter metadata table");
3119 assert!(openrouter.get("api_key").is_none());
3120 assert_eq!(
3121 openrouter.get("auth_mode").and_then(toml::Value::as_str),
3122 Some("api_key")
3123 );
3124 assert_eq!(
3125 codewhale_secrets::Secrets::auto_detect().get("openrouter")?,
3126 Some("openrouter-test-credential".to_string())
3127 );
3128 Ok(())
3129 }
3130
3131 #[test]
3132 fn provider_api_key_config_failure_restores_secret_and_keeps_external_route() -> Result<()> {
3133 let _lock = lock_test_env();
3134 for prior in [None, Some("prior-xai-secret")] {
3135 let temp_root = tempfile::tempdir()?;
3136 let _guard = EnvGuard::new(temp_root.path());
3137 let codewhale_home = temp_root.path().canonicalize()?.join("codewhale-home");
3138 fs::create_dir_all(&codewhale_home)?;
3139 let config_path = codewhale_home.join("config.toml");
3140 fs::create_dir(&config_path)?;
3141 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
3142 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
3143 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3144 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
3145 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
3146 store.write(generation, b"prior-owned-epoch", false)
3147 })?;
3148 let secrets = codewhale_secrets::Secrets::auto_detect();
3149 if let Some(prior) = prior {
3150 secrets.set("xai", prior)?;
3151 }
3152 let external_path = temp_root.path().join("external-grok.json");
3153 let route_config = Config {
3154 provider: Some(ApiProvider::Xai.as_str().to_string()),
3155 providers: Some(ProvidersConfig {
3156 xai: ProviderConfig {
3157 auth_mode: Some("oauth".to_string()),
3158 oauth_credential_generation: Some(generation.to_string()),
3159 external_credentials: Some(
3160 codewhale_config::ExternalCredentialConsentToml::read_only(
3161 codewhale_config::ProviderKind::Xai,
3162 codewhale_config::ExternalCredentialSource::GrokCli,
3163 external_path,
3164 ),
3165 ),
3166 ..ProviderConfig::default()
3167 },
3168 ..ProvidersConfig::default()
3169 }),
3170 ..Config::default()
3171 };
3172 let identity = ProviderIdentity {
3173 provider: ApiProvider::Xai,
3174 key: ApiProvider::Xai.as_str().to_string(),
3175 exact_id: Some(ApiProvider::Xai.as_str().to_string()),
3176 };
3177 let error = save_api_key_for_identity(&identity, &route_config, "new-xai-secret")
3178 .expect_err("config directory must reject metadata mutation");
3179 assert!(error.to_string().contains("config"), "{error:#}");
3180 assert_eq!(secrets.get("xai")?, prior.map(str::to_string));
3181 let xai = route_config
3182 .provider_config_for(ApiProvider::Xai)
3183 .expect("unchanged live route");
3184 assert_eq!(xai.auth_mode.as_deref(), Some("oauth"));
3185 assert!(xai.external_credentials.is_some());
3186 assert!(config_path.is_dir());
3187 assert_eq!(
3188 fs::read(codewhale_home.join("credentials").join(generation))?,
3189 b"prior-owned-epoch",
3190 "failed API-key mode switch must restore the prior OAuth epoch"
3191 );
3192 }
3193 Ok(())
3194 }
3195
3196 #[test]
3197 fn root_api_key_config_failure_restores_absent_and_existing_secret_state() -> Result<()> {
3198 let _lock = lock_test_env();
3199 for prior in [None, Some("prior-deepseek-secret")] {
3200 let temp_root = tempfile::tempdir()?;
3201 let _guard = EnvGuard::new(temp_root.path());
3202 let codewhale_home = temp_root.path().join("codewhale-home");
3203 fs::create_dir_all(&codewhale_home)?;
3204 let config_path = codewhale_home.join("config.toml");
3205 fs::create_dir(&config_path)?;
3206 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
3207 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
3208 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3209 let secrets = codewhale_secrets::Secrets::auto_detect();
3210 if let Some(prior) = prior {
3211 secrets.set("deepseek", prior)?;
3212 }
3213
3214 let error = save_api_key("new-deepseek-secret")
3215 .expect_err("config directory must reject root metadata mutation");
3216 assert!(error.to_string().contains("config"), "{error:#}");
3217 assert_eq!(secrets.get("deepseek")?, prior.map(str::to_string));
3218 assert!(config_path.is_dir());
3219 }
3220 Ok(())
3221 }
3222
3223 #[test]
3224 fn save_key_refuses_plaintext_config_when_isolated_file_store_is_unwritable() -> Result<()> {
3225 let _lock = lock_test_env();
3226 let temp_root = tempfile::tempdir()?;
3227 let _guard = EnvGuard::new(temp_root.path());
3228 let codewhale_home = temp_root.path().join("codewhale-home");
3229 let config_path = codewhale_home.join("config.toml");
3230 fs::create_dir_all(codewhale_home.join("secrets"))?;
3231 fs::write(
3232 codewhale_home.join("secrets/secrets.json"),
3233 "not valid json",
3234 )?;
3235 #[cfg(unix)]
3236 fs::set_permissions(
3237 codewhale_home.join("secrets/secrets.json"),
3238 fs::Permissions::from_mode(0o600),
3239 )?;
3240 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
3241 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
3242 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3243 let resolved_config_path = codewhale_config::resolve_config_path(None)?;
3244
3245 let error = save_api_key("fallback-test-credential")
3246 .expect_err("secret-store failure must not downgrade to plaintext");
3247 let message = format!("{error:#}");
3248 assert!(message.contains("Secret storage"), "{message}");
3249 assert!(message.contains("Refusing"), "{message}");
3250 assert!(
3251 message.contains(&codewhale_config::quote_os_path(&resolved_config_path)),
3252 "{message}"
3253 );
3254 assert!(
3255 !resolved_config_path.exists(),
3256 "plaintext config must stay untouched"
3257 );
3258 Ok(())
3259 }
3260
3261 #[test]
3262 fn provider_key_refuses_plaintext_config_when_secret_store_snapshot_fails() -> Result<()> {
3263 let _lock = lock_test_env();
3264 let temp_root = tempfile::tempdir()?;
3265 let _guard = EnvGuard::new(temp_root.path());
3266 let codewhale_home = temp_root.path().join("codewhale-home");
3267 let config_path = codewhale_home.join("config.toml");
3268 fs::create_dir_all(codewhale_home.join("secrets"))?;
3269 fs::write(
3270 codewhale_home.join("secrets/secrets.json"),
3271 "not valid json",
3272 )?;
3273 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
3274 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
3275 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3276 let resolved_config_path = codewhale_config::resolve_config_path(None)?;
3277 let identity = ProviderIdentity {
3278 provider: ApiProvider::Openrouter,
3279 key: ApiProvider::Openrouter.as_str().to_string(),
3280 exact_id: Some(ApiProvider::Openrouter.as_str().to_string()),
3281 };
3282
3283 let error = save_api_key_for_identity(&identity, &Config::default(), "provider-fallback-key")
3284 .expect_err("provider key must not downgrade to plaintext");
3285 let message = format!("{error:#}");
3286 assert!(message.contains("snapshot"), "{message}");
3287 assert!(
3288 message.contains(&codewhale_config::quote_os_path(&resolved_config_path)),
3289 "{message}"
3290 );
3291 assert!(
3292 !resolved_config_path.exists(),
3293 "plaintext config must stay untouched"
3294 );
3295 Ok(())
3296 }
3297
3298 #[test]
3299 fn relative_codewhale_home_key_save_creates_no_workspace_state() -> Result<()> {
3300 let _lock = lock_test_env();
3301 let temp_root = tempfile::tempdir()?;
3302 let _guard = EnvGuard::new(temp_root.path());
3303 let relative_home = PathBuf::from(format!(
3304 ".codewhale-relative-home-{}-{}",
3305 std::process::id(),
3306 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
3307 ));
3308 assert!(!relative_home.exists());
3309 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &relative_home);
3310 let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
3311 let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
3312 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
3313
3314 let error = save_api_key("never-persisted")
3315 .expect_err("relative CODEWHALE_HOME must fail before persistence");
3316 let message = format!("{error:#}");
3317 assert!(message.contains("CODEWHALE_HOME"), "{message}");
3318 assert!(message.contains("absolute"), "{message}");
3319 assert!(
3320 !relative_home.exists(),
3321 "relative home must not create workspace state"
3322 );
3323 Ok(())
3324 }
3325
3326 #[test]
3327 fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {
3328 // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free
3329 // sources used by `Config::deepseek_api_key` (in-memory override,
3330 // env var, config-file slot).
3331 let _lock = lock_test_env();
3332 // Explicit in-memory key wins over every other source per
3333 // `Config::deepseek_api_key`'s "Path 0" override.
3334 let cfg = Config {
3335 api_key: Some("sk-in-memory-override".to_string()),
3336 ..Default::default()
3337 };
3338 assert!(
3339 has_api_key(&cfg),
3340 "in-memory override must be detected as a usable key"
3341 );
3342
3343 // Env var path.
3344 let env_cfg = Config::default();
3345 unsafe {
3346 std::env::set_var("DEEPSEEK_API_KEY", "env-key");
3347 }
3348 assert!(
3349 has_api_key(&env_cfg),
3350 "env-var key must be detected even with empty config"
3351 );
3352 unsafe {
3353 std::env::remove_var("DEEPSEEK_API_KEY");
3354 }
3355 Ok(())
3356 }
3357
3358 #[test]
3359 fn deepseek_dispatcher_env_key_overrides_config_key() -> Result<()> {
3360 let _lock = lock_test_env();
3361 let prev_source = std::env::var_os("DEEPSEEK_API_KEY_SOURCE");
3362 unsafe {
3363 std::env::set_var("DEEPSEEK_API_KEY", "ark-dispatcher-key");
3364 std::env::set_var("DEEPSEEK_API_KEY_SOURCE", "cli");
3365 }
3366 let config = Config {
3367 api_key: Some("saved-deepseek-key".to_string()),
3368 ..Default::default()
3369 };
3370
3371 assert_eq!(config.deepseek_api_key()?, "ark-dispatcher-key");
3372
3373 unsafe {
3374 std::env::remove_var("DEEPSEEK_API_KEY");
3375 match prev_source {
3376 Some(value) => std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value),
3377 None => std::env::remove_var("DEEPSEEK_API_KEY_SOURCE"),
3378 }
3379 }
3380 Ok(())
3381 }
3382
3383 #[test]
3384 fn provider_neutral_cli_key_wins_after_profile_provider_switch() -> Result<()> {
3385 let _lock = lock_test_env();
3386 let _source = EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
3387 let _cli_key = EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-profile-key");
3388 let _anthropic_env = EnvVarGuard::remove("ANTHROPIC_API_KEY");
3389 let mut providers = ProvidersConfig::default();
3390 providers.anthropic.api_key = Some("saved-anthropic-key".to_string());
3391 let config = Config {
3392 provider: Some("anthropic".to_string()),
3393 providers: Some(providers),
3394 ..Default::default()
3395 };
3396
3397 assert_eq!(config.deepseek_api_key()?, "explicit-profile-key");
3398 assert!(has_api_key(&config));
3399 assert!(active_provider_has_env_api_key(&config));
3400 Ok(())
3401 }
3402
3403 #[test]
3404 fn provider_neutral_cli_key_requires_dispatcher_source_marker() -> Result<()> {
3405 let _lock = lock_test_env();
3406 let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
3407 let _cli_key = EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "untrusted-generic-key");
3408 let _anthropic_env = EnvVarGuard::remove("ANTHROPIC_API_KEY");
3409 let mut providers = ProvidersConfig::default();
3410 providers.anthropic.api_key = Some("saved-anthropic-key".to_string());
3411 let config = Config {
3412 provider: Some("anthropic".to_string()),
3413 providers: Some(providers),
3414 ..Default::default()
3415 };
3416
3417 assert_eq!(config.deepseek_api_key()?, "saved-anthropic-key");
3418 Ok(())
3419 }
3420
3421 fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {
3422 let mut providers = ProvidersConfig::default();
3423 match provider {
3424 "deepseek" | "deepseek-cn" => {
3425 providers.deepseek.api_key = Some(api_key.to_string());
3426 }
3427 "nvidia-nim" => {
3428 providers.nvidia_nim.api_key = Some(api_key.to_string());
3429 }
3430 "openai" => {
3431 providers.openai.api_key = Some(api_key.to_string());
3432 }
3433 "wanjie-ark" => {
3434 providers.wanjie_ark.api_key = Some(api_key.to_string());
3435 }
3436 "openrouter" => {
3437 providers.openrouter.api_key = Some(api_key.to_string());
3438 }
3439 "novita" => {
3440 providers.novita.api_key = Some(api_key.to_string());
3441 }
3442 "fireworks" => {
3443 providers.fireworks.api_key = Some(api_key.to_string());
3444 }
3445 "siliconflow" => {
3446 providers.siliconflow.api_key = Some(api_key.to_string());
3447 }
3448 "sglang" => {
3449 providers.sglang.api_key = Some(api_key.to_string());
3450 }
3451 "vllm" => {
3452 providers.vllm.api_key = Some(api_key.to_string());
3453 }
3454 "ollama" => {
3455 providers.ollama.api_key = Some(api_key.to_string());
3456 }
3457 "huggingface" => {
3458 providers.huggingface.api_key = Some(api_key.to_string());
3459 }
3460 "qianfan" => {
3461 providers.qianfan.api_key = Some(api_key.to_string());
3462 }
3463 _ => panic!("unexpected provider {provider}"),
3464 }
3465
3466 Config {
3467 provider: Some(provider.to_string()),
3468 providers: Some(providers),
3469 ..Config::default()
3470 }
3471 }
3472
3473 #[test]
3474 fn has_api_key_uses_active_provider_scoped_config_key() {
3475 // `has_api_key` intentionally consults live endpoint env overrides. Keep
3476 // this config-only assertion out of the windows where another test owns a
3477 // process-global custom endpoint.
3478 let _lock = lock_test_env();
3479 for provider in [
3480 "openai",
3481 "wanjie-ark",
3482 "openrouter",
3483 "novita",
3484 "fireworks",
3485 "siliconflow",
3486 "qianfan",
3487 ] {
3488 let config = config_with_provider_scoped_key(provider, "provider-config-key");
3489
3490 assert!(
3491 has_api_key(&config),
3492 "active provider config key must satisfy onboarding auth check for {provider}"
3493 );
3494 }
3495 }
3496
3497 #[test]
3498 fn has_api_key_uses_active_provider_env_key() -> Result<()> {
3499 let _lock = lock_test_env();
3500 for (provider, env_var) in [
3501 ("openai", "OPENAI_API_KEY"),
3502 ("wanjie-ark", "WANJIE_ARK_API_KEY"),
3503 ("openrouter", "OPENROUTER_API_KEY"),
3504 ("novita", "NOVITA_API_KEY"),
3505 ("fireworks", "FIREWORKS_API_KEY"),
3506 ("siliconflow", "SILICONFLOW_API_KEY"),
3507 ("qianfan", "QIANFAN_API_KEY"),
3508 ] {
3509 unsafe {
3510 std::env::set_var(env_var, "provider-env-key");
3511 }
3512
3513 let config = Config {
3514 provider: Some(provider.to_string()),
3515 ..Config::default()
3516 };
3517
3518 assert!(
3519 has_api_key(&config),
3520 "active provider env key must satisfy onboarding auth check for {provider}"
3521 );
3522
3523 unsafe {
3524 std::env::remove_var(env_var);
3525 }
3526 }
3527 Ok(())
3528 }
3529
3530 #[test]
3531 fn has_api_key_uses_root_config_key_for_deepseek_variants() {
3532 // A concurrent CODEWHALE_BASE_URL override deliberately unbinds the saved
3533 // root key from the active endpoint. Serialize this assertion with the
3534 // tests that install those process-global overrides.
3535 let _lock = lock_test_env();
3536 for provider in ["deepseek", "deepseek-cn"] {
3537 let config = Config {
3538 provider: Some(provider.to_string()),
3539 api_key: Some("root-config-key".to_string()),
3540 ..Config::default()
3541 };
3542
3543 assert!(
3544 has_api_key(&config),
3545 "root config api_key must satisfy onboarding auth check for {provider}"
3546 );
3547 }
3548 }
3549
3550 /// Regression for #343: clear_api_key strips both the root `api_key`
3551 /// and any nested `[providers.<name>].api_key` lines from config.toml
3552 /// so a stale credential can't shadow a fresh login.
3553 #[test]
3554 fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {
3555 let _lock = lock_test_env();
3556 let nanos = SystemTime::now()
3557 .duration_since(UNIX_EPOCH)
3558 .unwrap()
3559 .as_nanos();
3560 let temp_root = env::temp_dir().join(format!(
3561 "codewhale-tui-clear-{}-{}",
3562 std::process::id(),
3563 nanos
3564 ));
3565 fs::create_dir_all(&temp_root)?;
3566 let temp_root = temp_root.canonicalize()?;
3567 let _guard = EnvGuard::new(&temp_root);
3568
3569 let config_dir = temp_root.join(".deepseek");
3570 fs::create_dir_all(&config_dir)?;
3571 let config_path = config_dir.join("config.toml");
3572 fs::write(
3573 &config_path,
3574 r#"api_key = "old-root-key"
3575 default_text_model = "deepseek-v4-flash"
3576
3577 [providers.deepseek]
3578 api_key = "old-provider-key"
3579 base_url = "https://api.deepseek.com"
3580
3581 [providers.openrouter]
3582 api_key = "old-openrouter-key"
3583 "#,
3584 )?;
3585
3586 clear_api_key()?;
3587
3588 let after = fs::read_to_string(&config_path)?;
3589 assert!(
3590 !after.contains("old-root-key"),
3591 "root api_key must be stripped: {after}"
3592 );
3593 assert!(
3594 !after.contains("old-provider-key"),
3595 "provider-scoped codewhale key must be stripped: {after}"
3596 );
3597 assert!(
3598 !after.contains("old-openrouter-key"),
3599 "provider-scoped openrouter key must be stripped: {after}"
3600 );
3601 // Non-credential lines must survive.
3602 assert!(after.contains("default_text_model"));
3603 assert!(after.contains("base_url"));
3604 Ok(())
3605 }
3606
3607 /// Finding #20 golden: a comment that merely mentions `api_key` used to
3608 /// defeat the insert (the old `existing.contains("api_key")` scan treated it
3609 /// as an existing assignment and never wrote the key). The TOML-aware path
3610 /// must insert the real key and keep the comment.
3611 #[test]
3612 fn save_api_key_inserts_key_when_only_a_comment_mentions_it() -> Result<()> {
3613 let _lock = lock_test_env();
3614 let nanos = SystemTime::now()
3615 .duration_since(UNIX_EPOCH)
3616 .unwrap()
3617 .as_nanos();
3618 let temp_root = env::temp_dir().join(format!(
3619 "codewhale-tui-api-key-comment-{}-{}",
3620 std::process::id(),
3621 nanos
3622 ));
3623 fs::create_dir_all(&temp_root)?;
3624 let _guard = EnvGuard::new(&temp_root);
3625
3626 let config_path = temp_root.join(".deepseek").join("config.toml");
3627 fs::create_dir_all(config_path.parent().unwrap())?;
3628 fs::write(
3629 &config_path,
3630 "# api_key = \"sk-placeholder\" (uncomment to set manually)\n\
3631 default_text_model = \"deepseek-v4-flash\"\n",
3632 )?;
3633
3634 save_api_key("fresh-key")?;
3635
3636 let after = fs::read_to_string(&config_path)?;
3637 assert!(
3638 after.contains("# api_key = \"sk-placeholder\""),
3639 "comment must survive: {after}"
3640 );
3641 assert!(
3642 after.contains("default_text_model = \"deepseek-v4-flash\""),
3643 "unrelated key must survive: {after}"
3644 );
3645 let parsed: toml::Value = toml::from_str(&after)?;
3646 assert_eq!(
3647 parsed.get("api_key").and_then(toml::Value::as_str),
3648 Some("fresh-key"),
3649 "real key must be inserted despite the comment: {after}"
3650 );
3651 Ok(())
3652 }
3653
3654 /// Replacing an existing root api_key must keep surrounding comments,
3655 /// including the trailing comment on the api_key line itself.
3656 #[test]
3657 fn save_api_key_replaces_existing_key_preserving_comments() -> Result<()> {
3658 let _lock = lock_test_env();
3659 let nanos = SystemTime::now()
3660 .duration_since(UNIX_EPOCH)
3661 .unwrap()
3662 .as_nanos();
3663 let temp_root = env::temp_dir().join(format!(
3664 "codewhale-tui-api-key-replace-{}-{}",
3665 std::process::id(),
3666 nanos
3667 ));
3668 fs::create_dir_all(&temp_root)?;
3669 let _guard = EnvGuard::new(&temp_root);
3670
3671 let config_path = temp_root.join(".deepseek").join("config.toml");
3672 fs::create_dir_all(config_path.parent().unwrap())?;
3673 fs::write(
3674 &config_path,
3675 r#"# top note
3676 api_key = "old-key" # keep secret
3677 model = "deepseek-v4-pro"
3678
3679 # provider note
3680 [providers.openrouter]
3681 base_url = "https://openrouter.ai/api/v1"
3682 "#,
3683 )?;
3684
3685 save_api_key("new-key")?;
3686
3687 let after = fs::read_to_string(&config_path)?;
3688 assert!(
3689 after.contains("api_key = \"new-key\" # keep secret"),
3690 "value must be replaced in place with its comment: {after}"
3691 );
3692 assert!(!after.contains("old-key"), "{after}");
3693 assert!(after.contains("# top note"), "{after}");
3694 assert!(after.contains("# provider note"), "{after}");
3695 Ok(())
3696 }
3697
3698 /// Provider-scoped key saves used to round-trip through `toml::Value`
3699 /// pretty-printing, which dropped every comment in the file.
3700 #[test]
3701 fn save_api_key_for_preserves_comments_in_provider_tables() -> Result<()> {
3702 let _lock = lock_test_env();
3703 let nanos = SystemTime::now()
3704 .duration_since(UNIX_EPOCH)
3705 .unwrap()
3706 .as_nanos();
3707 let temp_root = env::temp_dir().join(format!(
3708 "codewhale-tui-provider-key-comments-{}-{}",
3709 std::process::id(),
3710 nanos
3711 ));
3712 fs::create_dir_all(&temp_root)?;
3713 let _guard = EnvGuard::new(&temp_root);
3714
3715 let config_path = temp_root.join(".deepseek").join("config.toml");
3716 fs::create_dir_all(config_path.parent().unwrap())?;
3717 fs::write(
3718 &config_path,
3719 r#"# root note
3720 model = "deepseek-v4-pro"
3721
3722 # openrouter note
3723 [providers.openrouter]
3724 base_url = "https://openrouter.ai/api/v1" # pinned
3725 "#,
3726 )?;
3727
3728 save_api_key_for(ApiProvider::Openrouter, "or-key")?;
3729
3730 let after = fs::read_to_string(&config_path)?;
3731 assert!(after.contains("# root note"), "{after}");
3732 assert!(after.contains("# openrouter note"), "{after}");
3733 assert!(
3734 after.contains("base_url = \"https://openrouter.ai/api/v1\" # pinned"),
3735 "inline comment must survive: {after}"
3736 );
3737 let parsed: toml::Value = toml::from_str(&after)?;
3738 assert_eq!(
3739 parsed
3740 .get("providers")
3741 .and_then(|providers| providers.get("openrouter"))
3742 .and_then(|entry| entry.get("api_key"))
3743 .and_then(toml::Value::as_str),
3744 Some("or-key"),
3745 "{after}"
3746 );
3747 Ok(())
3748 }
3749
3750 #[test]
3751 fn save_api_key_for_openai_codex_refuses_config_storage() {
3752 let err = save_api_key_for(ApiProvider::OpenaiCodex, "codex-token")
3753 .expect_err("Codex OAuth tokens must not be persisted as provider API keys");
3754
3755 let message = err.to_string();
3756 assert!(message.contains("OpenAI Codex uses OAuth"), "{message}");
3757 assert!(message.contains("codex login"), "{message}");
3758 }
3759
3760 /// Clearing credentials must not disturb comments, `api_key_env`, or
3761 /// provider tables with quoted names.
3762 #[test]
3763 fn clear_api_key_preserves_comments_and_unrelated_keys() -> Result<()> {
3764 let _lock = lock_test_env();
3765 let nanos = SystemTime::now()
3766 .duration_since(UNIX_EPOCH)
3767 .unwrap()
3768 .as_nanos();
3769 let temp_root = env::temp_dir().join(format!(
3770 "codewhale-tui-clear-comments-{}-{}",
3771 std::process::id(),
3772 nanos
3773 ));
3774 fs::create_dir_all(&temp_root)?;
3775 let temp_root = temp_root.canonicalize()?;
3776 let _guard = EnvGuard::new(&temp_root);
3777
3778 let config_path = temp_root.join(".deepseek").join("config.toml");
3779 fs::create_dir_all(config_path.parent().unwrap())?;
3780 fs::write(
3781 &config_path,
3782 r#"# root note
3783 api_key = "old-root-key"
3784 api_key_env = "MY_KEY_ENV"
3785 model = "deepseek-v4-pro"
3786
3787 # provider note
3788 [providers."quoted.provider"]
3789 api_key = "old-quoted-key"
3790 base_url = "https://quoted.example/v1"
3791 "#,
3792 )?;
3793
3794 clear_api_key()?;
3795
3796 let after = fs::read_to_string(&config_path)?;
3797 assert!(!after.contains("old-root-key"), "{after}");
3798 assert!(
3799 !after.contains("old-quoted-key"),
3800 "quoted provider table key must be stripped: {after}"
3801 );
3802 assert!(
3803 after.contains("api_key_env = \"MY_KEY_ENV\""),
3804 "api_key_env must not be stripped: {after}"
3805 );
3806 assert!(after.contains("# root note"), "{after}");
3807 assert!(after.contains("# provider note"), "{after}");
3808 assert!(after.contains("model = \"deepseek-v4-pro\""), "{after}");
3809 assert!(
3810 after.contains("base_url = \"https://quoted.example/v1\""),
3811 "{after}"
3812 );
3813 Ok(())
3814 }
3815
3816 /// The old line matcher compared against the literal `[providers.<name>]`
3817 /// header, so a quoted header (`[providers."openrouter"]`) was never
3818 /// matched and the key survived a targeted clear.
3819 #[test]
3820 fn clear_active_provider_api_key_handles_quoted_table_headers() -> Result<()> {
3821 let _lock = lock_test_env();
3822 let nanos = SystemTime::now()
3823 .duration_since(UNIX_EPOCH)
3824 .unwrap()
3825 .as_nanos();
3826 let temp_root = env::temp_dir().join(format!(
3827 "codewhale-tui-clear-quoted-{}-{}",
3828 std::process::id(),
3829 nanos
3830 ));
3831 fs::create_dir_all(&temp_root)?;
3832 let _guard = EnvGuard::new(&temp_root);
3833
3834 let config_path = temp_root.join(".deepseek").join("config.toml");
3835 fs::create_dir_all(config_path.parent().unwrap())?;
3836 fs::write(
3837 &config_path,
3838 r#"api_key = "root-key"
3839
3840 [providers."openrouter"]
3841 api_key = "old-openrouter-key"
3842 base_url = "https://openrouter.ai/api/v1"
3843 "#,
3844 )?;
3845
3846 clear_active_provider_api_key("openrouter")?;
3847
3848 let after = fs::read_to_string(&config_path)?;
3849 assert!(
3850 !after.contains("old-openrouter-key"),
3851 "quoted provider header must be matched: {after}"
3852 );
3853 assert!(
3854 after.contains("api_key = \"root-key\""),
3855 "root key belongs to deepseek and must survive: {after}"
3856 );
3857 assert!(
3858 after.contains("base_url = \"https://openrouter.ai/api/v1\""),
3859 "{after}"
3860 );
3861 Ok(())
3862 }
3863
3864 #[test]
3865 fn clear_active_provider_api_key_clears_deepseek_cn_root_scope() -> Result<()> {
3866 let _lock = lock_test_env();
3867 let nanos = SystemTime::now()
3868 .duration_since(UNIX_EPOCH)
3869 .unwrap()
3870 .as_nanos();
3871 let temp_root = env::temp_dir().join(format!(
3872 "codewhale-tui-clear-deepseek-cn-{}-{}",
3873 std::process::id(),
3874 nanos
3875 ));
3876 fs::create_dir_all(&temp_root)?;
3877 let _guard = EnvGuard::new(&temp_root);
3878 let config_path = temp_root.join(".deepseek").join("config.toml");
3879 fs::create_dir_all(config_path.parent().unwrap())?;
3880 fs::write(
3881 &config_path,
3882 r#"provider = "deepseek-cn"
3883 api_key = "deepseek-cn-root-key"
3884
3885 [providers.deepseek-cn]
3886 api_key = "deepseek-cn-table-key"
3887
3888 [providers.openrouter]
3889 api_key = "unrelated-key"
3890 "#,
3891 )?;
3892
3893 clear_active_provider_api_key("deepseek-cn")?;
3894
3895 let after = fs::read_to_string(&config_path)?;
3896 assert!(!after.contains("deepseek-cn-root-key"), "{after}");
3897 assert!(!after.contains("deepseek-cn-table-key"), "{after}");
3898 assert!(after.contains("unrelated-key"), "{after}");
3899 Ok(())
3900 }
3901
3902 #[test]
3903 fn clear_active_provider_api_key_distinguishes_literal_and_named_custom_routes() -> Result<()> {
3904 let _lock = lock_test_env();
3905 let nanos = SystemTime::now()
3906 .duration_since(UNIX_EPOCH)
3907 .unwrap()
3908 .as_nanos();
3909 let temp_root = env::temp_dir().join(format!(
3910 "codewhale-tui-clear-custom-{}-{}",
3911 std::process::id(),
3912 nanos
3913 ));
3914 fs::create_dir_all(&temp_root)?;
3915 let _guard = EnvGuard::new(&temp_root);
3916 let config_path = temp_root.join(".deepseek").join("config.toml");
3917 fs::create_dir_all(config_path.parent().unwrap())?;
3918 let contents = r#"provider = "custom"
3919 api_key = "legacy-root-key"
3920 base_url = "http://127.0.0.1:1234/v1"
3921 default_text_model = "legacy-model"
3922
3923 [providers.lm-studio]
3924 kind = "openai-compatible"
3925 api_key = "named-route-key"
3926 base_url = "http://127.0.0.1:5678/v1"
3927 model = "named-model"
3928 "#;
3929 fs::write(&config_path, contents)?;
3930
3931 clear_active_provider_api_key("custom")?;
3932
3933 let after_literal = fs::read_to_string(&config_path)?;
3934 assert!(
3935 !after_literal.contains("legacy-root-key"),
3936 "{after_literal}"
3937 );
3938 assert!(after_literal.contains("named-route-key"), "{after_literal}");
3939
3940 fs::write(&config_path, contents)?;
3941 clear_active_provider_api_key("lm-studio")?;
3942
3943 let after_named = fs::read_to_string(&config_path)?;
3944 assert!(after_named.contains("legacy-root-key"), "{after_named}");
3945 assert!(!after_named.contains("named-route-key"), "{after_named}");
3946 Ok(())
3947 }
3948
3949 #[test]
3950 fn clear_active_provider_api_key_prefers_exact_custom_table_over_legacy_root() -> Result<()> {
3951 let _lock = lock_test_env();
3952 let nanos = SystemTime::now()
3953 .duration_since(UNIX_EPOCH)
3954 .unwrap()
3955 .as_nanos();
3956 let temp_root = env::temp_dir().join(format!(
3957 "codewhale-tui-clear-exact-custom-{}-{}",
3958 std::process::id(),
3959 nanos
3960 ));
3961 fs::create_dir_all(&temp_root)?;
3962 let _guard = EnvGuard::new(&temp_root);
3963 let config_path = temp_root.join(".deepseek").join("config.toml");
3964 fs::create_dir_all(config_path.parent().unwrap())?;
3965 fs::write(
3966 &config_path,
3967 r#"provider = "custom"
3968 api_key = "legacy-root-key"
3969 base_url = "http://127.0.0.1:1234/v1"
3970 default_text_model = "legacy-model"
3971
3972 [providers.custom]
3973 kind = "openai-compatible"
3974 api_key = "exact-table-key"
3975 base_url = "http://127.0.0.1:5678/v1"
3976 model = "exact-model"
3977 "#,
3978 )?;
3979
3980 clear_active_provider_api_key("custom")?;
3981
3982 let after = fs::read_to_string(&config_path)?;
3983 assert!(after.contains("legacy-root-key"), "{after}");
3984 assert!(!after.contains("exact-table-key"), "{after}");
3985 assert!(after.contains("[providers.custom]"), "{after}");
3986 Ok(())
3987 }
3988
3989 /// Finding #19: workspace-trust saves used to round-trip through
3990 /// `toml::to_string_pretty`, destroying comments in the whole file.
3991 #[test]
3992 fn save_workspace_trust_preserves_comments() -> Result<()> {
3993 let _lock = lock_test_env();
3994 let nanos = SystemTime::now()
3995 .duration_since(UNIX_EPOCH)
3996 .unwrap()
3997 .as_nanos();
3998 let temp_root = env::temp_dir().join(format!(
3999 "codewhale-tui-trust-comments-{}-{}",
4000 std::process::id(),
4001 nanos
4002 ));
4003 fs::create_dir_all(&temp_root)?;
4004 let _guard = EnvGuard::new(&temp_root);
4005 let workspace = temp_root.join("project");
4006 fs::create_dir_all(&workspace)?;
4007
4008 let config_path = temp_root.join(".deepseek").join("config.toml");
4009 fs::create_dir_all(config_path.parent().unwrap())?;
4010 fs::write(
4011 &config_path,
4012 r#"# top note
4013 model = "deepseek-v4-pro"
4014
4015 # projects note
4016 [projects."/existing/workspace"]
4017 trust_level = "trusted" # granted earlier
4018 "#,
4019 )?;
4020
4021 save_workspace_trust(&workspace)?;
4022
4023 let after = fs::read_to_string(&config_path)?;
4024 assert!(after.contains("# top note"), "{after}");
4025 assert!(after.contains("# projects note"), "{after}");
4026 assert!(after.contains("# granted earlier"), "{after}");
4027 assert!(
4028 after.contains("[projects.\"/existing/workspace\"]"),
4029 "existing project entry must survive: {after}"
4030 );
4031 assert!(is_workspace_trusted(&workspace));
4032 Ok(())
4033 }
4034
4035 /// Regression for #343: explicit in-memory `api_key` (non-empty,
4036 /// non-sentinel) wins over env/config so a freshly-typed onboarding
4037 /// key takes effect immediately.
4038 #[test]
4039 fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {
4040 let _lock = lock_test_env();
4041 let nanos = SystemTime::now()
4042 .duration_since(UNIX_EPOCH)
4043 .unwrap()
4044 .as_nanos();
4045 let temp_root = env::temp_dir().join(format!(
4046 "codewhale-tui-override-{}-{}",
4047 std::process::id(),
4048 nanos
4049 ));
4050 fs::create_dir_all(&temp_root)?;
4051 let _guard = EnvGuard::new(&temp_root);
4052
4053 let config = Config {
4054 api_key: Some("freshly-typed-key".to_string()),
4055 ..Config::default()
4056 };
4057 let resolved = config
4058 .deepseek_api_key()
4059 .expect("explicit override must resolve");
4060 assert_eq!(resolved, "freshly-typed-key");
4061 Ok(())
4062 }
4063
4064 #[test]
4065 fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {
4066 let _lock = lock_test_env();
4067 let nanos = SystemTime::now()
4068 .duration_since(UNIX_EPOCH)
4069 .unwrap()
4070 .as_nanos();
4071 let temp_root = env::temp_dir().join(format!(
4072 "codewhale-tui-config-over-env-{}-{}",
4073 std::process::id(),
4074 nanos
4075 ));
4076 fs::create_dir_all(&temp_root)?;
4077 let _guard = EnvGuard::new(&temp_root);
4078
4079 unsafe {
4080 env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
4081 }
4082 let config = Config {
4083 api_key: Some("fresh-config-key".to_string()),
4084 ..Config::default()
4085 };
4086 assert_eq!(config.deepseek_api_key()?, "fresh-config-key");
4087 unsafe {
4088 env::remove_var("DEEPSEEK_API_KEY");
4089 }
4090 Ok(())
4091 }
4092
4093 #[test]
4094 fn standalone_tui_reads_saved_secret_before_ambient_env() -> Result<()> {
4095 let _lock = lock_test_env();
4096 let temp_root = tempfile::tempdir()?;
4097 let _guard = EnvGuard::new(temp_root.path());
4098 let codewhale_home = temp_root.path().join("isolated-codewhale");
4099 fs::create_dir_all(&codewhale_home)?;
4100 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
4101 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
4102 let _ambient_key = EnvVarGuard::set("DEEPSEEK_API_KEY", "stale-env-key");
4103
4104 let secrets = codewhale_secrets::Secrets::auto_detect();
4105 secrets.set("deepseek", "saved-secret-key")?;
4106
4107 let config = Config::default();
4108 assert_eq!(config.deepseek_api_key()?, "saved-secret-key");
4109 assert!(has_api_key(&config));
4110 assert!(active_provider_has_config_api_key(&config));
4111
4112 let configured = Config {
4113 api_key: Some("fresh-config-key".to_string()),
4114 ..Config::default()
4115 };
4116 assert_eq!(configured.deepseek_api_key()?, "fresh-config-key");
4117 Ok(())
4118 }
4119
4120 #[test]
4121 fn authenticated_local_provider_reads_saved_secret() -> Result<()> {
4122 let _lock = lock_test_env();
4123 let temp_root = tempfile::tempdir()?;
4124 let _guard = EnvGuard::new(temp_root.path());
4125 let codewhale_home = temp_root.path().join("isolated-codewhale");
4126 fs::create_dir_all(&codewhale_home)?;
4127 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
4128 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
4129 let _ambient_key = EnvVarGuard::remove("VLLM_API_KEY");
4130
4131 codewhale_secrets::Secrets::auto_detect().set("vllm", "saved-local-secret")?;
4132
4133 let mut providers = ProvidersConfig::default();
4134 providers.vllm.base_url = Some("http://127.0.0.1:8000/v1".to_string());
4135 providers.vllm.auth_mode = Some("api_key".to_string());
4136 let config = Config {
4137 provider: Some("vllm".to_string()),
4138 providers: Some(providers),
4139 ..Config::default()
4140 };
4141
4142 assert_eq!(config.deepseek_api_key()?, "saved-local-secret");
4143 assert!(has_api_key(&config));
4144 assert!(active_provider_has_config_api_key(&config));
4145 Ok(())
4146 }
4147
4148 #[test]
4149 fn named_custom_provider_never_reuses_generic_custom_secret() -> Result<()> {
4150 let _lock = lock_test_env();
4151 let temp_root = tempfile::tempdir()?;
4152 let _guard = EnvGuard::new(temp_root.path());
4153 let codewhale_home = temp_root.path().join("isolated-codewhale");
4154 fs::create_dir_all(&codewhale_home)?;
4155 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
4156 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
4157
4158 codewhale_secrets::Secrets::auto_detect().set("custom", "endpoint-a-secret")?;
4159
4160 let mut providers = ProvidersConfig::default();
4161 providers.custom.insert(
4162 "endpoint-b".to_string(),
4163 ProviderConfig {
4164 kind: Some("openai-compatible".to_string()),
4165 base_url: Some("https://endpoint-b.example.test/v1".to_string()),
4166 model: Some("endpoint-b-model".to_string()),
4167 auth_mode: Some("api_key".to_string()),
4168 ..ProviderConfig::default()
4169 },
4170 );
4171 let config = Config {
4172 provider: Some("endpoint-b".to_string()),
4173 providers: Some(providers),
4174 ..Config::default()
4175 };
4176
4177 assert!(config.should_skip_secret_store_for_provider(ApiProvider::Custom));
4178 assert!(provider_secret_store_api_key(&config, ApiProvider::Custom).is_none());
4179 assert!(config.deepseek_api_key().is_err());
4180 assert!(!has_api_key(&config));
4181 assert!(!active_provider_has_config_api_key(&config));
4182 Ok(())
4183 }
4184
4185 #[test]
4186 fn built_in_provider_custom_endpoint_never_reuses_global_credentials() -> Result<()> {
4187 let _lock = lock_test_env();
4188 let temp_root = tempfile::tempdir()?;
4189 let _guard = EnvGuard::new(temp_root.path());
4190 let codewhale_home = temp_root.path().join("isolated-codewhale");
4191 fs::create_dir_all(&codewhale_home)?;
4192 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
4193 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
4194 let _ambient = EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-official-key");
4195 codewhale_secrets::Secrets::auto_detect().set("openrouter", "saved-official-key")?;
4196
4197 let mut providers = ProvidersConfig::default();
4198 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
4199 let config = Config {
4200 provider: Some("openrouter".to_string()),
4201 providers: Some(providers),
4202 ..Config::default()
4203 };
4204
4205 assert!(config.provider_uses_custom_endpoint(ApiProvider::Openrouter));
4206 assert!(config.should_skip_secret_store_for_provider(ApiProvider::Openrouter));
4207 assert!(config.deepseek_api_key().is_err());
4208 assert!(!has_api_key(&config));
4209 assert!(!active_provider_has_config_api_key(&config));
4210 assert!(!active_provider_has_env_api_key(&config));
4211 Ok(())
4212 }
4213
4214 #[test]
4215 fn custom_endpoint_accepts_route_bound_api_key_env_and_reports_ready() -> Result<()> {
4216 let _lock = lock_test_env();
4217 let temp_root = tempfile::tempdir()?;
4218 let _guard = EnvGuard::new(temp_root.path());
4219 let _route_key = EnvVarGuard::set("MY_GATEWAY_ROUTE_KEY", "route-bound-key");
4220 let _ambient = EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-official-key");
4221
4222 let mut providers = ProvidersConfig::default();
4223 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
4224 providers.openrouter.api_key_env = Some("MY_GATEWAY_ROUTE_KEY".to_string());
4225 let config = Config {
4226 provider: Some("openrouter".to_string()),
4227 providers: Some(providers),
4228 ..Config::default()
4229 };
4230
4231 assert_eq!(config.deepseek_api_key()?, "route-bound-key");
4232 assert!(has_api_key_for(&config, ApiProvider::Openrouter));
4233 assert!(active_provider_has_env_api_key(&config));
4234 assert!(active_provider_uses_env_only_api_key(&config));
4235 Ok(())
4236 }
4237
4238 #[test]
4239 fn env_selected_custom_endpoints_do_not_rebind_file_credentials() -> Result<()> {
4240 let _lock = lock_test_env();
4241 let temp_root = tempfile::tempdir()?;
4242 let _guard = EnvGuard::new(temp_root.path());
4243 let config_path = temp_root.path().join("config.toml");
4244 let _route_key = EnvVarGuard::set("MY_GATEWAY_ROUTE_KEY", "route-bound-file-key");
4245 let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
4246 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
4247
4248 fs::write(
4249 &config_path,
4250 r#"api_key = "saved-root-deepseek-key"
4251 default_text_model = "deepseek-chat"
4252 "#,
4253 )?;
4254 {
4255 let _generic_base = EnvVarGuard::set(
4256 "CODEWHALE_BASE_URL",
4257 "https://generic-gateway.example.test/v1",
4258 );
4259 let config = Config::load(Some(config_path.clone()), None)?;
4260 assert!(config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
4261 assert!(config.deepseek_api_key().is_err());
4262 assert!(!active_provider_has_config_api_key(&config));
4263 assert!(!active_provider_has_env_api_key(&config));
4264 assert!(!has_api_key_for(&config, ApiProvider::Deepseek));
4265 }
4266
4267 fs::write(
4268 &config_path,
4269 r#"provider = "openrouter"
4270
4271 [providers.openrouter]
4272 api_key = "saved-openrouter-route-key"
4273 api_key_env = "MY_GATEWAY_ROUTE_KEY"
4274 model = "openai/gpt-5"
4275 "#,
4276 )?;
4277 for (env_name, endpoint) in [
4278 (
4279 "CODEWHALE_BASE_URL",
4280 "https://generic-openrouter-gateway.example.test/v1",
4281 ),
4282 (
4283 "OPENROUTER_BASE_URL",
4284 "https://provider-openrouter-gateway.example.test/v1",
4285 ),
4286 ] {
4287 let _base = EnvVarGuard::set(env_name, endpoint);
4288 let config = Config::load(Some(config_path.clone()), None)?;
4289 assert_eq!(config.deepseek_base_url(), endpoint);
4290 assert!(config.provider_uses_custom_endpoint(ApiProvider::Openrouter));
4291 assert!(config.deepseek_api_key().is_err());
4292 assert!(!active_provider_has_config_api_key(&config));
4293 assert!(!active_provider_has_env_api_key(&config));
4294 assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
4295 }
4296
4297 Ok(())
4298 }
4299
4300 #[test]
4301 fn file_bound_custom_endpoints_keep_route_credentials() -> Result<()> {
4302 let _lock = lock_test_env();
4303 let temp_root = tempfile::tempdir()?;
4304 let _guard = EnvGuard::new(temp_root.path());
4305 let config_path = temp_root.path().join("config.toml");
4306 let _route_key = EnvVarGuard::set("MY_GATEWAY_ROUTE_KEY", "file-env-route-key");
4307
4308 fs::write(
4309 &config_path,
4310 r#"api_key = "file-root-key"
4311 base_url = "https://file-deepseek-gateway.example.test/v1"
4312 default_text_model = "private-deepseek-model"
4313 "#,
4314 )?;
4315 let root = Config::load(Some(config_path.clone()), None)?;
4316 assert_eq!(root.deepseek_api_key()?, "file-root-key");
4317 assert!(active_provider_has_config_api_key(&root));
4318 assert!(has_api_key_for(&root, ApiProvider::Deepseek));
4319
4320 fs::write(
4321 &config_path,
4322 r#"provider = "openrouter"
4323
4324 [providers.openrouter]
4325 api_key = "file-provider-key"
4326 base_url = "https://file-openrouter-gateway.example.test/v1"
4327 model = "private-openrouter-model"
4328 "#,
4329 )?;
4330 let provider_key = Config::load(Some(config_path.clone()), None)?;
4331 assert_eq!(provider_key.deepseek_api_key()?, "file-provider-key");
4332 assert!(active_provider_has_config_api_key(&provider_key));
4333 assert!(has_api_key_for(&provider_key, ApiProvider::Openrouter));
4334
4335 fs::write(
4336 &config_path,
4337 r#"provider = "openrouter"
4338
4339 [providers.openrouter]
4340 api_key_env = "MY_GATEWAY_ROUTE_KEY"
4341 base_url = "https://file-openrouter-gateway.example.test/v1"
4342 model = "private-openrouter-model"
4343 "#,
4344 )?;
4345 let route_env = Config::load(Some(config_path), None)?;
4346 assert_eq!(route_env.deepseek_api_key()?, "file-env-route-key");
4347 assert!(active_provider_has_env_api_key(&route_env));
4348 assert!(has_api_key_for(&route_env, ApiProvider::Openrouter));
4349 Ok(())
4350 }
4351
4352 /// A session-scoped `[providers.*]` fixture with credentials but no endpoints,
4353 /// so every base URL in these tests comes from the resolver rather than a file.
4354 const CROSS_PROVIDER_ROUTE_FIXTURE: &str = r#"api_key = "session-deepseek-key"
4355 default_text_model = "deepseek-chat"
4356
4357 [providers.moonshot]
4358 api_key = "moonshot-route-key"
4359
4360 [providers.zai]
4361 api_key = "zai-route-key"
4362
4363 [providers.minimax]
4364 api_key = "minimax-route-key"
4365 "#;
4366
4367 #[test]
4368 fn generic_base_url_override_never_reaches_pinned_child_routes() -> Result<()> {
4369 // #4093-class routing truth: every cross-provider seam (pinned subagent /
4370 // fleet child, per-turn auto-router, tool routing, picker preview) clones
4371 // the session config and re-points `provider`. The generic endpoint
4372 // override belongs to the DeepSeek session that set it and must never
4373 // follow a child to another vendor's route.
4374 let _lock = lock_test_env();
4375 let temp_root = tempfile::tempdir()?;
4376 let _guard = EnvGuard::new(temp_root.path());
4377 let config_path = temp_root.path().join("config.toml");
4378 fs::write(&config_path, CROSS_PROVIDER_ROUTE_FIXTURE)?;
4379
4380 for env_name in ["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"] {
4381 let session_host = "https://session-gateway.example.test/v1";
4382 let _base = EnvVarGuard::set(env_name, session_host);
4383 let config = Config::load(Some(config_path.clone()), None)?;
4384
4385 // Documented behavior for the active DeepSeek route is unchanged.
4386 assert_eq!(config.api_provider(), ApiProvider::Deepseek);
4387 assert_eq!(config.deepseek_base_url(), session_host);
4388 assert!(config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
4389
4390 for (provider, expected) in [
4391 (ApiProvider::Moonshot, DEFAULT_MOONSHOT_BASE_URL),
4392 (ApiProvider::Zai, DEFAULT_ZAI_BASE_URL),
4393 (ApiProvider::Minimax, DEFAULT_MINIMAX_BASE_URL),
4394 ] {
4395 assert_eq!(
4396 config.base_url_for_route(provider),
4397 expected,
4398 "{env_name}: {provider:?} must resolve from its own identity table"
4399 );
4400 assert!(
4401 !config.provider_uses_custom_endpoint(provider),
4402 "{env_name}: {provider:?} is on its canonical host, not a custom one"
4403 );
4404
4405 let route = crate::route_runtime::resolve_runtime_route(&config, provider, None)
4406 .unwrap_or_else(|err| panic!("{env_name}: {provider:?} child route: {err}"));
4407 // The scoped config and the executable candidate must agree, and
4408 // neither may name the session host.
4409 assert_eq!(route.config.deepseek_base_url(), expected);
4410 assert_eq!(route.candidate.endpoint().base_url, expected);
4411 assert_ne!(route.candidate.endpoint().base_url, session_host);
4412 }
4413
4414 // An unknown/custom identity fails closed on the loopback placeholder
4415 // instead of borrowing the DeepSeek session route.
4416 let custom_placeholder = normalize_base_url(
4417 codewhale_config::ProviderKind::Custom
4418 .provider()
4419 .default_base_url(),
4420 );
4421 assert_eq!(
4422 config.base_url_for_route(ApiProvider::Custom),
4423 custom_placeholder
4424 );
4425 assert_ne!(config.base_url_for_route(ApiProvider::Custom), session_host);
4426 }
4427
4428 Ok(())
4429 }
4430
4431 #[test]
4432 fn provider_scoped_base_url_env_applies_only_to_its_own_route() -> Result<()> {
4433 let _lock = lock_test_env();
4434 let temp_root = tempfile::tempdir()?;
4435 let _guard = EnvGuard::new(temp_root.path());
4436 let config_path = temp_root.path().join("config.toml");
4437 fs::write(&config_path, CROSS_PROVIDER_ROUTE_FIXTURE)?;
4438
4439 let moonshot_host = "https://moonshot-gateway.example.test/v1";
4440 let _moonshot = EnvVarGuard::set("MOONSHOT_BASE_URL", moonshot_host);
4441
4442 // Without a generic override the active DeepSeek route keeps its default:
4443 // a provider-scoped variable names exactly one provider.
4444 let config = Config::load(Some(config_path.clone()), None)?;
4445 assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);
4446 assert_eq!(
4447 config.base_url_for_route(ApiProvider::Zai),
4448 DEFAULT_ZAI_BASE_URL
4449 );
4450 assert_eq!(
4451 config.base_url_for_route(ApiProvider::Moonshot),
4452 moonshot_host
4453 );
4454 let route = crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Moonshot, None)
4455 .expect("Moonshot child route");
4456 assert_eq!(route.candidate.endpoint().base_url, moonshot_host);
4457
4458 // With both set, each override stays on its own route.
4459 let session_host = "https://session-gateway.example.test/v1";
4460 let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", session_host);
4461 let config = Config::load(Some(config_path), None)?;
4462 assert_eq!(config.deepseek_base_url(), session_host);
4463 assert_eq!(
4464 config.base_url_for_route(ApiProvider::Moonshot),
4465 moonshot_host
4466 );
4467 assert_eq!(
4468 config.base_url_for_route(ApiProvider::Zai),
4469 DEFAULT_ZAI_BASE_URL
4470 );
4471 let route = crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Moonshot, None)
4472 .expect("Moonshot child route");
4473 assert_eq!(route.config.deepseek_base_url(), moonshot_host);
4474 assert_eq!(route.candidate.endpoint().base_url, moonshot_host);
4475 assert_ne!(route.candidate.endpoint().base_url, session_host);
4476
4477 Ok(())
4478 }
4479
4480 #[test]
4481 fn source_marked_cli_key_can_follow_cli_forwarded_custom_base_url() -> Result<()> {
4482 let _lock = lock_test_env();
4483 let temp_root = tempfile::tempdir()?;
4484 let _guard = EnvGuard::new(temp_root.path());
4485 let config_path = temp_root.path().join("config.toml");
4486 fs::write(
4487 &config_path,
4488 r#"api_key = "saved-root-key"
4489 base_url = "https://api.deepseek.com/v1"
4490 default_text_model = "deepseek-chat"
4491 "#,
4492 )?;
4493 let _base = EnvVarGuard::set(
4494 "DEEPSEEK_BASE_URL",
4495 "https://explicit-cli-gateway.example.test/v1",
4496 );
4497 let _source = EnvVarGuard::set("DEEPSEEK_API_KEY_SOURCE", "cli");
4498 let _cli_key = EnvVarGuard::set("CODEWHALE_CLI_API_KEY", "explicit-cli-key");
4499
4500 let config = Config::load(Some(config_path), None)?;
4501 assert_eq!(config.deepseek_api_key()?, "explicit-cli-key");
4502 assert!(!active_provider_has_config_api_key(&config));
4503 assert!(active_provider_has_env_api_key(&config));
4504 assert!(active_provider_uses_env_only_api_key(&config));
4505 assert!(has_api_key_for(&config, ApiProvider::Deepseek));
4506 Ok(())
4507 }
4508
4509 #[test]
4510 fn managed_file_endpoint_replaces_lower_env_provenance() -> Result<()> {
4511 let _lock = lock_test_env();
4512 let temp_root = tempfile::tempdir()?;
4513 let _guard = EnvGuard::new(temp_root.path());
4514 let config_path = temp_root.path().join("config.toml");
4515 let managed_path = temp_root.path().join("managed.toml");
4516 fs::write(
4517 &managed_path,
4518 r#"[providers.openrouter]
4519 api_key = "managed-route-key"
4520 base_url = "https://managed-gateway.example.test/v1"
4521 model = "managed-model"
4522 "#,
4523 )?;
4524 fs::write(
4525 &config_path,
4526 format!(
4527 "provider = \"openrouter\"\nmanaged_config_path = {:?}\n",
4528 managed_path.display().to_string()
4529 ),
4530 )?;
4531 let _base = EnvVarGuard::set(
4532 "CODEWHALE_BASE_URL",
4533 "https://lower-env-gateway.example.test/v1",
4534 );
4535
4536 let config = Config::load(Some(config_path), None)?;
4537 assert_eq!(
4538 config.deepseek_base_url(),
4539 "https://managed-gateway.example.test/v1"
4540 );
4541 assert_eq!(config.deepseek_api_key()?, "managed-route-key");
4542 assert!(active_provider_has_config_api_key(&config));
4543 assert!(has_api_key_for(&config, ApiProvider::Openrouter));
4544 Ok(())
4545 }
4546
4547 #[test]
4548 fn managed_config_cannot_grant_external_credential_consent() -> Result<()> {
4549 let _lock = lock_test_env();
4550 let temp_root = tempfile::tempdir()?;
4551 let _guard = EnvGuard::new(temp_root.path());
4552 let config_path = temp_root.path().join("config.toml");
4553 let managed_path = temp_root.path().join("managed.toml");
4554 let external_path = temp_root.path().join("codex-auth.json");
4555 let external_raw = r#"{"tokens":{"access_token":"must-never-be-read"}}"#;
4556 fs::write(&external_path, external_raw)?;
4557 fs::write(
4558 &managed_path,
4559 format!(
4560 r#"[providers.openai_codex.external_credentials]
4561 access = "read_only"
4562 provider = "openai-codex"
4563 source = "codex_cli"
4564 path = {:?}
4565 consent_version = 1
4566 "#,
4567 external_path.display().to_string()
4568 ),
4569 )?;
4570 fs::write(
4571 &config_path,
4572 format!(
4573 "provider = \"openai-codex\"\nmanaged_config_path = {:?}\n",
4574 managed_path.display().to_string()
4575 ),
4576 )?;
4577
4578 crate::external_credentials::reset_side_effect_trap();
4579 let config = Config::load(Some(config_path), None)?;
4580 assert!(
4581 config
4582 .provider_config_for(ApiProvider::OpenaiCodex)
4583 .and_then(|provider| provider.external_credentials.as_ref())
4584 .is_none()
4585 );
4586 assert!(!has_api_key_for(&config, ApiProvider::OpenaiCodex));
4587 assert_eq!(
4588 crate::external_credentials::side_effect_trap_counts(),
4589 (0, 0),
4590 "managed config must not consent to user-owned external credentials"
4591 );
4592 assert_eq!(fs::read_to_string(external_path)?, external_raw);
4593 Ok(())
4594 }
4595
4596 #[test]
4597 fn managed_disabled_external_policy_tightens_lower_user_consent() -> Result<()> {
4598 let _lock = lock_test_env();
4599 let temp_root = tempfile::tempdir()?;
4600 let _guard = EnvGuard::new(temp_root.path());
4601 let config_path = temp_root.path().join("config.toml");
4602 let managed_path = temp_root.path().join("managed.toml");
4603 let external_path = temp_root.path().join("codex-auth.json");
4604 fs::write(
4605 &external_path,
4606 r#"{"tokens":{"access_token":"must-not-read"}}"#,
4607 )?;
4608 fs::write(
4609 &managed_path,
4610 format!(
4611 r#"[providers.openai_codex.external_credentials]
4612 access = "disabled"
4613 provider = "openai-codex"
4614 source = "codex_cli"
4615 path = {:?}
4616 consent_version = 1
4617 "#,
4618 external_path.display().to_string()
4619 ),
4620 )?;
4621 fs::write(
4622 &config_path,
4623 format!(
4624 r#"provider = "openai-codex"
4625 managed_config_path = {:?}
4626
4627 [providers.openai_codex]
4628 auth_mode = "oauth"
4629
4630 [providers.openai_codex.external_credentials]
4631 access = "read_only"
4632 provider = "openai-codex"
4633 source = "codex_cli"
4634 path = {:?}
4635 consent_version = 1
4636 "#,
4637 managed_path.display().to_string(),
4638 external_path.display().to_string(),
4639 ),
4640 )?;
4641 let _auth_path = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &external_path);
4642 crate::external_credentials::reset_side_effect_trap();
4643 let config = Config::load(Some(config_path), None)?;
4644 let effective = config
4645 .provider_config_for(ApiProvider::OpenaiCodex)
4646 .and_then(|provider| provider.external_credentials.as_ref())
4647 .expect("managed disabled tombstone");
4648 assert_eq!(
4649 effective.access,
4650 codewhale_config::ExternalCredentialAccess::Disabled
4651 );
4652 assert!(!has_api_key_for(&config, ApiProvider::OpenaiCodex));
4653 assert_eq!(
4654 crate::external_credentials::complete_side_effect_trap_counts(),
4655 (0, 0, 0, 0, 0),
4656 "managed deny must suppress lower consent before every side effect"
4657 );
4658 Ok(())
4659 }
4660
4661 #[test]
4662 fn env_base_url_provenance_tracks_the_route_across_provider_switches() -> Result<()> {
4663 let _lock = lock_test_env();
4664 let temp_root = tempfile::tempdir()?;
4665 let _guard = EnvGuard::new(temp_root.path());
4666 let config_path = temp_root.path().join("config.toml");
4667 fs::write(
4668 &config_path,
4669 r#"provider = "openrouter"
4670
4671 [providers.openrouter]
4672 api_key = "stale-openrouter-file-key"
4673 model = "openrouter-model"
4674
4675 [providers.openai]
4676 api_key = "file-openai-key"
4677 base_url = "https://file-openai-gateway.example.test/v1"
4678 model = "private-openai-model"
4679
4680 [providers.anthropic]
4681 api_key = "stale-anthropic-file-key"
4682 model = "claude-sonnet-5"
4683 "#,
4684 )?;
4685 let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
4686 let mut config = Config::load(Some(config_path), None)?;
4687 assert!(config.deepseek_api_key().is_err());
4688
4689 config.provider = Some("openai".to_string());
4690 assert_eq!(
4691 config.deepseek_base_url(),
4692 "https://file-openai-gateway.example.test/v1"
4693 );
4694 assert_eq!(config.deepseek_api_key()?, "file-openai-key");
4695
4696 // Anthropic was never the route the environment addressed. Under the
4697 // endpoint-ownership receipt the generic override does not follow a
4698 // re-pointed config onto another vendor's route — that is the same
4699 // mechanism a pinned cross-provider child is resolved through, and it must
4700 // not be able to dispatch Anthropic traffic at the DeepSeek session's
4701 // gateway. Anthropic therefore resolves its own canonical endpoint, and
4702 // because it is no longer on an env-selected host its file-owned key is a
4703 // legitimate route-bound credential rather than one following a foreign
4704 // host.
4705 config.provider = Some("anthropic".to_string());
4706 assert_eq!(config.deepseek_base_url(), DEFAULT_ANTHROPIC_BASE_URL);
4707 assert_ne!(
4708 config.deepseek_base_url(),
4709 "https://env-gateway.example.test/v1"
4710 );
4711 assert_eq!(config.deepseek_api_key()?, "stale-anthropic-file-key");
4712
4713 config.provider = Some("openrouter".to_string());
4714 assert!(
4715 config.deepseek_api_key().is_err(),
4716 "switching away and back must retain the env ownership receipt for that route"
4717 );
4718 Ok(())
4719 }
4720
4721 #[test]
4722 fn named_custom_api_key_env_satisfies_runtime_and_onboarding_readiness() -> Result<()> {
4723 let _lock = lock_test_env();
4724 let temp_root = tempfile::tempdir()?;
4725 let _guard = EnvGuard::new(temp_root.path());
4726 let _route_key = EnvVarGuard::set("NAMED_CUSTOM_ROUTE_KEY", "named-route-key");
4727
4728 let mut providers = ProvidersConfig::default();
4729 providers.custom.insert(
4730 "acme".to_string(),
4731 ProviderConfig {
4732 kind: Some("openai-compatible".to_string()),
4733 base_url: Some("https://acme.example.test/v1".to_string()),
4734 model: Some("acme-model".to_string()),
4735 api_key_env: Some("NAMED_CUSTOM_ROUTE_KEY".to_string()),
4736 ..ProviderConfig::default()
4737 },
4738 );
4739 let config = Config {
4740 provider: Some("acme".to_string()),
4741 providers: Some(providers),
4742 ..Config::default()
4743 };
4744
4745 assert_eq!(config.deepseek_api_key()?, "named-route-key");
4746 assert!(has_api_key_for(&config, ApiProvider::Custom));
4747 assert!(has_api_key(&config));
4748 Ok(())
4749 }
4750
4751 #[test]
4752 fn auth_mode_none_suppresses_config_env_secret_and_oauth_credentials() -> Result<()> {
4753 let _lock = lock_test_env();
4754 let temp_root = tempfile::tempdir()?;
4755 let _guard = EnvGuard::new(temp_root.path());
4756 let codewhale_home = temp_root.path().join("isolated-codewhale");
4757 fs::create_dir_all(&codewhale_home)?;
4758 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
4759 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
4760 let _ambient = EnvVarGuard::set("XAI_API_KEY", "ambient-xai-key");
4761 let oauth_path = temp_root.path().join("grok-auth.json");
4762 fs::write(&oauth_path, r#"{"access_token":"oauth-token"}"#)?;
4763 let _oauth_path = EnvVarGuard::set("GROK_AUTH_PATH", oauth_path.as_os_str());
4764 codewhale_secrets::Secrets::auto_detect().set("xai", "saved-xai-key")?;
4765
4766 let mut providers = ProvidersConfig::default();
4767 providers.xai.auth_mode = Some("none".to_string());
4768 providers.xai.api_key = Some("configured-xai-key".to_string());
4769 providers.xai.http_headers = Some(HashMap::from([
4770 ("X-API-Key".to_string(), "configured-x-key".to_string()),
4771 ("Api-Key".to_string(), "configured-key".to_string()),
4772 (
4773 "Proxy-Authorization".to_string(),
4774 "Basic configured-proxy-secret".to_string(),
4775 ),
4776 (
4777 "X-Auth-Token".to_string(),
4778 "configured-auth-token".to_string(),
4779 ),
4780 (
4781 "X-Access-Token".to_string(),
4782 "configured-access-token".to_string(),
4783 ),
4784 (
4785 "X-Goog-Api-Key".to_string(),
4786 "configured-google-key".to_string(),
4787 ),
4788 ("Cookie".to_string(), "session=secret".to_string()),
4789 ("X-Route-Metadata".to_string(), "safe".to_string()),
4790 ]));
4791 let config = Config {
4792 provider: Some("xai".to_string()),
4793 http_headers: Some(HashMap::from([(
4794 "aUtHoRiZaTiOn".to_string(),
4795 "Bearer configured-secret".to_string(),
4796 )])),
4797 providers: Some(providers),
4798 ..Config::default()
4799 };
4800
4801 assert_eq!(config.deepseek_api_key()?, "");
4802 assert!(
4803 has_api_key(&config),
4804 "no-auth routes are ready without a key"
4805 );
4806 assert!(!active_provider_has_config_api_key(&config));
4807 assert!(!active_provider_has_env_api_key(&config));
4808 let headers = config.http_headers();
4809 for name in [
4810 "authorization",
4811 "x-api-key",
4812 "api-key",
4813 "proxy-authorization",
4814 "x-auth-token",
4815 "x-access-token",
4816 "x-goog-api-key",
4817 "cookie",
4818 ] {
4819 assert!(
4820 !headers
4821 .keys()
4822 .any(|candidate| candidate.eq_ignore_ascii_case(name)),
4823 "disabled auth leaked {name}: {headers:?}"
4824 );
4825 }
4826 assert_eq!(
4827 headers.get("X-Route-Metadata").map(String::as_str),
4828 Some("safe")
4829 );
4830 Ok(())
4831 }
4832
4833 #[test]
4834 fn active_provider_detects_env_only_api_key() -> Result<()> {
4835 let _lock = lock_test_env();
4836 let temp_root =
4837 env::temp_dir().join(format!("codewhale-tui-env-only-key-{}", std::process::id()));
4838 fs::create_dir_all(&temp_root)?;
4839 let _guard = EnvGuard::new(&temp_root);
4840
4841 unsafe {
4842 env::set_var("DEEPSEEK_API_KEY", "env-only-key");
4843 }
4844 let mut config = Config::default();
4845 assert!(active_provider_has_env_api_key(&config));
4846 assert!(!active_provider_has_config_api_key(&config));
4847 assert!(active_provider_uses_env_only_api_key(&config));
4848
4849 config.api_key = Some("config-key".to_string());
4850 assert!(active_provider_has_config_api_key(&config));
4851 assert!(!active_provider_uses_env_only_api_key(&config));
4852
4853 unsafe {
4854 env::remove_var("DEEPSEEK_API_KEY");
4855 }
4856 Ok(())
4857 }
4858
4859 #[test]
4860 fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {
4861 let _lock = lock_test_env();
4862 let nanos = SystemTime::now()
4863 .duration_since(UNIX_EPOCH)
4864 .unwrap()
4865 .as_nanos();
4866 let temp_root = env::temp_dir().join(format!(
4867 "codewhale-tui-sentinel-{}-{}",
4868 std::process::id(),
4869 nanos
4870 ));
4871 fs::create_dir_all(&temp_root)?;
4872 let _guard = EnvGuard::new(&temp_root);
4873
4874 let config = Config {
4875 api_key: Some(API_KEYRING_SENTINEL.to_string()),
4876 ..Config::default()
4877 };
4878 // Sentinel must not be treated as a real key — the resolver should
4879 // fall through to env / config-provider and ultimately bail out
4880 // with a "key not found" error.
4881 let _err = config
4882 .deepseek_api_key()
4883 .expect_err("sentinel placeholder must not satisfy the API key check");
4884 Ok(())
4885 }
4886
4887 #[test]
4888 fn provider_sentinel_falls_through_to_route_env_then_fixture_store() -> Result<()> {
4889 let _lock = lock_test_env();
4890 let temp_root = tempfile::tempdir()?;
4891 let _guard = EnvGuard::new(temp_root.path());
4892 let codewhale_home = temp_root.path().join("isolated-codewhale");
4893 fs::create_dir_all(&codewhale_home)?;
4894 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
4895 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
4896 let config_path = temp_root.path().join("config.toml");
4897 let secrets = codewhale_secrets::Secrets::auto_detect();
4898
4899 for sentinel in [API_KEYRING_SENTINEL, " __KEYRING__ "] {
4900 fs::write(
4901 &config_path,
4902 format!("provider = \"openai\"\n\n[providers.openai]\napi_key = {sentinel:?}\n"),
4903 )?;
4904 let config = Config::load(Some(config_path.clone()), None)?;
4905 assert_eq!(
4906 config
4907 .provider_config()
4908 .and_then(|entry| entry.api_key.as_deref())
4909 .map(classify_config_api_key_value),
4910 Some(ConfigApiKeyValueKind::SecretStoreSentinel)
4911 );
4912 assert!(!active_provider_has_config_api_key(&config));
4913 assert!(!has_api_key_for(&config, ApiProvider::Openai));
4914
4915 secrets.set("openai", "FIXTURE-STORED-KEY")?;
4916 assert_eq!(
4917 config.deepseek_api_key()?,
4918 "FIXTURE-STORED-KEY",
4919 "{sentinel:?} must fall through to the allowed fixture store"
4920 );
4921 assert!(active_provider_has_config_api_key(&config));
4922 assert!(has_api_key_for(&config, ApiProvider::Openai));
4923 secrets.delete("openai")?;
4924
4925 let _route_env = EnvVarGuard::set("OFFICIAL_SENTINEL_ROUTE_KEY", "FIXTURE-ENV-KEY");
4926 fs::write(
4927 &config_path,
4928 format!(
4929 "provider = \"openai\"\n\n[providers.openai]\napi_key = {sentinel:?}\napi_key_env = \"OFFICIAL_SENTINEL_ROUTE_KEY\"\n"
4930 ),
4931 )?;
4932 let config = Config::load(Some(config_path.clone()), None)?;
4933 assert_eq!(
4934 config.deepseek_api_key()?,
4935 "FIXTURE-ENV-KEY",
4936 "route-bound api_key_env must outrank the store after {sentinel:?}"
4937 );
4938 assert!(!active_provider_has_config_api_key(&config));
4939 assert!(active_provider_has_env_api_key(&config));
4940
4941 fs::write(&config_path, format!("api_key = {sentinel:?}\n"))?;
4942 let root = Config::load(Some(config_path.clone()), None)?;
4943 assert!(!active_provider_has_config_api_key(&root));
4944 assert!(!has_api_key_for(&root, ApiProvider::Deepseek));
4945 secrets.set("deepseek", "FIXTURE-DEEPSEEK-STORED-KEY")?;
4946 assert_eq!(
4947 root.deepseek_api_key()?,
4948 "FIXTURE-DEEPSEEK-STORED-KEY",
4949 "root {sentinel:?} must also fall through to the allowed fixture store"
4950 );
4951 assert!(active_provider_has_config_api_key(&root));
4952 secrets.delete("deepseek")?;
4953 }
4954 Ok(())
4955 }
4956
4957 #[test]
4958 fn custom_route_sentinel_is_never_a_key_and_requires_a_route_binding() -> Result<()> {
4959 let _lock = lock_test_env();
4960 let temp_root = tempfile::tempdir()?;
4961 let _guard = EnvGuard::new(temp_root.path());
4962 let config_path = temp_root.path().join("config.toml");
4963
4964 for sentinel in [API_KEYRING_SENTINEL, " __KEYRING__ "] {
4965 fs::write(
4966 &config_path,
4967 format!(
4968 "provider = \"acme\"\n\n[providers.acme]\nkind = \"openai-compatible\"\nbase_url = \"https://acme.example.test/v1\"\nmodel = \"acme-model\"\napi_key = {sentinel:?}\n"
4969 ),
4970 )?;
4971 let config = Config::load(Some(config_path.clone()), None)?;
4972 assert!(config.should_skip_secret_store_for_provider(ApiProvider::Custom));
4973 let error = config
4974 .deepseek_api_key()
4975 .expect_err("named custom sentinel must not become a bearer key");
4976 assert!(error.to_string().contains("must be bound explicitly"));
4977 assert!(!active_provider_has_config_api_key(&config));
4978 assert!(!has_api_key_for(&config, ApiProvider::Custom));
4979
4980 let _route_env = EnvVarGuard::set("CUSTOM_SENTINEL_ROUTE_KEY", "FIXTURE-CUSTOM-ENV-KEY");
4981 fs::write(
4982 &config_path,
4983 format!(
4984 "provider = \"acme\"\n\n[providers.acme]\nkind = \"openai-compatible\"\nbase_url = \"https://acme.example.test/v1\"\nmodel = \"acme-model\"\napi_key = {sentinel:?}\napi_key_env = \"CUSTOM_SENTINEL_ROUTE_KEY\"\n"
4985 ),
4986 )?;
4987 let config = Config::load(Some(config_path.clone()), None)?;
4988 assert_eq!(config.deepseek_api_key()?, "FIXTURE-CUSTOM-ENV-KEY");
4989 assert!(!active_provider_has_config_api_key(&config));
4990 assert!(active_provider_has_env_api_key(&config));
4991 }
4992
4993 fs::write(
4994 &config_path,
4995 format!(
4996 "provider = \"openrouter\"\n\n[providers.openrouter]\nbase_url = \"https://gateway.example.test/v1\"\napi_key = {API_KEYRING_SENTINEL:?}\n"
4997 ),
4998 )?;
4999 let custom_endpoint = Config::load(Some(config_path), None)?;
5000 assert!(custom_endpoint.should_skip_secret_store_for_provider(ApiProvider::Openrouter));
5001 assert!(custom_endpoint.deepseek_api_key().is_err());
5002 assert!(!active_provider_has_config_api_key(&custom_endpoint));
5003 assert!(!has_api_key_for(&custom_endpoint, ApiProvider::Openrouter));
5004 Ok(())
5005 }
5006
5007 #[test]
5008 fn default_user_paths_use_codewhale_home_for_fresh_installs() -> Result<()> {
5009 let _lock = lock_test_env();
5010 let nanos = SystemTime::now()
5011 .duration_since(UNIX_EPOCH)
5012 .unwrap()
5013 .as_nanos();
5014 let temp_root = env::temp_dir().join(format!(
5015 "codewhale-tui-fresh-home-test-{}-{}",
5016 std::process::id(),
5017 nanos
5018 ));
5019 fs::create_dir_all(&temp_root)?;
5020 let _guard = EnvGuard::new(&temp_root);
5021
5022 // EnvGuard pins DEEPSEEK_CONFIG_PATH for older tests; this test wants
5023 // the no-explicit-path startup behavior.
5024 unsafe {
5025 env::remove_var("DEEPSEEK_CONFIG_PATH");
5026 }
5027
5028 let config = Config::default();
5029 assert_eq!(
5030 default_config_path().unwrap(),
5031 temp_root.join(".codewhale").join("config.toml")
5032 );
5033 assert_eq!(
5034 config.mcp_config_path(),
5035 temp_root.join(".codewhale").join("mcp.json")
5036 );
5037 assert_eq!(
5038 config.notes_path(),
5039 temp_root.join(".codewhale").join("notes.txt")
5040 );
5041 assert_eq!(
5042 config.memory_path(),
5043 temp_root.join(".codewhale").join("memory.md")
5044 );
5045 assert_eq!(
5046 config.skills_dir(),
5047 temp_root.join(".codewhale").join("skills")
5048 );
5049
5050 Ok(())
5051 }
5052
5053 #[test]
5054 fn default_user_paths_preserve_existing_legacy_files() -> Result<()> {
5055 let _lock = lock_test_env();
5056 let nanos = SystemTime::now()
5057 .duration_since(UNIX_EPOCH)
5058 .unwrap()
5059 .as_nanos();
5060 let temp_root = env::temp_dir().join(format!(
5061 "codewhale-tui-legacy-home-test-{}-{}",
5062 std::process::id(),
5063 nanos
5064 ));
5065 let legacy_home = temp_root.join(".deepseek");
5066 fs::create_dir_all(&legacy_home)?;
5067 for name in ["config.toml", "mcp.json", "notes.txt", "memory.md"] {
5068 fs::write(legacy_home.join(name), "")?;
5069 }
5070 fs::create_dir_all(legacy_home.join("skills"))?;
5071 let _guard = EnvGuard::new(&temp_root);
5072
5073 unsafe {
5074 env::remove_var("DEEPSEEK_CONFIG_PATH");
5075 }
5076
5077 let config = Config::default();
5078 assert_eq!(
5079 default_config_path().unwrap(),
5080 legacy_home.join("config.toml")
5081 );
5082 assert_eq!(config.mcp_config_path(), legacy_home.join("mcp.json"));
5083 assert_eq!(config.notes_path(), legacy_home.join("notes.txt"));
5084 assert_eq!(config.memory_path(), legacy_home.join("memory.md"));
5085 assert_eq!(config.skills_dir(), legacy_home.join("skills"));
5086
5087 Ok(())
5088 }
5089
5090 #[test]
5091 fn explicit_codewhale_home_isolates_all_config_owned_user_paths() -> Result<()> {
5092 let _lock = lock_test_env();
5093 let temp_root = tempfile::tempdir()?;
5094 let ambient_home = temp_root.path().join("ambient-home");
5095 let explicit_home = temp_root.path().join("explicit-home");
5096 let ambient_legacy = ambient_home.join(".deepseek");
5097 fs::create_dir_all(ambient_legacy.join("skills"))?;
5098 for name in ["mcp.json", "notes.txt", "memory.md"] {
5099 fs::write(ambient_legacy.join(name), "legacy")?;
5100 }
5101 let _home = EnvVarGuard::set("HOME", &ambient_home);
5102 let _userprofile = EnvVarGuard::set("USERPROFILE", &ambient_home);
5103 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
5104
5105 assert_eq!(default_skills_dir(), Some(explicit_home.join("skills")));
5106 assert_eq!(
5107 default_mcp_config_path(),
5108 Some(explicit_home.join("mcp.json"))
5109 );
5110 assert_eq!(default_notes_path(), Some(explicit_home.join("notes.txt")));
5111 assert_eq!(default_memory_path(), Some(explicit_home.join("memory.md")));
5112 Ok(())
5113 }
5114
5115 #[test]
5116 fn whitespace_codewhale_home_keeps_ambient_legacy_config_path_fallbacks() -> Result<()> {
5117 let _lock = lock_test_env();
5118 let temp_root = tempfile::tempdir()?;
5119 let ambient_home = temp_root.path().join("ambient-home");
5120 let ambient_legacy = ambient_home.join(".deepseek");
5121 fs::create_dir_all(ambient_legacy.join("skills"))?;
5122 for name in ["mcp.json", "notes.txt", "memory.md"] {
5123 fs::write(ambient_legacy.join(name), "legacy")?;
5124 }
5125 let _home = EnvVarGuard::set("HOME", &ambient_home);
5126 let _userprofile = EnvVarGuard::set("USERPROFILE", &ambient_home);
5127 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", " \t ");
5128
5129 assert_eq!(default_skills_dir(), Some(ambient_legacy.join("skills")));
5130 assert_eq!(
5131 default_mcp_config_path(),
5132 Some(ambient_legacy.join("mcp.json"))
5133 );
5134 assert_eq!(default_notes_path(), Some(ambient_legacy.join("notes.txt")));
5135 assert_eq!(
5136 default_memory_path(),
5137 Some(ambient_legacy.join("memory.md"))
5138 );
5139 Ok(())
5140 }
5141
5142 #[cfg(unix)]
5143 #[test]
5144 fn non_unicode_codewhale_home_is_preserved_by_config_owned_user_paths() -> Result<()> {
5145 use std::os::unix::ffi::OsStringExt;
5146
5147 let _lock = lock_test_env();
5148 let temp_root = tempfile::tempdir()?;
5149 let explicit_home = temp_root
5150 .path()
5151 .join(OsString::from_vec(b"codewhale-\xff-home".to_vec()));
5152 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
5153
5154 assert_eq!(default_skills_dir(), Some(explicit_home.join("skills")));
5155 assert_eq!(
5156 default_mcp_config_path(),
5157 Some(explicit_home.join("mcp.json"))
5158 );
5159 assert_eq!(default_notes_path(), Some(explicit_home.join("notes.txt")));
5160 assert_eq!(default_memory_path(), Some(explicit_home.join("memory.md")));
5161 Ok(())
5162 }
5163
5164 #[test]
5165 fn codewhale_config_path_env_wins_over_legacy_env() -> Result<()> {
5166 let _lock = lock_test_env();
5167 let prev_codewhale = env::var_os("CODEWHALE_CONFIG_PATH");
5168 let prev_deepseek = env::var_os("DEEPSEEK_CONFIG_PATH");
5169 let nanos = SystemTime::now()
5170 .duration_since(UNIX_EPOCH)
5171 .unwrap()
5172 .as_nanos();
5173 let temp_root = env::temp_dir().join(format!(
5174 "codewhale-tui-config-env-test-{}-{}",
5175 std::process::id(),
5176 nanos
5177 ));
5178 let preferred = temp_root.join("preferred.toml");
5179 let legacy = temp_root.join("legacy.toml");
5180
5181 unsafe {
5182 env::set_var("CODEWHALE_CONFIG_PATH", &preferred);
5183 env::set_var("DEEPSEEK_CONFIG_PATH", &legacy);
5184 }
5185
5186 assert_eq!(env_config_path().unwrap().unwrap(), preferred);
5187
5188 unsafe {
5189 EnvGuard::restore_var("CODEWHALE_CONFIG_PATH", prev_codewhale);
5190 EnvGuard::restore_var("DEEPSEEK_CONFIG_PATH", prev_deepseek);
5191 }
5192
5193 Ok(())
5194 }
5195
5196 #[test]
5197 fn test_tilde_expansion_in_paths() -> Result<()> {
5198 let _lock = lock_test_env();
5199 let nanos = SystemTime::now()
5200 .duration_since(UNIX_EPOCH)
5201 .unwrap()
5202 .as_nanos();
5203 let temp_root = env::temp_dir().join(format!(
5204 "codewhale-tui-tilde-test-{}-{}",
5205 std::process::id(),
5206 nanos
5207 ));
5208 fs::create_dir_all(&temp_root)?;
5209 let _guard = EnvGuard::new(&temp_root);
5210
5211 let config = Config {
5212 skills_dir: Some("~/.deepseek/skills".to_string()),
5213 ..Default::default()
5214 };
5215 let expected_skills = temp_root.join(".deepseek").join("skills");
5216 let actual_skills = config.skills_dir();
5217 assert_eq!(
5218 actual_skills.components().collect::<Vec<_>>(),
5219 expected_skills.components().collect::<Vec<_>>()
5220 );
5221
5222 Ok(())
5223 }
5224
5225 #[test]
5226 fn skills_scan_codewhale_only_defaults_false_and_parses_true() -> Result<()> {
5227 assert!(!Config::default().skills_config().scan_codewhale_only());
5228
5229 let config: Config = toml::from_str(
5230 r#"
5231 [skills]
5232 scan_codewhale_only = true
5233 "#,
5234 )?;
5235
5236 assert!(config.skills_config().scan_codewhale_only());
5237 Ok(())
5238 }
5239
5240 #[test]
5241 fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {
5242 let _lock = lock_test_env();
5243 let nanos = SystemTime::now()
5244 .duration_since(UNIX_EPOCH)
5245 .unwrap()
5246 .as_nanos();
5247 let temp_root = env::temp_dir().join(format!(
5248 "codewhale-tui-load-tilde-test-{}-{}",
5249 std::process::id(),
5250 nanos
5251 ));
5252 fs::create_dir_all(&temp_root)?;
5253 let _guard = EnvGuard::new(&temp_root);
5254
5255 let config_path = temp_root.join(".custom-deepseek").join("config.toml");
5256 ensure_parent_dir(&config_path)?;
5257 fs::write(&config_path, "api_key = \"test-key\"\n")?;
5258
5259 // Safety: test-only environment mutation guarded by a global mutex.
5260 unsafe {
5261 env::set_var("DEEPSEEK_CONFIG_PATH", "~/.custom-deepseek/config.toml");
5262 }
5263
5264 let config = Config::load(None, None)?;
5265 assert_eq!(config.api_key.as_deref(), Some("test-key"));
5266 Ok(())
5267 }
5268
5269 #[test]
5270 fn missing_env_config_path_does_not_fall_back_to_a_different_home_file() -> Result<()> {
5271 let _lock = lock_test_env();
5272 let nanos = SystemTime::now()
5273 .duration_since(UNIX_EPOCH)
5274 .unwrap()
5275 .as_nanos();
5276 let temp_root = env::temp_dir().join(format!(
5277 "codewhale-tui-load-fallback-test-{}-{}",
5278 std::process::id(),
5279 nanos
5280 ));
5281 fs::create_dir_all(&temp_root)?;
5282 let _guard = EnvGuard::new(&temp_root);
5283
5284 let home_config = temp_root.join(".deepseek").join("config.toml");
5285 ensure_parent_dir(&home_config)?;
5286 fs::write(&home_config, "api_key = \"home-key\"\n")?;
5287
5288 // Safety: test-only environment mutation guarded by a global mutex.
5289 unsafe {
5290 env::set_var(
5291 "DEEPSEEK_CONFIG_PATH",
5292 temp_root.join("missing-config.toml").as_os_str(),
5293 );
5294 }
5295
5296 let config = Config::load(None, None)?;
5297 assert_eq!(
5298 config.api_key, None,
5299 "reads must honor the same missing env target that writes will create"
5300 );
5301 Ok(())
5302 }
5303
5304 #[test]
5305 fn save_then_load_uses_the_same_missing_absolute_env_config_path() -> Result<()> {
5306 let _lock = lock_test_env();
5307 let temp_root = tempfile::tempdir()?;
5308 let _guard = EnvGuard::new(temp_root.path());
5309 let config_path = temp_root.path().join("nested/config.toml");
5310 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
5311 let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
5312 let identity = ProviderIdentity {
5313 provider: ApiProvider::Openrouter,
5314 key: ApiProvider::Openrouter.as_str().to_string(),
5315 exact_id: Some(ApiProvider::Openrouter.as_str().to_string()),
5316 };
5317
5318 let written =
5319 save_provider_model_for_identity(&identity, &Config::default(), "round-trip-model")?;
5320 assert_eq!(written, config_path);
5321 let loaded = Config::load(None, None)?;
5322 assert_eq!(
5323 loaded
5324 .provider_config_for(ApiProvider::Openrouter)
5325 .and_then(|provider| provider.model.as_deref()),
5326 Some("round-trip-model")
5327 );
5328 Ok(())
5329 }
5330
5331 #[test]
5332 fn relative_config_env_is_a_load_error() -> Result<()> {
5333 let _lock = lock_test_env();
5334 let temp_root = tempfile::tempdir()?;
5335 let _guard = EnvGuard::new(temp_root.path());
5336 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", ".codewhale/config.toml");
5337 let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
5338
5339 let error = Config::load(None, None).expect_err("relative config path must fail closed");
5340 let message = format!("{error:#}");
5341 assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
5342 assert!(message.contains("absolute"), "{message}");
5343
5344 for error in [
5345 default_config_path().expect_err("default path must preserve the override error"),
5346 resolve_load_config_path(None)
5347 .expect_err("load-path helper must preserve the override error"),
5348 ensure_config_file_exists(None)
5349 .expect_err("first-run config creation must preserve the override error"),
5350 ] {
5351 let message = format!("{error:#}");
5352 assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
5353 assert!(message.contains("absolute"), "{message}");
5354 assert!(!message.contains("home directory not found"), "{message}");
5355 }
5356 let error = env_config_path().expect_err("env helper must preserve the override error");
5357 let message = error.to_string();
5358 assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
5359 assert!(workspace_trust_config_candidate_paths().is_empty());
5360 Ok(())
5361 }
5362
5363 #[test]
5364 fn test_nonexistent_profile_error() {
5365 let mut profiles = HashMap::new();
5366 profiles.insert("work".to_string(), Config::default());
5367 let config = ConfigFile {
5368 base: Config::default(),
5369 profiles: Some(profiles),
5370 };
5371
5372 let err = apply_profile(config, Some("nonexistent")).unwrap_err();
5373 let message = err.to_string();
5374 assert!(message.contains("Profile 'nonexistent' not found"));
5375 assert!(message.contains("Available profiles"));
5376 assert!(message.contains("work"));
5377 }
5378
5379 #[test]
5380 fn test_profile_with_no_profiles_section() {
5381 let config = ConfigFile {
5382 base: Config::default(),
5383 profiles: None,
5384 };
5385
5386 let err = apply_profile(config, Some("missing")).unwrap_err();
5387 assert!(err.to_string().contains("Available profiles: none"));
5388 }
5389
5390 #[test]
5391 fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {
5392 let _lock = lock_test_env();
5393 let nanos = SystemTime::now()
5394 .duration_since(UNIX_EPOCH)
5395 .unwrap()
5396 .as_nanos();
5397 let temp_root = env::temp_dir().join(format!(
5398 "codewhale-tui-api-key-test-{}-{}",
5399 std::process::id(),
5400 nanos
5401 ));
5402 fs::create_dir_all(&temp_root)?;
5403 let _guard = EnvGuard::new(&temp_root);
5404
5405 let config_path = temp_root.join(".deepseek").join("config.toml");
5406 ensure_parent_dir(&config_path)?;
5407 fs::write(
5408 &config_path,
5409 "api_key_backup = \"old\"\napi_key = \"current\"\n",
5410 )?;
5411 let resolved_config_path = codewhale_config::resolve_config_path(None)?;
5412
5413 let saved = save_api_key("new-key")?;
5414 assert_eq!(saved, SavedCredential::ConfigFile(resolved_config_path));
5415
5416 let contents = fs::read_to_string(&config_path)?;
5417 assert!(contents.contains("api_key_backup = \"old\""));
5418 assert!(contents.contains("api_key = \""));
5419 Ok(())
5420 }
5421
5422 #[test]
5423 fn test_empty_api_key_rejected() {
5424 let config = Config {
5425 api_key: Some(" ".to_string()),
5426 ..Default::default()
5427 };
5428 assert!(config.validate().is_err());
5429 }
5430
5431 #[test]
5432 fn test_missing_api_key_allowed() -> Result<()> {
5433 let config = Config::default();
5434 config.validate()?;
5435 Ok(())
5436 }
5437
5438 #[test]
5439 fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {
5440 let _lock = lock_test_env();
5441 let nanos = SystemTime::now()
5442 .duration_since(UNIX_EPOCH)
5443 .unwrap()
5444 .as_nanos();
5445 let temp_root = env::temp_dir().join(format!(
5446 "codewhale-tui-empty-key-{}-{}",
5447 std::process::id(),
5448 nanos
5449 ));
5450 fs::create_dir_all(&temp_root)?;
5451 let _guard = EnvGuard::new(&temp_root);
5452
5453 // Simulate a fresh user who copied .env.example to .env without
5454 // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.
5455 // Safety: test-only environment mutation guarded by a global mutex.
5456 unsafe {
5457 env::set_var("DEEPSEEK_API_KEY", "");
5458 }
5459
5460 let mut config = Config {
5461 api_key: Some("from-config-file".to_string()),
5462 ..Default::default()
5463 };
5464 apply_env_overrides(&mut config, ConfigEnvironmentPolicy::Runtime);
5465
5466 assert_eq!(config.api_key.as_deref(), Some("from-config-file"));
5467 config.validate()?;
5468 Ok(())
5469 }
5470
5471 #[test]
5472 fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {
5473 let _lock = lock_test_env();
5474 let nanos = SystemTime::now()
5475 .duration_since(UNIX_EPOCH)
5476 .unwrap()
5477 .as_nanos();
5478 let temp_root = env::temp_dir().join(format!(
5479 "codewhale-tui-env-key-not-config-{}-{}",
5480 std::process::id(),
5481 nanos
5482 ));
5483 fs::create_dir_all(&temp_root)?;
5484 let _guard = EnvGuard::new(&temp_root);
5485
5486 unsafe {
5487 env::set_var("DEEPSEEK_API_KEY", "env-key");
5488 }
5489 let mut config = Config::default();
5490 apply_env_overrides(&mut config, ConfigEnvironmentPolicy::Runtime);
5491
5492 assert_eq!(config.api_key, None);
5493 assert_eq!(config.deepseek_api_key()?, "env-key");
5494 unsafe {
5495 env::remove_var("DEEPSEEK_API_KEY");
5496 }
5497 Ok(())
5498 }
5499
5500 #[test]
5501 fn normalize_model_name_preserves_v_series_snapshots() {
5502 // v4 canonical forms still resolve
5503 assert_eq!(
5504 normalize_model_name("deepseek-v4-pro").as_deref(),
5505 Some("deepseek-v4-pro")
5506 );
5507 assert_eq!(
5508 normalize_model_name("deepseek-v4pro").as_deref(),
5509 Some("deepseek-v4-pro")
5510 );
5511 assert_eq!(
5512 normalize_model_name("pro").as_deref(),
5513 Some("deepseek-v4-pro")
5514 );
5515 assert_eq!(
5516 normalize_model_name("flash").as_deref(),
5517 Some("deepseek-v4-flash")
5518 );
5519 // v-series dated snapshots pass through unchanged
5520 assert_eq!(
5521 normalize_model_name("deepseek-v4-flash-20260423").as_deref(),
5522 Some("deepseek-v4-flash-20260423")
5523 );
5524 // future v-series identities pass through
5525 assert_eq!(
5526 normalize_model_name("deepseek-v5-pro-20270101").as_deref(),
5527 Some("deepseek-v5-pro-20270101")
5528 );
5529 // legacy names pass through unchanged — server decides
5530 assert_eq!(
5531 normalize_model_name("deepseek-chat").as_deref(),
5532 Some("deepseek-chat")
5533 );
5534 // cross-provider names still normalize
5535 assert_eq!(
5536 normalize_model_name("deepseek-ai/deepseek-v4-pro").as_deref(),
5537 Some("deepseek-ai/deepseek-v4-pro")
5538 );
5539 // preserve exact case for providers that require case-sensitive model IDs
5540 assert_eq!(
5541 normalize_model_name("DeepSeek-V4-Pro").as_deref(),
5542 Some("DeepSeek-V4-Pro")
5543 );
5544 assert_eq!(
5545 normalize_model_name("deepseek-ai/DeepSeek-V4-Pro").as_deref(),
5546 Some("deepseek-ai/DeepSeek-V4-Pro")
5547 );
5548 }
5549
5550 #[test]
5551 fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {
5552 assert_eq!(
5553 normalize_model_for_provider(ApiProvider::Deepseek, "DeepSeek-V4-Pro").as_deref(),
5554 Some("DeepSeek-V4-Pro")
5555 );
5556 assert_eq!(
5557 normalize_model_for_provider(ApiProvider::NvidiaNim, "DeepSeek-V4-Pro").as_deref(),
5558 Some(DEFAULT_NVIDIA_NIM_MODEL)
5559 );
5560 }
5561
5562 #[test]
5563 fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {
5564 assert_eq!(
5565 normalize_model_name_for_provider(ApiProvider::Deepseek, "deepseek-ai/DeepSeek-V4-Pro")
5566 .as_deref(),
5567 Some("deepseek-v4-pro")
5568 );
5569 assert_eq!(
5570 normalize_model_name_for_provider(ApiProvider::Deepseek, "deepseek/deepseek-v4-flash")
5571 .as_deref(),
5572 Some("deepseek-v4-flash")
5573 );
5574
5575 for provider in [
5576 ApiProvider::Deepseek,
5577 ApiProvider::DeepseekCN,
5578 ApiProvider::DeepseekAnthropic,
5579 ] {
5580 for alias in ["deepseek-chat", "deepseek-reasoner"] {
5581 assert_eq!(
5582 canonical_model_id_for_provider(provider, alias).as_deref(),
5583 Some(DEEPSEEK_ALIAS_REPLACEMENT),
5584 "{provider:?} must retire {alias} before the wire boundary"
5585 );
5586 assert_eq!(
5587 normalize_model_name_for_provider(provider, alias).as_deref(),
5588 Some(DEEPSEEK_ALIAS_REPLACEMENT),
5589 "{provider:?} config normalization must retire {alias}"
5590 );
5591 }
5592 }
5593 }
5594
5595 #[test]
5596 fn migrated_deepseek_alias_receipt_is_runtime_only_and_defaults_empty() {
5597 assert!(Config::default().migrated_deepseek_model_alias.is_none());
5598
5599 let config: Config = toml::from_str(
5600 r#"
5601 default_text_model = "deepseek-v4-flash"
5602 migrated_deepseek_model_alias = "deepseek-chat"
5603 "#,
5604 )
5605 .expect("deserialize config");
5606 assert!(config.migrated_deepseek_model_alias.is_none());
5607 }
5608
5609 #[test]
5610 fn retired_deepseek_aliases_keep_mode_intent_unless_effort_is_explicit() {
5611 // Model normalization reads the process-global model override. Without the
5612 // shared lock, env-focused config tests can replace these fixture aliases.
5613 let _lock = lock_test_env();
5614 for (alias, expected_effort) in [("deepseek-chat", "off"), ("deepseek-reasoner", "high")] {
5615 for provider in [
5616 ApiProvider::Deepseek,
5617 ApiProvider::DeepseekCN,
5618 ApiProvider::DeepseekAnthropic,
5619 ] {
5620 let mut config = Config {
5621 provider: Some(provider.as_str().to_string()),
5622 default_text_model: Some(alias.to_string()),
5623 ..Default::default()
5624 };
5625 normalize_model_config(&mut config);
5626
5627 assert_eq!(
5628 config.default_text_model.as_deref(),
5629 Some(DEEPSEEK_ALIAS_REPLACEMENT)
5630 );
5631 assert_eq!(config.reasoning_effort.as_deref(), Some(expected_effort));
5632 }
5633 }
5634
5635 let mut explicit = Config {
5636 provider: Some("deepseek".to_string()),
5637 default_text_model: Some("deepseek-chat".to_string()),
5638 reasoning_effort: Some("max".to_string()),
5639 ..Default::default()
5640 };
5641 normalize_model_config(&mut explicit);
5642 assert_eq!(
5643 explicit.default_text_model.as_deref(),
5644 Some(DEEPSEEK_ALIAS_REPLACEMENT)
5645 );
5646 assert_eq!(explicit.reasoning_effort.as_deref(), Some("max"));
5647
5648 let mut provider_scoped = Config {
5649 provider: Some("deepseek-anthropic".to_string()),
5650 providers: Some(ProvidersConfig {
5651 deepseek_anthropic: ProviderConfig {
5652 model: Some("deepseek-reasoner".to_string()),
5653 ..Default::default()
5654 },
5655 ..Default::default()
5656 }),
5657 ..Default::default()
5658 };
5659 normalize_model_config(&mut provider_scoped);
5660 assert_eq!(
5661 provider_scoped
5662 .provider_config_for(ApiProvider::DeepseekAnthropic)
5663 .and_then(|entry| entry.model.as_deref()),
5664 Some(DEEPSEEK_ALIAS_REPLACEMENT)
5665 );
5666 assert_eq!(provider_scoped.reasoning_effort.as_deref(), Some("high"));
5667
5668 let mut custom_endpoint = Config {
5669 provider: Some("deepseek".to_string()),
5670 base_url: Some("https://gateway.example/v1".to_string()),
5671 default_text_model: Some("deepseek-chat".to_string()),
5672 ..Default::default()
5673 };
5674 normalize_model_config(&mut custom_endpoint);
5675 assert_eq!(
5676 custom_endpoint.default_text_model.as_deref(),
5677 Some("deepseek-chat")
5678 );
5679 assert_eq!(custom_endpoint.reasoning_effort, None);
5680 }
5681
5682 #[test]
5683 fn retired_deepseek_aliases_do_not_escape_provider_owned_namespaces() {
5684 for provider in [
5685 ApiProvider::NvidiaNim,
5686 ApiProvider::Openrouter,
5687 ApiProvider::WanjieArk,
5688 ApiProvider::Custom,
5689 ] {
5690 for alias in ["deepseek-chat", "deepseek-reasoner"] {
5691 assert_eq!(
5692 canonical_model_id_for_provider(provider, alias).as_deref(),
5693 Some(alias),
5694 "{provider:?} owns the meaning of {alias}"
5695 );
5696 }
5697 }
5698 }
5699
5700 #[test]
5701 fn deepseek_default_model_canonicalizes_provider_prefixed_ids() {
5702 let _lock = lock_test_env();
5703 let temp_root = tempfile::tempdir().unwrap();
5704 let _guard = EnvGuard::new(temp_root.path());
5705
5706 let config = Config {
5707 provider: Some("deepseek".to_string()),
5708 default_text_model: Some(DEFAULT_OPENROUTER_MODEL.to_string()),
5709 ..Default::default()
5710 };
5711 assert_eq!(config.default_model(), DEFAULT_TEXT_MODEL);
5712
5713 let config = Config {
5714 provider: Some("deepseek".to_string()),
5715 providers: Some(ProvidersConfig {
5716 deepseek: ProviderConfig {
5717 model: Some(DEFAULT_OPENROUTER_MODEL.to_string()),
5718 ..Default::default()
5719 },
5720 ..Default::default()
5721 }),
5722 ..Default::default()
5723 };
5724 assert_eq!(config.default_model(), DEFAULT_TEXT_MODEL);
5725 }
5726
5727 #[test]
5728 fn requested_model_for_provider_is_permissive_off_deepseek() {
5729 // #3018: the provider API is the authority for non-DeepSeek routes.
5730 assert_eq!(
5731 requested_model_for_provider(ApiProvider::Moonshot, "kimi-k2.5").as_deref(),
5732 Some("kimi-k2.5")
5733 );
5734 assert_eq!(
5735 requested_model_for_provider(ApiProvider::Ollama, "qwen3:32b").as_deref(),
5736 Some("qwen3:32b")
5737 );
5738 // The official DeepSeek API stays strict.
5739 assert!(requested_model_for_provider(ApiProvider::Deepseek, "kimi-k2.5").is_none());
5740 assert_eq!(
5741 requested_model_for_provider(ApiProvider::Deepseek, "deepseek-v4-pro").as_deref(),
5742 Some("deepseek-v4-pro")
5743 );
5744 }
5745
5746 #[test]
5747 fn validate_route_rejects_mismatched_provider_model_tuple() {
5748 // #3227: the exact contamination — Z.ai provider paired with a
5749 // DeepSeek model — is rejected locally with a diagnostic that names
5750 // the incompatible pair, before any network call.
5751 let err = validate_route(ApiProvider::Zai, "deepseek-v4-pro")
5752 .expect_err("zai + deepseek model must be rejected");
5753 assert!(err.contains("deepseek-v4-pro"), "names the model: {err}");
5754 assert!(err.contains("zai"), "names the provider: {err}");
5755
5756 // A DeepSeek-native provider rejects a non-DeepSeek model id.
5757 let err = validate_route(ApiProvider::Deepseek, "GLM-5.2")
5758 .expect_err("deepseek + GLM must be rejected");
5759 assert!(err.contains("GLM-5.2"), "names the model: {err}");
5760
5761 // Coherent routes pass.
5762 assert!(validate_route(ApiProvider::Zai, "GLM-5.2").is_ok());
5763 assert!(validate_route(ApiProvider::Deepseek, "deepseek-v4-pro").is_ok());
5764 // `auto` is always acceptable; the per-turn router resolves it.
5765 assert!(validate_route(ApiProvider::Zai, "auto").is_ok());
5766 // Pass-through / aggregator providers stay permissive — the upstream
5767 // API remains the authority for them.
5768 assert!(validate_route(ApiProvider::Openai, "deepseek-v4-pro").is_ok());
5769 assert!(validate_route(ApiProvider::Openai, "qwen-plus").is_ok());
5770 assert!(validate_route(ApiProvider::Openrouter, "deepseek-v4-pro").is_ok());
5771 assert!(validate_route(ApiProvider::NvidiaNim, "deepseek-v4-pro").is_ok());
5772 assert!(validate_route(ApiProvider::Together, DEFAULT_TOGETHER_MODEL).is_ok());
5773 assert!(validate_route(ApiProvider::Together, DEFAULT_TOGETHER_FLASH_MODEL).is_ok());
5774 assert!(validate_route(ApiProvider::Together, "deepseek-v4-pro").is_ok());
5775
5776 // Sakana AI (Fugu) is a native provider — DeepSeek ids must not cross-wire.
5777 let err = validate_route(ApiProvider::Sakana, "deepseek-v4-flash")
5778 .expect_err("sakana + deepseek flash must be rejected");
5779 assert!(err.contains("deepseek-v4-flash"), "names the model: {err}");
5780 assert!(err.contains("sakana"), "names the provider: {err}");
5781 assert!(validate_route(ApiProvider::Sakana, DEFAULT_SAKANA_MODEL).is_ok());
5782 }
5783
5784 #[test]
5785 fn wire_model_for_provider_matches_active_provider_shape() {
5786 assert_eq!(
5787 wire_model_for_provider(ApiProvider::Deepseek, DEFAULT_OPENROUTER_MODEL),
5788 DEFAULT_TEXT_MODEL
5789 );
5790 assert_eq!(
5791 wire_model_for_provider(ApiProvider::Openrouter, DEFAULT_TEXT_MODEL),
5792 DEFAULT_OPENROUTER_MODEL
5793 );
5794 assert_eq!(
5795 wire_model_for_provider(ApiProvider::NvidiaNim, DEFAULT_TEXT_MODEL),
5796 DEFAULT_NVIDIA_NIM_MODEL
5797 );
5798 assert_eq!(
5799 wire_model_for_provider(ApiProvider::Together, DEFAULT_TEXT_MODEL),
5800 DEFAULT_TOGETHER_MODEL
5801 );
5802 assert_eq!(
5803 wire_model_for_provider(ApiProvider::Together, "deepseek-v4-flash"),
5804 DEFAULT_TOGETHER_FLASH_MODEL
5805 );
5806 assert_eq!(
5807 wire_model_for_provider(ApiProvider::Together, "thinkingmachines/inkling"),
5808 TOGETHER_INKLING_MODEL
5809 );
5810 assert_eq!(
5811 wire_model_for_provider(ApiProvider::Together, "inkling"),
5812 TOGETHER_INKLING_MODEL
5813 );
5814 assert_eq!(
5815 wire_model_for_provider(ApiProvider::Together, "together-inkling"),
5816 TOGETHER_INKLING_MODEL
5817 );
5818 assert_eq!(
5819 wire_model_for_provider(ApiProvider::Openai, DEFAULT_OPENROUTER_MODEL),
5820 DEFAULT_OPENROUTER_MODEL
5821 );
5822 assert_eq!(
5823 wire_model_for_provider(ApiProvider::Openrouter, OPENROUTER_MINIMAX_M3_MODEL),
5824 OPENROUTER_MINIMAX_M3_MODEL
5825 );
5826 assert_eq!(
5827 wire_model_for_provider(ApiProvider::SiliconflowCn, DEFAULT_SILICONFLOW_MODEL),
5828 DEFAULT_SILICONFLOW_MODEL
5829 );
5830 assert_eq!(
5831 wire_model_for_provider(ApiProvider::SiliconflowCn, "deepseek-v4-pro"),
5832 DEFAULT_SILICONFLOW_MODEL
5833 );
5834 }
5835
5836 #[test]
5837 fn wire_model_route_retires_aliases_only_on_official_deepseek_endpoints() {
5838 for (provider, base_url) in [
5839 (ApiProvider::Deepseek, "https://api.deepseek.com"),
5840 (ApiProvider::Deepseek, "https://api.deepseek.com/v1"),
5841 (ApiProvider::DeepseekCN, "https://api.deepseek.com/beta"),
5842 (
5843 ApiProvider::DeepseekAnthropic,
5844 "https://api.deepseek.com/anthropic",
5845 ),
5846 (
5847 ApiProvider::DeepseekAnthropic,
5848 "https://api.deepseek.com/anthropic/v1/",
5849 ),
5850 ] {
5851 for alias in ["deepseek-chat", "deepseek-reasoner"] {
5852 assert_eq!(
5853 wire_model_for_provider_route(provider, base_url, alias),
5854 DEEPSEEK_ALIAS_REPLACEMENT,
5855 "{provider:?} {base_url} must not send {alias}"
5856 );
5857 }
5858 }
5859
5860 for (provider, base_url, alias) in [
5861 (
5862 ApiProvider::Deepseek,
5863 "https://gateway.example/v1",
5864 "deepseek-chat",
5865 ),
5866 (
5867 ApiProvider::DeepseekAnthropic,
5868 "https://messages.example/v1",
5869 "deepseek-reasoner",
5870 ),
5871 (
5872 ApiProvider::WanjieArk,
5873 DEFAULT_WANJIE_ARK_BASE_URL,
5874 "deepseek-reasoner",
5875 ),
5876 (
5877 ApiProvider::NvidiaNim,
5878 DEFAULT_NVIDIA_NIM_BASE_URL,
5879 "deepseek-reasoner",
5880 ),
5881 ] {
5882 assert_eq!(
5883 wire_model_for_provider_route(provider, base_url, alias),
5884 alias,
5885 "{provider:?} owns the meaning of {alias}"
5886 );
5887 }
5888 }
5889
5890 #[test]
5891 fn normalize_model_name_for_provider_keeps_provider_specific_ids() {
5892 assert_eq!(
5893 normalize_model_name_for_provider(ApiProvider::NvidiaNim, "deepseek-v4-pro").as_deref(),
5894 Some(DEFAULT_NVIDIA_NIM_MODEL)
5895 );
5896 assert_eq!(
5897 normalize_model_name_for_provider(ApiProvider::Openrouter, "deepseek-v4-flash").as_deref(),
5898 Some(DEFAULT_OPENROUTER_FLASH_MODEL)
5899 );
5900 assert_eq!(
5901 normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v4-pro").as_deref(),
5902 Some(DEFAULT_SILICONFLOW_MODEL)
5903 );
5904 assert_eq!(
5905 normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-reasoner").as_deref(),
5906 Some(DEFAULT_SILICONFLOW_MODEL)
5907 );
5908 assert_eq!(
5909 normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-r1").as_deref(),
5910 Some(DEFAULT_SILICONFLOW_MODEL)
5911 );
5912 assert_eq!(
5913 normalize_model_name_for_provider(ApiProvider::SiliconflowCn, "deepseek-reasoner")
5914 .as_deref(),
5915 Some(DEFAULT_SILICONFLOW_MODEL)
5916 );
5917 assert_eq!(
5918 normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-chat").as_deref(),
5919 Some(DEFAULT_SILICONFLOW_FLASH_MODEL)
5920 );
5921 assert_eq!(
5922 normalize_model_name_for_provider(ApiProvider::SiliconflowCn, "deepseek-chat").as_deref(),
5923 Some(DEFAULT_SILICONFLOW_FLASH_MODEL)
5924 );
5925 assert_eq!(
5926 normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v3").as_deref(),
5927 Some(DEFAULT_SILICONFLOW_FLASH_MODEL)
5928 );
5929 assert_eq!(
5930 normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v3.2").as_deref(),
5931 Some("deepseek-v3.2")
5932 );
5933 assert_eq!(
5934 normalize_model_name_for_provider(ApiProvider::Together, "deepseek-v4-pro").as_deref(),
5935 Some(DEFAULT_TOGETHER_MODEL)
5936 );
5937 assert_eq!(
5938 normalize_model_name_for_provider(ApiProvider::Together, "deepseek-chat").as_deref(),
5939 Some(DEFAULT_TOGETHER_FLASH_MODEL)
5940 );
5941 }
5942
5943 #[test]
5944 fn normalize_model_name_for_provider_maps_recent_openrouter_aliases() {
5945 for (alias, expected) in [
5946 (
5947 "trinity-large-thinking",
5948 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
5949 ),
5950 ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL),
5951 ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
5952 ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
5953 ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL),
5954 ("qwen3.7-plus", OPENROUTER_QWEN_3_7_PLUS_MODEL),
5955 ("qwen-3.7-plus", OPENROUTER_QWEN_3_7_PLUS_MODEL),
5956 ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
5957 ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL),
5958 ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL),
5959 ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
5960 ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL),
5961 ("minimax-2.7", OPENROUTER_MINIMAX_M2_7_MODEL),
5962 ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
5963 ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
5964 ("glm-5.2", OPENROUTER_GLM_5_2_MODEL),
5965 ] {
5966 assert_eq!(
5967 normalize_model_name_for_provider(ApiProvider::Openrouter, alias).as_deref(),
5968 Some(expected)
5969 );
5970 }
5971 }
5972
5973 #[test]
5974 fn normalize_model_name_for_provider_maps_moonshot_aliases() {
5975 for (alias, expected) in [
5976 ("kimi", DEFAULT_MOONSHOT_MODEL),
5977 ("kimi-k2.7", DEFAULT_MOONSHOT_MODEL),
5978 ("kimi-k2.7-code", DEFAULT_MOONSHOT_MODEL),
5979 ("kimi-code", DEFAULT_MOONSHOT_MODEL),
5980 ("kimi-k2.6", MOONSHOT_KIMI_K2_6_MODEL),
5981 ] {
5982 assert_eq!(
5983 normalize_model_name_for_provider(ApiProvider::Moonshot, alias).as_deref(),
5984 Some(expected)
5985 );
5986 }
5987 }
5988
5989 #[test]
5990 fn normalize_model_name_for_provider_maps_minimax_direct_aliases() {
5991 for (alias, expected) in [
5992 ("minimax", DEFAULT_MINIMAX_MODEL),
5993 ("minimax-m3", DEFAULT_MINIMAX_MODEL),
5994 ("minimax-m2.7", MINIMAX_M2_7_MODEL),
5995 ("minimax-m2-7-highspeed", MINIMAX_M2_7_HIGHSPEED_MODEL),
5996 ("minimax-m2.5", MINIMAX_M2_5_MODEL),
5997 ("minimax-m2-5-highspeed", MINIMAX_M2_5_HIGHSPEED_MODEL),
5998 ("minimax-m2.1", MINIMAX_M2_1_MODEL),
5999 ("minimax-m2-1-highspeed", MINIMAX_M2_1_HIGHSPEED_MODEL),
6000 ("minimax-m2", MINIMAX_M2_MODEL),
6001 ] {
6002 assert_eq!(
6003 normalize_model_name_for_provider(ApiProvider::Minimax, alias).as_deref(),
6004 Some(expected)
6005 );
6006 }
6007 }
6008
6009 #[test]
6010 fn normalize_model_name_for_provider_maps_arcee_direct_aliases() {
6011 for (alias, expected) in [
6012 ("trinity", DEFAULT_ARCEE_MODEL),
6013 ("arcee-trinity", DEFAULT_ARCEE_MODEL),
6014 ("trinity-large-thinking", DEFAULT_ARCEE_MODEL),
6015 ("arcee-trinity-large-thinking", DEFAULT_ARCEE_MODEL),
6016 ("arcee-trinity-mini", ARCEE_TRINITY_MINI_MODEL),
6017 ("trinity-mini", ARCEE_TRINITY_MINI_MODEL),
6018 (
6019 "arcee-trinity-large-preview",
6020 ARCEE_TRINITY_LARGE_PREVIEW_MODEL,
6021 ),
6022 ("TRINITY_LARGE_PREVIEW", ARCEE_TRINITY_LARGE_PREVIEW_MODEL),
6023 ] {
6024 assert_eq!(
6025 normalize_model_name_for_provider(ApiProvider::Arcee, alias).as_deref(),
6026 Some(expected)
6027 );
6028 }
6029 }
6030
6031 #[test]
6032 fn normalize_xiaomi_mimo_aliases_for_provider() {
6033 assert_eq!(
6034 normalize_model_name_for_provider(ApiProvider::XiaomiMimo, "omni").as_deref(),
6035 Some("mimo-v2.5")
6036 );
6037 assert_eq!(
6038 normalize_model_name_for_provider(ApiProvider::XiaomiMimo, "tts").as_deref(),
6039 Some("mimo-v2.5-tts")
6040 );
6041 assert_eq!(
6042 normalize_model_name_for_provider(ApiProvider::XiaomiMimo, "voice-design").as_deref(),
6043 Some("mimo-v2.5-tts-voicedesign")
6044 );
6045 assert_eq!(
6046 wire_model_for_provider(ApiProvider::XiaomiMimo, "voiceclone"),
6047 "mimo-v2.5-tts-voiceclone"
6048 );
6049 }
6050
6051 #[test]
6052 fn model_completion_names_for_xiaomi_mimo_include_chat_models() {
6053 let models = model_completion_names_for_provider(ApiProvider::XiaomiMimo);
6054 for expected in ["mimo-v2.5-pro", "mimo-v2.5"] {
6055 assert!(models.contains(&expected), "missing {expected}");
6056 }
6057 for deprecated in ["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"] {
6058 assert!(
6059 !models.contains(&deprecated),
6060 "{deprecated} is deprecated and should not be promoted"
6061 );
6062 }
6063 for speech_model in [
6064 "mimo-v2.5-tts",
6065 "mimo-v2.5-tts-voicedesign",
6066 "mimo-v2.5-tts-voiceclone",
6067 "mimo-v2-tts",
6068 ] {
6069 assert!(
6070 !models.contains(&speech_model),
6071 "{speech_model} belongs in speech/TTS selection, not /model"
6072 );
6073 }
6074 }
6075
6076 #[test]
6077 fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {
6078 assert_eq!(
6079 model_completion_names_for_provider(ApiProvider::Deepseek),
6080 vec!["deepseek-v4-pro", "deepseek-v4-flash"]
6081 );
6082 }
6083
6084 #[test]
6085 fn model_completion_names_for_together_include_provider_owned_models() {
6086 assert_eq!(
6087 model_completion_names_for_provider(ApiProvider::Together),
6088 vec![DEFAULT_TOGETHER_MODEL, DEFAULT_TOGETHER_FLASH_MODEL]
6089 );
6090 }
6091
6092 #[test]
6093 fn model_completion_names_for_wanjie_keep_legacy_default_and_v4_ids() {
6094 let models = model_completion_names_for_provider(ApiProvider::WanjieArk);
6095
6096 assert_eq!(models.first().copied(), Some(DEFAULT_WANJIE_ARK_MODEL));
6097 assert!(models.contains(&"deepseek-v4-pro"));
6098 assert!(models.contains(&"deepseek-v4-flash"));
6099 }
6100
6101 #[test]
6102 fn model_completion_names_for_ollama_do_not_promote_static_remote_models() {
6103 let models = model_completion_names_for_provider(ApiProvider::Ollama);
6104
6105 assert!(models.is_empty());
6106 }
6107
6108 #[test]
6109 fn model_completion_names_for_openrouter_include_recent_large_models() {
6110 let models = model_completion_names_for_provider(ApiProvider::Openrouter);
6111
6112 for expected in [
6113 DEFAULT_OPENROUTER_MODEL,
6114 DEFAULT_OPENROUTER_FLASH_MODEL,
6115 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
6116 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL,
6117 OPENROUTER_MINIMAX_M3_MODEL,
6118 OPENROUTER_MINIMAX_M2_7_MODEL,
6119 OPENROUTER_QWEN_3_6_FLASH_MODEL,
6120 OPENROUTER_QWEN_3_6_35B_A3B_MODEL,
6121 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL,
6122 OPENROUTER_QWEN_3_6_27B_MODEL,
6123 OPENROUTER_QWEN_3_6_PLUS_MODEL,
6124 OPENROUTER_GLM_5_1_MODEL,
6125 OPENROUTER_GLM_5_2_MODEL,
6126 OPENROUTER_GEMMA_4_31B_MODEL,
6127 ] {
6128 assert!(models.contains(&expected), "missing {expected}");
6129 }
6130 }
6131
6132 #[test]
6133 fn model_completion_names_for_moonshot_uses_latest_platform_model() {
6134 let models = model_completion_names_for_provider(ApiProvider::Moonshot);
6135
6136 assert_eq!(models.first().copied(), Some(DEFAULT_MOONSHOT_MODEL));
6137 // `kimi-k3` is served by this provider's default (direct platform) route
6138 // and must be offerable — a dogfood user on v0.9.1 could not find it.
6139 assert!(models.contains(&MOONSHOT_KIMI_K3_MODEL), "{models:?}");
6140 // The Kimi Code coding-plan ids belong to api.kimi.com/coding/v1, which
6141 // this base-URL-less list cannot express. Offering them here would
6142 // advertise a pairing `validate_kimi_code_api_model_id` rejects.
6143 assert!(!models.contains(&KIMI_CODE_K3_MODEL), "{models:?}");
6144 assert!(!models.contains(&DEFAULT_KIMI_CODE_MODEL), "{models:?}");
6145 for model in &models {
6146 let config = Config {
6147 provider: Some(ApiProvider::Moonshot.as_str().to_string()),
6148 default_text_model: Some((*model).to_string()),
6149 ..Default::default()
6150 };
6151 config
6152 .validate()
6153 .expect("every advertised Moonshot model must be valid on its default route");
6154 }
6155 }
6156
6157 #[test]
6158 fn model_completion_names_for_zai_lists_default_5_1_and_turbo() {
6159 let models = model_completion_names_for_provider(ApiProvider::Zai);
6160
6161 // GLM-5.2 is the default and must be first; GLM-5.1 stays available,
6162 // and GLM-5-Turbo is the faster sub-agent sibling.
6163 assert_eq!(models.first().copied(), Some(DEFAULT_ZAI_MODEL));
6164 assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL);
6165 assert!(models.contains(&ZAI_GLM_5_1_MODEL));
6166 assert!(models.contains(&ZAI_GLM_5_TURBO_MODEL));
6167 // GLM-5.3 is offered alongside the others but must not take the default
6168 // slot: adding a model never changes anyone's route.
6169 assert!(models.contains(&ZAI_GLM_5_3_MODEL));
6170 assert_ne!(models.first().copied(), Some(ZAI_GLM_5_3_MODEL));
6171 // No accidental duplicate entries.
6172 let mut sorted = models.to_vec();
6173 sorted.sort_unstable();
6174 let mut deduped = sorted.clone();
6175 deduped.dedup();
6176 assert_eq!(sorted, deduped);
6177 }
6178
6179 #[test]
6180 fn normalize_model_name_for_zai_canonicalizes_current_glm_models() {
6181 for (alias, expected) in [
6182 ("glm-5.1", ZAI_GLM_5_1_MODEL),
6183 ("glm-5-1", ZAI_GLM_5_1_MODEL),
6184 ("glm-5.2", DEFAULT_ZAI_MODEL),
6185 ("zai-glm-5-2", DEFAULT_ZAI_MODEL),
6186 ("glm-5.3", ZAI_GLM_5_3_MODEL),
6187 ("glm-5-3", ZAI_GLM_5_3_MODEL),
6188 ("zai-glm-5-3", ZAI_GLM_5_3_MODEL),
6189 ("glm-5-turbo", ZAI_GLM_5_TURBO_MODEL),
6190 ("zai-glm-5-turbo", ZAI_GLM_5_TURBO_MODEL),
6191 ] {
6192 assert_eq!(
6193 normalize_model_name_for_provider(ApiProvider::Zai, alias).as_deref(),
6194 Some(expected)
6195 );
6196 }
6197 // The 5.1-era bug shape: a new alias silently resolving to the provider
6198 // default. GLM-5.3 must keep its own id.
6199 assert_ne!(ZAI_GLM_5_3_MODEL, DEFAULT_ZAI_MODEL);
6200 assert_eq!(
6201 normalize_model_name_for_provider(ApiProvider::Zai, "glm-5.3").as_deref(),
6202 Some(ZAI_GLM_5_3_MODEL)
6203 );
6204 assert_eq!(
6205 normalize_model_name_for_provider(ApiProvider::Zai, "glm-next-preview").as_deref(),
6206 Some("glm-next-preview")
6207 );
6208 }
6209
6210 #[test]
6211 fn model_completion_names_for_minimax_include_direct_chat_models() {
6212 let models = model_completion_names_for_provider(ApiProvider::Minimax);
6213
6214 for expected in [
6215 DEFAULT_MINIMAX_MODEL,
6216 MINIMAX_M2_7_MODEL,
6217 MINIMAX_M2_7_HIGHSPEED_MODEL,
6218 MINIMAX_M2_5_MODEL,
6219 MINIMAX_M2_5_HIGHSPEED_MODEL,
6220 MINIMAX_M2_1_MODEL,
6221 MINIMAX_M2_1_HIGHSPEED_MODEL,
6222 MINIMAX_M2_MODEL,
6223 ] {
6224 assert!(models.contains(&expected), "missing {expected}");
6225 }
6226 assert!(
6227 !models.contains(&OPENROUTER_MINIMAX_M3_MODEL),
6228 "direct MiniMax picker must not expose OpenRouter namespaced IDs"
6229 );
6230 }
6231
6232 #[test]
6233 fn model_completion_names_for_minimax_anthropic_include_target_models() {
6234 let models = model_completion_names_for_provider(ApiProvider::MinimaxAnthropic);
6235
6236 assert!(models.contains(&DEFAULT_MINIMAX_MODEL));
6237 assert!(models.contains(&MINIMAX_M2_7_MODEL));
6238 }
6239
6240 #[test]
6241 fn model_completion_names_for_sakana_include_fugu_models() {
6242 assert_eq!(
6243 model_completion_names_for_provider(ApiProvider::Sakana),
6244 vec![DEFAULT_SAKANA_MODEL, SAKANA_FUGU_ULTRA_MODEL]
6245 );
6246 }
6247
6248 #[test]
6249 fn opencode_go_config_uses_only_current_chat_completions_models() -> Result<()> {
6250 let _lock = lock_test_env();
6251 let _api_key = EnvVarGuard::remove("OPENCODE_GO_API_KEY");
6252 let _base_url = EnvVarGuard::remove("OPENCODE_GO_BASE_URL");
6253 let _model = EnvVarGuard::remove("OPENCODE_GO_MODEL");
6254
6255 let config: Config = toml::from_str(
6256 r#"
6257 provider = "opencode_go"
6258
6259 [providers.opencode_go]
6260 api_key = "go-config-key"
6261 model = "opencode-go/glm-5.2"
6262 "#,
6263 )?;
6264
6265 assert_eq!(config.api_provider(), ApiProvider::OpencodeGo);
6266 assert_eq!(config.deepseek_base_url(), DEFAULT_OPENCODE_GO_BASE_URL);
6267 assert_eq!(config.default_model(), "glm-5.2");
6268 assert_eq!(config.deepseek_api_key()?, "go-config-key");
6269 assert_eq!(
6270 wire_model_for_provider(ApiProvider::OpencodeGo, "opencode-go/mimo-v2.5-pro"),
6271 "mimo-v2.5-pro"
6272 );
6273 assert_eq!(
6274 model_completion_names_for_provider(ApiProvider::OpencodeGo),
6275 OPENCODE_GO_CHAT_MODELS.to_vec()
6276 );
6277 for chat_model in OPENCODE_GO_CHAT_MODELS {
6278 assert_eq!(
6279 canonical_model_id_for_provider(ApiProvider::OpencodeGo, chat_model).as_deref(),
6280 Some(*chat_model)
6281 );
6282 assert!(validate_route(ApiProvider::OpencodeGo, chat_model).is_ok());
6283 }
6284 for messages_only in [
6285 "minimax-m3",
6286 "minimax-m2.7",
6287 "minimax-m2.5",
6288 "qwen3.7-max",
6289 "qwen3.7-plus",
6290 "qwen3.6-plus",
6291 ] {
6292 assert!(
6293 !model_completion_names_for_provider(ApiProvider::OpencodeGo).contains(&messages_only),
6294 "{messages_only} uses the Messages endpoint and must not be advertised"
6295 );
6296 assert!(
6297 canonical_model_id_for_provider(ApiProvider::OpencodeGo, messages_only).is_none(),
6298 "{messages_only} must not pass the explicit selector gate"
6299 );
6300 assert!(
6301 requested_model_for_provider(ApiProvider::OpencodeGo, messages_only).is_none(),
6302 "{messages_only} must not pass the runtime request gate"
6303 );
6304 assert!(validate_route(ApiProvider::OpencodeGo, messages_only).is_err());
6305 // Never substitute a different model. Keep the caller's spelling so
6306 // validate_route / the route resolver can reject by name. A base URL
6307 // override still cannot promote a Messages-only id onto Chat Completions.
6308 assert_eq!(
6309 wire_model_for_provider(ApiProvider::OpencodeGo, messages_only),
6310 messages_only,
6311 "must not silently rewrite {messages_only} to the Chat default"
6312 );
6313 assert_eq!(
6314 wire_model_for_provider_route(
6315 ApiProvider::OpencodeGo,
6316 "https://go-gateway.example/v1",
6317 messages_only,
6318 ),
6319 messages_only,
6320 "a base URL override must not rewrite or re-admit {messages_only}"
6321 );
6322 }
6323
6324 Ok(())
6325 }
6326
6327 #[test]
6328 fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {
6329 assert!(normalize_model_name("qwen3-coder").is_none());
6330 assert!(normalize_model_name("codewhale v4").is_none());
6331 assert!(normalize_model_name("").is_none());
6332 }
6333
6334 #[test]
6335 fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {
6336 assert_eq!(
6337 normalize_model_name("accounts/fireworks/models/deepseek-v4-flash").as_deref(),
6338 Some("accounts/fireworks/models/deepseek-v4-flash")
6339 );
6340 assert_eq!(
6341 normalize_model_name("provider/deepseek-ai/deepseek-v4-pro").as_deref(),
6342 Some("provider/deepseek-ai/deepseek-v4-pro")
6343 );
6344 }
6345
6346 #[test]
6347 fn default_context_seams_are_opt_in() {
6348 let config = Config::default();
6349 assert!(!config.context.enabled.unwrap_or(false));
6350 assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);
6351 assert_eq!(
6352 config
6353 .context
6354 .seam_model
6355 .as_deref()
6356 .unwrap_or("deepseek-v4-flash"),
6357 "deepseek-v4-flash"
6358 );
6359 }
6360
6361 #[test]
6362 fn profile_without_context_does_not_disable_base_context() {
6363 let mut profiles = HashMap::new();
6364 profiles.insert("work".to_string(), Config::default());
6365 let config = ConfigFile {
6366 base: Config {
6367 context: ContextConfig {
6368 enabled: Some(true),
6369 ..Default::default()
6370 },
6371 ..Default::default()
6372 },
6373 profiles: Some(profiles),
6374 };
6375
6376 let merged = apply_profile(config, Some("work")).expect("profile");
6377 assert_eq!(merged.context.enabled, Some(true));
6378 }
6379
6380 #[test]
6381 fn profile_skills_config_merges_individual_fields() {
6382 let mut profiles = HashMap::new();
6383 profiles.insert(
6384 "strict".to_string(),
6385 Config {
6386 skills: Some(SkillsConfig {
6387 scan_codewhale_only: Some(true),
6388 ..Default::default()
6389 }),
6390 ..Default::default()
6391 },
6392 );
6393 let config = ConfigFile {
6394 base: Config {
6395 skills: Some(SkillsConfig {
6396 registry_url: Some("https://registry.example/skills.json".to_string()),
6397 max_install_size_bytes: Some(1234),
6398 ..Default::default()
6399 }),
6400 ..Default::default()
6401 },
6402 profiles: Some(profiles),
6403 };
6404
6405 let merged = apply_profile(config, Some("strict")).expect("profile");
6406 let skills = merged.skills.expect("merged skills config");
6407 assert_eq!(
6408 skills.registry_url.as_deref(),
6409 Some("https://registry.example/skills.json")
6410 );
6411 assert_eq!(skills.max_install_size_bytes, Some(1234));
6412 assert_eq!(skills.scan_codewhale_only, Some(true));
6413 }
6414
6415 #[test]
6416 fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {
6417 let parsed: ConfigFile = toml::from_str(
6418 r#"
6419 [context]
6420 enabled = true
6421
6422 [context.per_model.deepseek-v4-pro]
6423 l1_threshold = 111
6424 l2_threshold = 222
6425 l3_threshold = 333
6426 "#,
6427 )?;
6428
6429 assert_eq!(parsed.base.context.enabled, Some(true));
6430 Ok(())
6431 }
6432
6433 #[test]
6434 fn project_context_pack_defaults_off_and_can_be_enabled() {
6435 // #4781: project context pack is opt-in (large pretty-printed tree).
6436 let mut config = Config::default();
6437 assert!(!config.project_context_pack_enabled());
6438
6439 config.context.project_pack = Some(true);
6440 assert!(config.project_context_pack_enabled());
6441
6442 config.context.project_pack = Some(false);
6443 assert!(!config.project_context_pack_enabled());
6444 }
6445
6446 #[test]
6447 fn validate_accepts_future_deepseek_model_id() -> Result<()> {
6448 let config = Config {
6449 default_text_model: Some("deepseek-v4".to_string()),
6450 ..Default::default()
6451 };
6452 config.validate()?;
6453 Ok(())
6454 }
6455
6456 #[test]
6457 fn validate_accepts_auto_default_text_model() -> Result<()> {
6458 let config = Config {
6459 default_text_model: Some("auto".to_string()),
6460 ..Default::default()
6461 };
6462 config.validate()?;
6463 assert_eq!(config.default_model(), "auto");
6464 Ok(())
6465 }
6466
6467 #[test]
6468 fn deepseek_provider_defaults_to_beta_endpoint() {
6469 let config = Config::default();
6470
6471 assert_eq!(config.api_provider(), ApiProvider::Deepseek);
6472 assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);
6473 }
6474
6475 #[test]
6476 fn explicit_deepseek_base_url_overrides_beta_default() {
6477 let config = Config {
6478 base_url: Some("https://api.deepseek.com".to_string()),
6479 ..Default::default()
6480 };
6481
6482 assert_eq!(config.api_provider(), ApiProvider::Deepseek);
6483 assert_eq!(config.deepseek_base_url(), "https://api.deepseek.com");
6484 }
6485
6486 #[test]
6487 fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {
6488 let _lock = lock_test_env();
6489 let config = Config {
6490 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
6491 ..Default::default()
6492 };
6493
6494 assert_eq!(config.api_provider(), ApiProvider::Deepseek);
6495 assert!(has_api_key(&config));
6496 assert_eq!(config.deepseek_api_key()?, "");
6497 Ok(())
6498 }
6499
6500 #[test]
6501 fn deepseek_model_env_overrides_default_text_model() -> Result<()> {
6502 let _lock = lock_test_env();
6503 let nanos = SystemTime::now()
6504 .duration_since(UNIX_EPOCH)
6505 .unwrap()
6506 .as_nanos();
6507 let temp_root = env::temp_dir().join(format!(
6508 "codewhale-tui-model-env-test-{}-{}",
6509 std::process::id(),
6510 nanos
6511 ));
6512 fs::create_dir_all(&temp_root)?;
6513 let _guard = EnvGuard::new(&temp_root);
6514
6515 // Safety: test-only environment mutation guarded by a global mutex.
6516 unsafe {
6517 env::set_var("DEEPSEEK_MODEL", "deepseek-v4-flash-20260423");
6518 }
6519
6520 let config = Config::load(None, None)?;
6521 // v-series snapshots pass through unchanged — no alias folding
6522 assert_eq!(
6523 config.default_text_model.as_deref(),
6524 Some("deepseek-v4-flash-20260423")
6525 );
6526 Ok(())
6527 }
6528
6529 #[test]
6530 fn retired_deepseek_aliases_from_env_are_migrated_before_runtime() -> Result<()> {
6531 let _lock = lock_test_env();
6532 let temp_root = tempfile::tempdir()?;
6533 let _managed_config = crate::test_support::EnvVarGuard::set(
6534 "DEEPSEEK_MANAGED_CONFIG_PATH",
6535 temp_root.path().join("missing-managed.toml"),
6536 );
6537
6538 for (provider, alias, expected_effort) in [
6539 ("deepseek", "deepseek-chat", "off"),
6540 ("deepseek-cn", "deepseek-reasoner", "high"),
6541 ("deepseek-anthropic", "deepseek-chat", "off"),
6542 ] {
6543 let _guard = EnvGuard::new(temp_root.path());
6544 // Safety: test-only environment mutation guarded by a global mutex.
6545 unsafe {
6546 env::set_var("CODEWHALE_PROVIDER", provider);
6547 env::set_var("CODEWHALE_MODEL", alias);
6548 }
6549
6550 // Pass the isolated path explicitly: the process-wide default config
6551 // path is cached by earlier tests and can otherwise point back at the
6552 // developer's real provider-scoped model.
6553 let config = Config::load(
6554 Some(temp_root.path().join("isolated-alias-config.toml")),
6555 None,
6556 )?;
6557 assert_eq!(
6558 config.default_model(),
6559 DEEPSEEK_ALIAS_REPLACEMENT,
6560 "provider={provider} resolved={:?} root_model={:?} scoped_model={:?}",
6561 config.api_provider(),
6562 config.default_text_model,
6563 config
6564 .provider_config_for(config.api_provider())
6565 .and_then(|entry| entry.model.as_deref())
6566 );
6567 assert_eq!(config.reasoning_effort(), Some(expected_effort));
6568 let deprecation = config
6569 .active_deepseek_alias_deprecation()
6570 .expect("loaded config should retain the alias migration receipt");
6571 assert_eq!(deprecation.alias, alias);
6572 assert_eq!(deprecation.replacement, DEEPSEEK_ALIAS_REPLACEMENT);
6573 }
6574
6575 Ok(())
6576 }
6577
6578 #[test]
6579 fn http_headers_load_from_root_config() -> Result<()> {
6580 let _lock = lock_test_env();
6581 let nanos = SystemTime::now()
6582 .duration_since(UNIX_EPOCH)
6583 .unwrap()
6584 .as_nanos();
6585 let temp_root = env::temp_dir().join(format!(
6586 "codewhale-tui-http-headers-root-{}-{}",
6587 std::process::id(),
6588 nanos
6589 ));
6590 fs::create_dir_all(&temp_root)?;
6591 let _guard = EnvGuard::new(&temp_root);
6592
6593 let config_path = temp_root.join(".deepseek").join("config.toml");
6594 ensure_parent_dir(&config_path)?;
6595 fs::write(
6596 &config_path,
6597 r#"
6598 api_key = "test-key"
6599 http_headers = { "X-Model-Provider-Id" = "tongyi" }
6600 "#,
6601 )?;
6602
6603 let config = Config::load(None, None)?;
6604 assert_eq!(
6605 config
6606 .http_headers()
6607 .get("X-Model-Provider-Id")
6608 .map(String::as_str),
6609 Some("tongyi")
6610 );
6611 Ok(())
6612 }
6613
6614 #[test]
6615 fn provider_http_headers_extend_and_override_root_config() {
6616 let mut providers = ProvidersConfig::default();
6617 providers.deepseek.http_headers = Some(HashMap::from([
6618 ("X-Model-Provider-Id".to_string(), "tongyi".to_string()),
6619 ("X-Shared".to_string(), "provider".to_string()),
6620 ]));
6621 let config = Config {
6622 http_headers: Some(HashMap::from([
6623 ("X-Root".to_string(), "root".to_string()),
6624 ("X-Shared".to_string(), "root".to_string()),
6625 ])),
6626 providers: Some(providers),
6627 ..Default::default()
6628 };
6629
6630 let headers = config.http_headers();
6631 assert_eq!(
6632 headers.get("X-Model-Provider-Id").map(String::as_str),
6633 Some("tongyi")
6634 );
6635 assert_eq!(headers.get("X-Root").map(String::as_str), Some("root"));
6636 assert_eq!(
6637 headers.get("X-Shared").map(String::as_str),
6638 Some("provider")
6639 );
6640 }
6641
6642 #[test]
6643 fn http_headers_env_overrides_config() -> Result<()> {
6644 let _lock = lock_test_env();
6645 let nanos = SystemTime::now()
6646 .duration_since(UNIX_EPOCH)
6647 .unwrap()
6648 .as_nanos();
6649 let temp_root = env::temp_dir().join(format!(
6650 "codewhale-tui-http-headers-env-{}-{}",
6651 std::process::id(),
6652 nanos
6653 ));
6654 fs::create_dir_all(&temp_root)?;
6655 let _guard = EnvGuard::new(&temp_root);
6656
6657 let config_path = temp_root.join(".deepseek").join("config.toml");
6658 ensure_parent_dir(&config_path)?;
6659 fs::write(
6660 &config_path,
6661 r#"
6662 api_key = "test-key"
6663 http_headers = { "X-Model-Provider-Id" = "from-file" }
6664 "#,
6665 )?;
6666 // Safety: test-only environment mutation guarded by a global mutex.
6667 unsafe {
6668 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
6669 }
6670
6671 let config = Config::load(None, None)?;
6672 assert_eq!(
6673 config
6674 .http_headers()
6675 .get("X-Model-Provider-Id")
6676 .map(String::as_str),
6677 Some("from-env")
6678 );
6679 Ok(())
6680 }
6681
6682 #[test]
6683 fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {
6684 let config = Config {
6685 provider: Some("nvidia-nim".to_string()),
6686 ..Default::default()
6687 };
6688
6689 config.validate()?;
6690 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
6691 assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
6692 assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);
6693 Ok(())
6694 }
6695
6696 #[test]
6697 fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {
6698 let _lock = lock_test_env();
6699 let nanos = SystemTime::now()
6700 .duration_since(UNIX_EPOCH)
6701 .unwrap()
6702 .as_nanos();
6703 let temp_root = env::temp_dir().join(format!(
6704 "codewhale-tui-nim-model-alias-test-{}-{}",
6705 std::process::id(),
6706 nanos
6707 ));
6708 fs::create_dir_all(&temp_root)?;
6709 let _guard = EnvGuard::new(&temp_root);
6710
6711 let config_path = temp_root.join(".deepseek").join("config.toml");
6712 ensure_parent_dir(&config_path)?;
6713 fs::write(
6714 &config_path,
6715 "provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-pro\"\napi_key = \"nim-key\"\n",
6716 )?;
6717
6718 let config = Config::load(None, None)?;
6719 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
6720 assert_eq!(
6721 config.default_text_model.as_deref(),
6722 Some(DEFAULT_NVIDIA_NIM_MODEL)
6723 );
6724 Ok(())
6725 }
6726
6727 #[test]
6728 fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {
6729 let _lock = lock_test_env();
6730 let nanos = SystemTime::now()
6731 .duration_since(UNIX_EPOCH)
6732 .unwrap()
6733 .as_nanos();
6734 let temp_root = env::temp_dir().join(format!(
6735 "codewhale-tui-nim-flash-model-alias-test-{}-{}",
6736 std::process::id(),
6737 nanos
6738 ));
6739 fs::create_dir_all(&temp_root)?;
6740 let _guard = EnvGuard::new(&temp_root);
6741
6742 let config = Config {
6743 provider: Some("nvidia-nim".to_string()),
6744 default_text_model: Some("deepseek-v4-flash".to_string()),
6745 ..Default::default()
6746 };
6747
6748 config.validate()?;
6749 assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);
6750 Ok(())
6751 }
6752
6753 #[test]
6754 fn vendor_locked_providers_reject_foreign_root_default_model() {
6755 let _lock = lock_test_env();
6756 for (provider, expected) in [
6757 ("xai", DEFAULT_XAI_MODEL),
6758 ("openai", DEFAULT_OPENAI_MODEL),
6759 ("moonshot", DEFAULT_MOONSHOT_MODEL),
6760 ] {
6761 let config = Config {
6762 provider: Some(provider.to_string()),
6763 default_text_model: Some("deepseek-v4-pro".to_string()),
6764 ..Default::default()
6765 };
6766 assert_eq!(
6767 config.default_model(),
6768 expected,
6769 "a root DeepSeek default must not leak onto the official {provider} endpoint"
6770 );
6771 }
6772 }
6773
6774 #[test]
6775 fn xai_custom_endpoint_keeps_root_default_model_pass_through() {
6776 let _lock = lock_test_env();
6777 let mut providers = ProvidersConfig::default();
6778 providers.xai.base_url = Some("https://proxy.example.test/v1".to_string());
6779 let config = Config {
6780 provider: Some("xai".to_string()),
6781 default_text_model: Some("deepseek-v4-pro".to_string()),
6782 providers: Some(providers),
6783 ..Default::default()
6784 };
6785 assert_eq!(
6786 config.default_model(),
6787 "deepseek-v4-pro",
6788 "custom compatible endpoints may serve any model id"
6789 );
6790 }
6791
6792 #[test]
6793 fn xai_explicit_provider_model_is_honored_over_vendor_default() {
6794 let _lock = lock_test_env();
6795 let mut providers = ProvidersConfig::default();
6796 providers.xai.model = Some("grok-4.5-mini".to_string());
6797 let config = Config {
6798 provider: Some("xai".to_string()),
6799 default_text_model: Some("deepseek-v4-pro".to_string()),
6800 providers: Some(providers),
6801 ..Default::default()
6802 };
6803 assert_eq!(config.default_model(), "grok-4.5-mini");
6804 }
6805
6806 #[test]
6807 fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {
6808 let _lock = lock_test_env();
6809 let nanos = SystemTime::now()
6810 .duration_since(UNIX_EPOCH)
6811 .unwrap()
6812 .as_nanos();
6813 let temp_root = env::temp_dir().join(format!(
6814 "codewhale-tui-nim-env-test-{}-{}",
6815 std::process::id(),
6816 nanos
6817 ));
6818 fs::create_dir_all(&temp_root)?;
6819 let _guard = EnvGuard::new(&temp_root);
6820
6821 // Safety: test-only environment mutation guarded by a global mutex.
6822 unsafe {
6823 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
6824 env::set_var("NVIDIA_API_KEY", "nim-env-key");
6825 env::set_var("NVIDIA_NIM_MODEL", "deepseek-ai/deepseek-v4-pro");
6826 }
6827
6828 let config = Config::load(None, None)?;
6829 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
6830 assert_eq!(config.deepseek_api_key()?, "nim-env-key");
6831 assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
6832 Ok(())
6833 }
6834
6835 #[test]
6836 fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {
6837 let _lock = lock_test_env();
6838 let nanos = SystemTime::now()
6839 .duration_since(UNIX_EPOCH)
6840 .unwrap()
6841 .as_nanos();
6842 let temp_root = env::temp_dir().join(format!(
6843 "codewhale-tui-nim-base-url-alias-test-{}-{}",
6844 std::process::id(),
6845 nanos
6846 ));
6847 fs::create_dir_all(&temp_root)?;
6848 let _guard = EnvGuard::new(&temp_root);
6849
6850 // Safety: test-only environment mutation guarded by a global mutex.
6851 unsafe {
6852 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
6853 env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
6854 }
6855
6856 let config = Config::load(None, None)?;
6857 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
6858 assert_eq!(config.deepseek_base_url(), "https://short-nim.example/v1");
6859 Ok(())
6860 }
6861
6862 #[test]
6863 fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {
6864 let _lock = lock_test_env();
6865 let nanos = SystemTime::now()
6866 .duration_since(UNIX_EPOCH)
6867 .unwrap()
6868 .as_nanos();
6869 let temp_root = env::temp_dir().join(format!(
6870 "codewhale-tui-nim-forwarded-base-url-test-{}-{}",
6871 std::process::id(),
6872 nanos
6873 ));
6874 fs::create_dir_all(&temp_root)?;
6875 let _guard = EnvGuard::new(&temp_root);
6876
6877 // Safety: test-only environment mutation guarded by a global mutex.
6878 unsafe {
6879 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
6880 env::set_var("DEEPSEEK_BASE_URL", "https://forwarded-nim.example/v1");
6881 }
6882
6883 let config = Config::load(None, None)?;
6884 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
6885 assert_eq!(
6886 config.deepseek_base_url(),
6887 "https://forwarded-nim.example/v1"
6888 );
6889 Ok(())
6890 }
6891
6892 #[test]
6893 fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {
6894 let config = Config {
6895 provider: Some("openai".to_string()),
6896 ..Default::default()
6897 };
6898
6899 config.validate()?;
6900 assert_eq!(config.api_provider(), ApiProvider::Openai);
6901 assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);
6902 assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);
6903 Ok(())
6904 }
6905
6906 #[test]
6907 fn openai_codex_default_model_falls_back_to_codex_model() {
6908 // The Codex Responses backend only accepts its own model family, and a
6909 // global `default_text_model` is validated to DeepSeek IDs (or "auto"),
6910 // so with the Codex provider it must resolve to the Codex default
6911 // instead of leaking a DeepSeek id the backend rejects.
6912 // Isolate from any ambient developer Codex roster: the seed fallback is
6913 // asserted here; the fresh-roster preference (#5034) is covered by
6914 // codex_switch_without_saved_model_prefers_fresh_roster_head.
6915 let _lock = lock_test_env();
6916 let empty_codex_home = tempfile::tempdir().expect("empty codex home");
6917 let _codex_home = EnvVarGuard::set("CODEX_HOME", empty_codex_home.path());
6918 let with_deepseek_default = Config {
6919 provider: Some("openai-codex".to_string()),
6920 default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
6921 ..Default::default()
6922 };
6923 assert_eq!(
6924 with_deepseek_default.api_provider(),
6925 ApiProvider::OpenaiCodex
6926 );
6927 assert_eq!(
6928 with_deepseek_default.default_model(),
6929 DEFAULT_OPENAI_CODEX_MODEL
6930 );
6931
6932 // No global default resolves the same way.
6933 let bare = Config {
6934 provider: Some("openai-codex".to_string()),
6935 ..Default::default()
6936 };
6937 assert_eq!(bare.default_model(), DEFAULT_OPENAI_CODEX_MODEL);
6938
6939 // An explicit provider-scoped model still wins over the fallback.
6940 let mut providers = ProvidersConfig::default();
6941 providers.openai_codex.model = Some("gpt-5.5-codex-preview".to_string());
6942 let pinned = Config {
6943 provider: Some("openai-codex".to_string()),
6944 default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
6945 providers: Some(providers),
6946 ..Default::default()
6947 };
6948 assert_eq!(pinned.default_model(), "gpt-5.5-codex-preview");
6949 }
6950
6951 #[test]
6952 fn direct_provider_ignores_foreign_deepseek_root_default_model() {
6953 let _lock = lock_test_env();
6954
6955 let config = Config {
6956 provider: Some("zai".to_string()),
6957 default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
6958 ..Default::default()
6959 };
6960
6961 assert_eq!(config.api_provider(), ApiProvider::Zai);
6962 assert_eq!(config.default_model(), DEFAULT_ZAI_MODEL);
6963 }
6964
6965 #[test]
6966 fn insecure_skip_tls_verify_is_scoped_to_active_provider() {
6967 let mut providers = ProvidersConfig::default();
6968 providers.deepseek.insecure_skip_tls_verify = Some(true);
6969 providers.openai.insecure_skip_tls_verify = Some(false);
6970 let config = Config {
6971 provider: Some("openai".to_string()),
6972 providers: Some(providers),
6973 ..Default::default()
6974 };
6975
6976 assert_eq!(config.api_provider(), ApiProvider::Openai);
6977 assert!(!config.insecure_skip_tls_verify());
6978 }
6979
6980 #[test]
6981 fn insecure_skip_tls_verify_reads_active_provider_table() {
6982 let mut providers = ProvidersConfig::default();
6983 providers.openai.insecure_skip_tls_verify = Some(true);
6984 let config = Config {
6985 provider: Some("openai".to_string()),
6986 providers: Some(providers),
6987 ..Default::default()
6988 };
6989
6990 assert!(config.insecure_skip_tls_verify());
6991 }
6992
6993 #[test]
6994 fn xiaomi_mimo_provider_uses_documented_defaults() -> Result<()> {
6995 let _lock = lock_test_env();
6996 let nanos = SystemTime::now()
6997 .duration_since(UNIX_EPOCH)
6998 .unwrap()
6999 .as_nanos();
7000 let temp_root = env::temp_dir().join(format!(
7001 "codewhale-tui-xiaomi-mimo-defaults-{}-{}",
7002 std::process::id(),
7003 nanos
7004 ));
7005 fs::create_dir_all(&temp_root)?;
7006 let _guard = EnvGuard::new(&temp_root);
7007
7008 let config = Config {
7009 provider: Some("xiaomi-mimo".to_string()),
7010 ..Default::default()
7011 };
7012
7013 config.validate()?;
7014 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7015 assert_eq!(config.default_model(), DEFAULT_XIAOMI_MIMO_MODEL);
7016 assert_eq!(config.deepseek_base_url(), DEFAULT_XIAOMI_MIMO_BASE_URL);
7017 Ok(())
7018 }
7019
7020 #[test]
7021 fn xiaomi_mimo_provider_honours_root_default_model_and_base_url() -> Result<()> {
7022 let config = Config {
7023 provider: Some("xiaomi-mimo".to_string()),
7024 base_url: Some("https://token-plan-cn.xiaomimimo.com/v1".to_string()),
7025 default_text_model: Some("mimo-v2.5".to_string()),
7026 ..Default::default()
7027 };
7028
7029 config.validate()?;
7030 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7031 assert_eq!(config.default_model(), "mimo-v2.5");
7032 assert_eq!(
7033 config.deepseek_base_url(),
7034 "https://token-plan-cn.xiaomimimo.com/v1"
7035 );
7036 Ok(())
7037 }
7038
7039 #[test]
7040 fn xiaomi_mimo_provider_drops_stale_deepseek_root_default_model() -> Result<()> {
7041 // A leftover DeepSeek id after a provider switch must not be forwarded to
7042 // Xiaomi. Fall back to the MiMo seed default instead of substituting a
7043 // different *configured* model.
7044 let config = Config {
7045 provider: Some("xiaomi-mimo".to_string()),
7046 default_text_model: Some(DEFAULT_OPENROUTER_MODEL.to_string()),
7047 ..Default::default()
7048 };
7049
7050 config.validate()?;
7051 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7052 assert_eq!(config.default_model(), DEFAULT_XIAOMI_MIMO_MODEL);
7053 Ok(())
7054 }
7055
7056 #[test]
7057 fn openai_codex_provider_ignores_legacy_root_base_url() -> Result<()> {
7058 let config = Config {
7059 provider: Some("openai-codex".to_string()),
7060 // `base_url` is the legacy DeepSeek setting in a normal multi-provider
7061 // config. Switching to Codex must not inherit it and make the official
7062 // CLI OAuth login ineligible.
7063 base_url: Some("https://api.deepseek.com".to_string()),
7064 default_text_model: Some("gpt-5.5".to_string()),
7065 ..Default::default()
7066 };
7067
7068 config.validate()?;
7069 assert_eq!(config.api_provider(), ApiProvider::OpenaiCodex);
7070 assert_eq!(config.default_model(), "gpt-5.5");
7071 assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_CODEX_BASE_URL);
7072 assert!(!config.provider_uses_custom_endpoint(ApiProvider::OpenaiCodex));
7073 Ok(())
7074 }
7075
7076 #[test]
7077 fn xiaomi_provider_alias_table_maps_to_mimo_config() -> Result<()> {
7078 let config: Config = toml::from_str(
7079 r#"
7080 provider = "xiaomi-mimo"
7081 default_text_model = "deepseek/deepseek-v4-pro"
7082
7083 [providers.xiaomi]
7084 api_key = "mimo-table-key"
7085 base_url = "https://token-plan-sgp.xiaomimimo.com/v1"
7086 model = "mimo-v2.5-pro"
7087 "#,
7088 )?;
7089
7090 config.validate()?;
7091 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7092 assert_eq!(config.deepseek_api_key()?, "mimo-table-key");
7093 assert_eq!(
7094 config.deepseek_base_url(),
7095 "https://token-plan-sgp.xiaomimimo.com/v1"
7096 );
7097 assert_eq!(config.default_model(), DEFAULT_XIAOMI_MIMO_MODEL);
7098 Ok(())
7099 }
7100
7101 #[test]
7102 fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() -> Result<()> {
7103 let config: Config = toml::from_str(
7104 r#"
7105 provider = "xiaomi-mimo"
7106
7107 [providers.xiaomi_mimo]
7108 api_key = "tp-test-token-plan-key"
7109 base_url = "https://api.xiaomimimo.com/v1"
7110 model = "mimo-v2.5-pro"
7111 "#,
7112 )?;
7113
7114 config.validate()?;
7115 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7116 assert_eq!(config.deepseek_base_url(), DEFAULT_XIAOMI_MIMO_BASE_URL);
7117 assert_eq!(config.default_model(), DEFAULT_XIAOMI_MIMO_MODEL);
7118 Ok(())
7119 }
7120
7121 #[test]
7122 fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() -> Result<()> {
7123 let config: Config = toml::from_str(
7124 r#"
7125 provider = "mimo"
7126
7127 [providers.mimo]
7128 mode = "token-plan-ams"
7129 "#,
7130 )?;
7131
7132 config.validate()?;
7133 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7134 assert_eq!(
7135 config.deepseek_base_url(),
7136 XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
7137 );
7138 Ok(())
7139 }
7140
7141 #[test]
7142 fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() -> Result<()> {
7143 let config: Config = toml::from_str(
7144 r#"
7145 provider = "mimo"
7146
7147 [providers.mimo]
7148 mode = "token-plan-usa"
7149 "#,
7150 )?;
7151
7152 config.validate()?;
7153 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7154 assert_eq!(config.deepseek_base_url(), DEFAULT_XIAOMI_MIMO_BASE_URL);
7155 Ok(())
7156 }
7157
7158 #[test]
7159 fn xiaomi_mimo_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
7160 let _lock = lock_test_env();
7161 let nanos = SystemTime::now()
7162 .duration_since(UNIX_EPOCH)
7163 .unwrap()
7164 .as_nanos();
7165 let temp_root = env::temp_dir().join(format!(
7166 "codewhale-tui-xiaomi-mimo-env-test-{}-{}",
7167 std::process::id(),
7168 nanos
7169 ));
7170 fs::create_dir_all(&temp_root)?;
7171 let _guard = EnvGuard::new(&temp_root);
7172
7173 // Safety: test-only environment mutation guarded by a global mutex.
7174 unsafe {
7175 env::set_var("DEEPSEEK_PROVIDER", "mimo");
7176 env::set_var("MIMO_API_KEY", "mimo-env-key");
7177 env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
7178 env::set_var("MIMO_MODEL", "mimo-v2.5");
7179 }
7180
7181 let config = Config::load(None, None)?;
7182 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7183 let error = config
7184 .deepseek_api_key()
7185 .expect_err("ambient key must not follow a custom endpoint");
7186 assert!(error.to_string().contains("must be bound explicitly"));
7187 assert!(!has_api_key(&config));
7188 assert_eq!(
7189 config.deepseek_base_url(),
7190 "https://mimo-gateway.example/v1"
7191 );
7192 assert_eq!(config.default_model(), "mimo-v2.5");
7193 Ok(())
7194 }
7195
7196 #[test]
7197 fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() -> Result<()> {
7198 let _lock = lock_test_env();
7199 let nanos = SystemTime::now()
7200 .duration_since(UNIX_EPOCH)
7201 .unwrap()
7202 .as_nanos();
7203 let temp_root = env::temp_dir().join(format!(
7204 "codewhale-tui-xiaomi-mimo-token-plan-env-test-{}-{}",
7205 std::process::id(),
7206 nanos
7207 ));
7208 fs::create_dir_all(&temp_root)?;
7209 let _guard = EnvGuard::new(&temp_root);
7210
7211 // Safety: test-only environment mutation guarded by a global mutex.
7212 unsafe {
7213 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7214 env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn");
7215 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7216 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7217 env::set_var("XIAOMI_MIMO_MODEL", "voiceclone");
7218 }
7219
7220 let config = Config::load(None, None)?;
7221 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7222 assert_eq!(config.deepseek_api_key()?, "tp-env-key");
7223 assert_eq!(
7224 config.deepseek_base_url(),
7225 XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
7226 );
7227 assert_eq!(config.default_model(), "voiceclone");
7228 Ok(())
7229 }
7230
7231 #[test]
7232 fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() -> Result<()> {
7233 let _lock = lock_test_env();
7234 let nanos = SystemTime::now()
7235 .duration_since(UNIX_EPOCH)
7236 .unwrap()
7237 .as_nanos();
7238 let temp_root = env::temp_dir().join(format!(
7239 "codewhale-tui-xiaomi-mimo-payg-env-test-{}-{}",
7240 std::process::id(),
7241 nanos
7242 ));
7243 fs::create_dir_all(&temp_root)?;
7244 let _guard = EnvGuard::new(&temp_root);
7245
7246 // Safety: test-only environment mutation guarded by a global mutex.
7247 unsafe {
7248 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7249 env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go");
7250 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7251 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7252 }
7253
7254 let config = Config::load(None, None)?;
7255 assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
7256 assert_eq!(config.deepseek_api_key()?, "sk-env-key");
7257 assert_eq!(
7258 config.deepseek_base_url(),
7259 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL
7260 );
7261 Ok(())
7262 }
7263
7264 #[test]
7265 fn atlascloud_provider_uses_documented_defaults() -> Result<()> {
7266 let config = Config {
7267 provider: Some("atlascloud".to_string()),
7268 ..Default::default()
7269 };
7270
7271 config.validate()?;
7272 assert_eq!(config.api_provider(), ApiProvider::Atlascloud);
7273 assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);
7274 assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);
7275 Ok(())
7276 }
7277
7278 #[test]
7279 fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {
7280 let _lock = lock_test_env();
7281 let nanos = SystemTime::now()
7282 .duration_since(UNIX_EPOCH)
7283 .unwrap()
7284 .as_nanos();
7285 let temp_root = env::temp_dir().join(format!(
7286 "codewhale-tui-atlascloud-env-test-{}-{}",
7287 std::process::id(),
7288 nanos
7289 ));
7290 fs::create_dir_all(&temp_root)?;
7291 let _guard = EnvGuard::new(&temp_root);
7292
7293 unsafe {
7294 env::set_var("DEEPSEEK_PROVIDER", "atlascloud");
7295 env::set_var("ATLASCLOUD_API_KEY", "atlascloud-env-key");
7296 env::set_var("ATLASCLOUD_BASE_URL", "https://api.atlascloud.ai/v1");
7297 env::set_var("ATLASCLOUD_MODEL", "deepseek-ai/deepseek-v4-flash");
7298 }
7299
7300 let config = Config::load(None, None)?;
7301 assert_eq!(config.api_provider(), ApiProvider::Atlascloud);
7302 assert_eq!(config.deepseek_api_key()?, "atlascloud-env-key");
7303 assert_eq!(config.deepseek_base_url(), "https://api.atlascloud.ai/v1");
7304 assert_eq!(config.default_model(), "deepseek-ai/deepseek-v4-flash");
7305 Ok(())
7306 }
7307
7308 #[test]
7309 fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {
7310 let config = Config {
7311 provider: Some("wanjie-ark".to_string()),
7312 ..Default::default()
7313 };
7314
7315 config.validate()?;
7316 assert_eq!(config.api_provider(), ApiProvider::WanjieArk);
7317 assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);
7318 assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);
7319 Ok(())
7320 }
7321
7322 #[test]
7323 fn wanjie_ark_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
7324 let _lock = lock_test_env();
7325 let nanos = SystemTime::now()
7326 .duration_since(UNIX_EPOCH)
7327 .unwrap()
7328 .as_nanos();
7329 let temp_root = env::temp_dir().join(format!(
7330 "codewhale-tui-wanjie-env-test-{}-{}",
7331 std::process::id(),
7332 nanos
7333 ));
7334 fs::create_dir_all(&temp_root)?;
7335 let _guard = EnvGuard::new(&temp_root);
7336
7337 unsafe {
7338 env::set_var("DEEPSEEK_PROVIDER", "ark-wanjie");
7339 env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
7340 env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
7341 env::set_var("WANJIE_ARK_MODEL", "wanjie-model-id");
7342 }
7343
7344 let config = Config::load(None, None)?;
7345 assert_eq!(config.api_provider(), ApiProvider::WanjieArk);
7346 let error = config
7347 .deepseek_api_key()
7348 .expect_err("ambient key must not follow a custom endpoint");
7349 assert!(error.to_string().contains("must be bound explicitly"));
7350 assert!(!has_api_key(&config));
7351 assert_eq!(config.deepseek_base_url(), "https://wanjie.example/api/v1");
7352 assert_eq!(config.default_model(), "wanjie-model-id");
7353 Ok(())
7354 }
7355
7356 #[test]
7357 fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {
7358 let _lock = lock_test_env();
7359 let nanos = SystemTime::now()
7360 .duration_since(UNIX_EPOCH)
7361 .unwrap()
7362 .as_nanos();
7363 let temp_root = env::temp_dir().join(format!(
7364 "codewhale-tui-wanjie-table-{}-{}",
7365 std::process::id(),
7366 nanos
7367 ));
7368 fs::create_dir_all(&temp_root)?;
7369 let _guard = EnvGuard::new(&temp_root);
7370
7371 let config_path = temp_root.join(".deepseek").join("config.toml");
7372 ensure_parent_dir(&config_path)?;
7373 fs::write(
7374 &config_path,
7375 r#"provider = "wanjie-ark"
7376
7377 [providers.wanjie_ark]
7378 api_key = "wanjie-table-key"
7379 base_url = "https://maas-openapi.wanjiedata.com/api/v1"
7380 model = "account-model-id"
7381 "#,
7382 )?;
7383
7384 let config = Config::load(None, None)?;
7385 assert_eq!(config.api_provider(), ApiProvider::WanjieArk);
7386 assert_eq!(config.deepseek_api_key()?, "wanjie-table-key");
7387 assert_eq!(
7388 config.deepseek_base_url(),
7389 "https://maas-openapi.wanjiedata.com/api/v1"
7390 );
7391 assert_eq!(config.default_model(), "account-model-id");
7392 Ok(())
7393 }
7394
7395 #[test]
7396 fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {
7397 let _lock = lock_test_env();
7398 let nanos = SystemTime::now()
7399 .duration_since(UNIX_EPOCH)
7400 .unwrap()
7401 .as_nanos();
7402 let temp_root = env::temp_dir().join(format!(
7403 "codewhale-tui-openai-table-{}-{}",
7404 std::process::id(),
7405 nanos
7406 ));
7407 fs::create_dir_all(&temp_root)?;
7408 let _guard = EnvGuard::new(&temp_root);
7409
7410 let config_path = temp_root.join(".deepseek").join("config.toml");
7411 ensure_parent_dir(&config_path)?;
7412 fs::write(
7413 &config_path,
7414 r#"provider = "openai"
7415
7416 [providers.openai]
7417 api_key = "openai-table-key"
7418 base_url = "https://openai-compatible.example/api/coding/paas/v4"
7419 model = "glm-5"
7420 "#,
7421 )?;
7422
7423 let config = Config::load(None, None)?;
7424 assert_eq!(config.api_provider(), ApiProvider::Openai);
7425 assert_eq!(config.deepseek_api_key()?, "openai-table-key");
7426 assert_eq!(
7427 config.deepseek_base_url(),
7428 "https://openai-compatible.example/api/coding/paas/v4"
7429 );
7430 assert_eq!(config.default_model(), "glm-5");
7431 Ok(())
7432 }
7433
7434 #[test]
7435 fn openai_provider_accepts_dashscope_bailian_fixture() -> Result<()> {
7436 let _lock = lock_test_env();
7437 let nanos = SystemTime::now()
7438 .duration_since(UNIX_EPOCH)
7439 .unwrap()
7440 .as_nanos();
7441 let temp_root = env::temp_dir().join(format!(
7442 "codewhale-tui-dashscope-openai-{}-{}",
7443 std::process::id(),
7444 nanos
7445 ));
7446 fs::create_dir_all(&temp_root)?;
7447 let _guard = EnvGuard::new(&temp_root);
7448
7449 let config_path = temp_root.join(".deepseek").join("config.toml");
7450 ensure_parent_dir(&config_path)?;
7451 fs::write(
7452 &config_path,
7453 r#"provider = "openai"
7454
7455 [providers.openai]
7456 api_key = "dashscope-table-key"
7457 base_url = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
7458 model = "qwen-plus"
7459 "#,
7460 )?;
7461
7462 let config = Config::load(None, None)?;
7463 assert_eq!(config.api_provider(), ApiProvider::Openai);
7464 assert_eq!(config.deepseek_api_key()?, "dashscope-table-key");
7465 assert_eq!(
7466 config.deepseek_base_url(),
7467 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
7468 );
7469 assert_eq!(config.default_model(), "qwen-plus");
7470 Ok(())
7471 }
7472
7473 #[test]
7474 fn qianfan_provider_accepts_custom_model_and_base_url() -> Result<()> {
7475 let _lock = lock_test_env();
7476 let nanos = SystemTime::now()
7477 .duration_since(UNIX_EPOCH)
7478 .unwrap()
7479 .as_nanos();
7480 let temp_root = env::temp_dir().join(format!(
7481 "codewhale-tui-qianfan-provider-{}-{}",
7482 std::process::id(),
7483 nanos
7484 ));
7485 fs::create_dir_all(&temp_root)?;
7486 let _guard = EnvGuard::new(&temp_root);
7487
7488 let config_path = temp_root.join(".deepseek").join("config.toml");
7489 ensure_parent_dir(&config_path)?;
7490 fs::write(
7491 &config_path,
7492 r#"provider = "qianfan"
7493
7494 [providers.qianfan]
7495 api_key = "qianfan-table-key"
7496 base_url = "https://qianfan.baidubce.com/v2"
7497 model = "custom-qianfan-service-id"
7498 "#,
7499 )?;
7500
7501 let config = Config::load(None, None)?;
7502 assert_eq!(config.api_provider(), ApiProvider::Qianfan);
7503 assert_eq!(config.deepseek_api_key()?, "qianfan-table-key");
7504 assert_eq!(
7505 config.deepseek_base_url(),
7506 "https://qianfan.baidubce.com/v2"
7507 );
7508 assert_eq!(config.default_model(), "custom-qianfan-service-id");
7509 Ok(())
7510 }
7511
7512 #[test]
7513 fn provider_config_loads_reasoning_stream_style() -> Result<()> {
7514 let _lock = lock_test_env();
7515 let nanos = SystemTime::now()
7516 .duration_since(UNIX_EPOCH)
7517 .unwrap()
7518 .as_nanos();
7519 let temp_root = env::temp_dir().join(format!(
7520 "codewhale-tui-reasoning-style-{}-{}",
7521 std::process::id(),
7522 nanos
7523 ));
7524 fs::create_dir_all(&temp_root)?;
7525 let _guard = EnvGuard::new(&temp_root);
7526
7527 let config_path = temp_root.join(".deepseek").join("config.toml");
7528 ensure_parent_dir(&config_path)?;
7529 fs::write(
7530 &config_path,
7531 r#"provider = "openai"
7532
7533 [providers.openai]
7534 api_key = "openai-table-key"
7535 base_url = "https://openai-compatible.example/v1"
7536 model = "custom-reasoner"
7537 reasoning_stream_style = "inline_tags"
7538 "#,
7539 )?;
7540
7541 let config = Config::load(None, None)?;
7542 let openai = config
7543 .provider_config_for(ApiProvider::Openai)
7544 .expect("openai provider config");
7545 assert_eq!(
7546 openai.reasoning_stream_style.as_deref(),
7547 Some("inline_tags")
7548 );
7549 Ok(())
7550 }
7551
7552 // Regression for issue #1714: `codewhale --provider openai --model
7553 // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never
7554 // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model
7555 // must pass through verbatim instead of silently becoming a
7556 // DeepSeek/provider default.
7557 #[test]
7558 fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {
7559 let _lock = lock_test_env();
7560 let nanos = SystemTime::now()
7561 .duration_since(UNIX_EPOCH)
7562 .unwrap()
7563 .as_nanos();
7564 let temp_root = env::temp_dir().join(format!(
7565 "codewhale-tui-1714-passthrough-{}-{}",
7566 std::process::id(),
7567 nanos
7568 ));
7569 fs::create_dir_all(&temp_root)?;
7570
7571 // (a) provider=openai + model="MiniMax-M2.7" via env, NO OPENAI_MODEL,
7572 // DEFAULT base_url.
7573 {
7574 let _guard = EnvGuard::new(&temp_root);
7575 // Safety: test-only environment mutation guarded by a global mutex.
7576 unsafe {
7577 env::set_var("DEEPSEEK_PROVIDER", "openai");
7578 env::set_var("OPENAI_API_KEY", "openai-env-key");
7579 env::set_var("DEEPSEEK_MODEL", "MiniMax-M2.7");
7580 }
7581
7582 let config = Config::load(None, None)?;
7583 assert_eq!(config.api_provider(), ApiProvider::Openai);
7584 assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);
7585 assert_eq!(config.default_model(), "MiniMax-M2.7");
7586 }
7587
7588 // (b) a non-passthrough provider (novita) with an unknown custom model
7589 // and the DEFAULT base_url must also be preserved verbatim — never
7590 // rewritten to DEFAULT_NOVITA_MODEL.
7591 {
7592 let _guard = EnvGuard::new(&temp_root);
7593 // Safety: test-only environment mutation guarded by a global mutex.
7594 unsafe {
7595 env::set_var("DEEPSEEK_PROVIDER", "novita");
7596 env::set_var("NOVITA_API_KEY", "novita-env-key");
7597 env::set_var("DEEPSEEK_MODEL", "MiniMax-M2.7");
7598 }
7599
7600 let config = Config::load(None, None)?;
7601 assert_eq!(config.api_provider(), ApiProvider::Novita);
7602 assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);
7603 assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);
7604 assert_eq!(config.default_model(), "MiniMax-M2.7");
7605 }
7606
7607 Ok(())
7608 }
7609
7610 #[test]
7611 fn openai_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
7612 let _lock = lock_test_env();
7613 let nanos = SystemTime::now()
7614 .duration_since(UNIX_EPOCH)
7615 .unwrap()
7616 .as_nanos();
7617 let temp_root = env::temp_dir().join(format!(
7618 "codewhale-tui-openai-env-test-{}-{}",
7619 std::process::id(),
7620 nanos
7621 ));
7622 fs::create_dir_all(&temp_root)?;
7623 let _guard = EnvGuard::new(&temp_root);
7624
7625 // Safety: test-only environment mutation guarded by a global mutex.
7626 unsafe {
7627 env::set_var("DEEPSEEK_PROVIDER", "openai");
7628 env::set_var("OPENAI_API_KEY", "openai-env-key");
7629 env::set_var("OPENAI_BASE_URL", "https://openai-compatible.example/v4");
7630 env::set_var("OPENAI_MODEL", "glm-5");
7631 }
7632
7633 let config = Config::load(None, None)?;
7634 assert_eq!(config.api_provider(), ApiProvider::Openai);
7635 let error = config
7636 .deepseek_api_key()
7637 .expect_err("ambient key must not follow a custom endpoint");
7638 assert!(error.to_string().contains("must be bound explicitly"));
7639 assert!(!has_api_key(&config));
7640 assert_eq!(
7641 config.deepseek_base_url(),
7642 "https://openai-compatible.example/v4"
7643 );
7644 assert_eq!(config.default_model(), "glm-5");
7645 Ok(())
7646 }
7647
7648 #[test]
7649 fn openai_facade_custom_url_does_not_inherit_ambient_key() -> Result<()> {
7650 let _lock = lock_test_env();
7651 let nanos = SystemTime::now()
7652 .duration_since(UNIX_EPOCH)
7653 .unwrap()
7654 .as_nanos();
7655 let temp_root = env::temp_dir().join(format!(
7656 "codewhale-tui-openai-forwarded-base-url-test-{}-{}",
7657 std::process::id(),
7658 nanos
7659 ));
7660 fs::create_dir_all(&temp_root)?;
7661 let _guard = EnvGuard::new(&temp_root);
7662
7663 // Safety: test-only environment mutation guarded by a global mutex.
7664 unsafe {
7665 env::set_var("DEEPSEEK_PROVIDER", "openai");
7666 env::set_var("OPENAI_API_KEY", "forwarded-openai-key");
7667 env::set_var("DEEPSEEK_BASE_URL", "https://forwarded-openai.example/v4");
7668 env::set_var("DEEPSEEK_MODEL", "glm-5");
7669 }
7670
7671 let config = Config::load(None, None)?;
7672 assert_eq!(config.api_provider(), ApiProvider::Openai);
7673 let error = config
7674 .deepseek_api_key()
7675 .expect_err("ambient key must not follow a custom endpoint");
7676 assert!(error.to_string().contains("must be bound explicitly"));
7677 assert!(!has_api_key(&config));
7678 assert_eq!(
7679 config.deepseek_base_url(),
7680 "https://forwarded-openai.example/v4"
7681 );
7682 assert_eq!(config.default_model(), "glm-5");
7683 Ok(())
7684 }
7685
7686 #[test]
7687 fn openrouter_provider_uses_canonical_defaults() -> Result<()> {
7688 let _lock = lock_test_env();
7689 let nanos = SystemTime::now()
7690 .duration_since(UNIX_EPOCH)
7691 .unwrap()
7692 .as_nanos();
7693 let temp_root = env::temp_dir().join(format!(
7694 "codewhale-tui-or-defaults-{}-{}",
7695 std::process::id(),
7696 nanos
7697 ));
7698 fs::create_dir_all(&temp_root)?;
7699 let _guard = EnvGuard::new(&temp_root);
7700
7701 let config = Config {
7702 provider: Some("openrouter".to_string()),
7703 ..Default::default()
7704 };
7705 config.validate()?;
7706 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
7707 assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);
7708 assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);
7709 Ok(())
7710 }
7711
7712 #[test]
7713 fn novita_provider_uses_canonical_defaults() -> Result<()> {
7714 let _lock = lock_test_env();
7715 let nanos = SystemTime::now()
7716 .duration_since(UNIX_EPOCH)
7717 .unwrap()
7718 .as_nanos();
7719 let temp_root = env::temp_dir().join(format!(
7720 "codewhale-tui-novita-defaults-{}-{}",
7721 std::process::id(),
7722 nanos
7723 ));
7724 fs::create_dir_all(&temp_root)?;
7725 let _guard = EnvGuard::new(&temp_root);
7726
7727 let config = Config {
7728 provider: Some("novita".to_string()),
7729 ..Default::default()
7730 };
7731 config.validate()?;
7732 assert_eq!(config.api_provider(), ApiProvider::Novita);
7733 assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);
7734 assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);
7735 Ok(())
7736 }
7737
7738 #[test]
7739 fn fireworks_provider_uses_canonical_defaults() -> Result<()> {
7740 let _lock = lock_test_env();
7741 let nanos = SystemTime::now()
7742 .duration_since(UNIX_EPOCH)
7743 .unwrap()
7744 .as_nanos();
7745 let temp_root = env::temp_dir().join(format!(
7746 "codewhale-tui-fireworks-defaults-{}-{}",
7747 std::process::id(),
7748 nanos
7749 ));
7750 fs::create_dir_all(&temp_root)?;
7751 let _guard = EnvGuard::new(&temp_root);
7752
7753 let config = Config {
7754 provider: Some("fireworks".to_string()),
7755 ..Default::default()
7756 };
7757 config.validate()?;
7758 assert_eq!(config.api_provider(), ApiProvider::Fireworks);
7759 assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);
7760 assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);
7761 Ok(())
7762 }
7763
7764 #[test]
7765 fn fireworks_flash_alias_is_not_mapped_to_undocumented_model() -> Result<()> {
7766 let config = Config {
7767 provider: Some("fireworks".to_string()),
7768 default_text_model: Some("deepseek-v4-flash".to_string()),
7769 ..Default::default()
7770 };
7771
7772 config.validate()?;
7773 assert_eq!(config.api_provider(), ApiProvider::Fireworks);
7774 assert_eq!(config.default_model(), "deepseek-v4-flash");
7775 Ok(())
7776 }
7777
7778 #[test]
7779 fn volcengine_provider_requires_api_key() -> Result<()> {
7780 let _lock = lock_test_env();
7781 let nanos = SystemTime::now()
7782 .duration_since(UNIX_EPOCH)
7783 .unwrap()
7784 .as_nanos();
7785 let temp_root = env::temp_dir().join(format!(
7786 "codewhale-tui-volcengine-auth-test-{}-{}",
7787 std::process::id(),
7788 nanos
7789 ));
7790 fs::create_dir_all(&temp_root)?;
7791 let _guard = EnvGuard::new(&temp_root);
7792
7793 let config = Config {
7794 provider: Some("volcengine".to_string()),
7795 ..Default::default()
7796 };
7797
7798 config.validate()?;
7799 let err = config.deepseek_api_key().expect_err("missing key");
7800 assert!(err.to_string().contains("Volcengine Ark API key not found"));
7801 Ok(())
7802 }
7803
7804 #[test]
7805 fn volcengine_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
7806 let _lock = lock_test_env();
7807 let nanos = SystemTime::now()
7808 .duration_since(UNIX_EPOCH)
7809 .unwrap()
7810 .as_nanos();
7811 let temp_root = env::temp_dir().join(format!(
7812 "codewhale-tui-volcengine-env-test-{}-{}",
7813 std::process::id(),
7814 nanos
7815 ));
7816 fs::create_dir_all(&temp_root)?;
7817 let _guard = EnvGuard::new(&temp_root);
7818
7819 // Safety: test-only environment mutation guarded by a global mutex.
7820 unsafe {
7821 env::set_var("DEEPSEEK_PROVIDER", "volcengine");
7822 env::set_var("ARK_API_KEY", "volc-env-key");
7823 env::set_var("VOLCENGINE_ARK_BASE_URL", "https://volc.example/v1");
7824 env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash");
7825 }
7826
7827 let config = Config::load(None, None)?;
7828 assert_eq!(config.api_provider(), ApiProvider::Volcengine);
7829 let error = config
7830 .deepseek_api_key()
7831 .expect_err("ambient key must not follow a custom endpoint");
7832 assert!(error.to_string().contains("must be bound explicitly"));
7833 assert!(!has_api_key(&config));
7834 assert_eq!(config.deepseek_base_url(), "https://volc.example/v1");
7835 assert_eq!(config.default_model(), "DeepSeek-V4-Flash");
7836 Ok(())
7837 }
7838
7839 #[test]
7840 fn siliconflow_provider_uses_canonical_defaults() -> Result<()> {
7841 let _lock = lock_test_env();
7842 let nanos = SystemTime::now()
7843 .duration_since(UNIX_EPOCH)
7844 .unwrap()
7845 .as_nanos();
7846 let temp_root = env::temp_dir().join(format!(
7847 "codewhale-tui-siliconflow-defaults-{}-{}",
7848 std::process::id(),
7849 nanos
7850 ));
7851 fs::create_dir_all(&temp_root)?;
7852 let _guard = EnvGuard::new(&temp_root);
7853
7854 let config = Config {
7855 provider: Some("siliconflow".to_string()),
7856 ..Default::default()
7857 };
7858 config.validate()?;
7859 assert_eq!(config.api_provider(), ApiProvider::Siliconflow);
7860 assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL);
7861 assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_BASE_URL);
7862 assert_eq!(
7863 model_completion_names_for_provider(ApiProvider::Siliconflow),
7864 vec![DEFAULT_SILICONFLOW_MODEL, DEFAULT_SILICONFLOW_FLASH_MODEL]
7865 );
7866 Ok(())
7867 }
7868
7869 #[test]
7870 fn sglang_provider_works_without_api_key() -> Result<()> {
7871 let _lock = lock_test_env();
7872 let nanos = SystemTime::now()
7873 .duration_since(UNIX_EPOCH)
7874 .unwrap()
7875 .as_nanos();
7876 let temp_root = env::temp_dir().join(format!(
7877 "codewhale-tui-sglang-defaults-{}-{}",
7878 std::process::id(),
7879 nanos
7880 ));
7881 fs::create_dir_all(&temp_root)?;
7882 let _guard = EnvGuard::new(&temp_root);
7883
7884 let config = Config {
7885 provider: Some("sglang".to_string()),
7886 ..Default::default()
7887 };
7888 config.validate()?;
7889 assert_eq!(config.api_provider(), ApiProvider::Sglang);
7890 assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);
7891 assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);
7892 assert_eq!(config.deepseek_api_key()?, "");
7893 assert!(has_api_key_for(&config, ApiProvider::Sglang));
7894 Ok(())
7895 }
7896
7897 #[test]
7898 fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {
7899 let _lock = lock_test_env();
7900 let nanos = SystemTime::now()
7901 .duration_since(UNIX_EPOCH)
7902 .unwrap()
7903 .as_nanos();
7904 let temp_root = env::temp_dir().join(format!(
7905 "codewhale-tui-ollama-defaults-{}-{}",
7906 std::process::id(),
7907 nanos
7908 ));
7909 fs::create_dir_all(&temp_root)?;
7910 let _guard = EnvGuard::new(&temp_root);
7911
7912 let config = Config {
7913 provider: Some("ollama".to_string()),
7914 ..Default::default()
7915 };
7916 config.validate()?;
7917 assert_eq!(config.api_provider(), ApiProvider::Ollama);
7918 assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);
7919 assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);
7920 assert_eq!(config.deepseek_api_key()?, "");
7921 assert!(has_api_key_for(&config, ApiProvider::Ollama));
7922 Ok(())
7923 }
7924
7925 #[test]
7926 fn ollama_model_is_passed_through_verbatim() -> Result<()> {
7927 let _lock = lock_test_env();
7928 let nanos = SystemTime::now()
7929 .duration_since(UNIX_EPOCH)
7930 .unwrap()
7931 .as_nanos();
7932 let temp_root = env::temp_dir().join(format!(
7933 "codewhale-tui-ollama-model-test-{}-{}",
7934 std::process::id(),
7935 nanos
7936 ));
7937 fs::create_dir_all(&temp_root)?;
7938 let _guard = EnvGuard::new(&temp_root);
7939
7940 let config_path = temp_root.join(".deepseek").join("config.toml");
7941 ensure_parent_dir(&config_path)?;
7942 fs::write(
7943 &config_path,
7944 r#"provider = "ollama"
7945
7946 [providers.ollama]
7947 base_url = "http://127.0.0.1:11434/v1"
7948 model = "qwen2.5-coder:7b"
7949 "#,
7950 )?;
7951
7952 let config = Config::load(None, None)?;
7953 assert_eq!(config.api_provider(), ApiProvider::Ollama);
7954 assert_eq!(config.default_model(), "qwen2.5-coder:7b");
7955 assert_eq!(config.deepseek_base_url(), "http://127.0.0.1:11434/v1");
7956 Ok(())
7957 }
7958
7959 #[test]
7960 fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {
7961 let _lock = lock_test_env();
7962 let nanos = SystemTime::now()
7963 .duration_since(UNIX_EPOCH)
7964 .unwrap()
7965 .as_nanos();
7966 let temp_root = env::temp_dir().join(format!(
7967 "codewhale-tui-self-hosted-base-url-test-{}-{}",
7968 std::process::id(),
7969 nanos
7970 ));
7971 fs::create_dir_all(&temp_root)?;
7972 let _guard = EnvGuard::new(&temp_root);
7973
7974 // Safety: test-only environment mutation guarded by a global mutex.
7975 unsafe {
7976 env::set_var("DEEPSEEK_PROVIDER", "ollama");
7977 env::set_var("DEEPSEEK_BASE_URL", "http://ollama.remote:11434/v1");
7978 }
7979 let config = Config::load(None, None)?;
7980 assert_eq!(config.api_provider(), ApiProvider::Ollama);
7981 assert_eq!(config.deepseek_base_url(), "http://ollama.remote:11434/v1");
7982
7983 // Safety: test-only environment mutation guarded by a global mutex.
7984 unsafe {
7985 env::set_var("DEEPSEEK_PROVIDER", "vllm");
7986 env::set_var("DEEPSEEK_BASE_URL", "http://vllm.remote:8000/v1");
7987 }
7988 let config = Config::load(None, None)?;
7989 assert_eq!(config.api_provider(), ApiProvider::Vllm);
7990 assert_eq!(config.deepseek_base_url(), "http://vllm.remote:8000/v1");
7991 Ok(())
7992 }
7993
7994 #[test]
7995 fn vllm_env_resolves_reported_lan_http_endpoint_and_model() -> Result<()> {
7996 let _lock = lock_test_env();
7997 let nanos = SystemTime::now()
7998 .duration_since(UNIX_EPOCH)
7999 .unwrap()
8000 .as_nanos();
8001 let temp_root = env::temp_dir().join(format!(
8002 "codewhale-tui-vllm-lan-http-test-{}-{}",
8003 std::process::id(),
8004 nanos
8005 ));
8006 fs::create_dir_all(&temp_root)?;
8007 let _guard = EnvGuard::new(&temp_root);
8008
8009 // Safety: test-only environment mutation guarded by a global mutex.
8010 unsafe {
8011 env::set_var("DEEPSEEK_PROVIDER", "vllm");
8012 env::set_var("VLLM_BASE_URL", "http://192.168.0.110:8000/v1");
8013 env::set_var("DEEPSEEK_MODEL", "deepseek-v4-flash");
8014 }
8015
8016 let config = Config::load(None, None)?;
8017 assert_eq!(config.api_provider(), ApiProvider::Vllm);
8018 assert_eq!(config.deepseek_base_url(), "http://192.168.0.110:8000/v1");
8019 assert_eq!(config.default_model(), "deepseek-v4-flash");
8020 Ok(())
8021 }
8022
8023 #[test]
8024 fn ollama_env_overrides_base_url_and_model() -> Result<()> {
8025 let _lock = lock_test_env();
8026 let nanos = SystemTime::now()
8027 .duration_since(UNIX_EPOCH)
8028 .unwrap()
8029 .as_nanos();
8030 let temp_root = env::temp_dir().join(format!(
8031 "codewhale-tui-ollama-env-test-{}-{}",
8032 std::process::id(),
8033 nanos
8034 ));
8035 fs::create_dir_all(&temp_root)?;
8036 let _guard = EnvGuard::new(&temp_root);
8037
8038 // Safety: test-only environment mutation guarded by a global mutex.
8039 unsafe {
8040 env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
8041 env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
8042 env::set_var("OLLAMA_MODEL", "deepseek-coder-v2:16b");
8043 }
8044
8045 let config = Config::load(None, None)?;
8046 assert_eq!(config.api_provider(), ApiProvider::Ollama);
8047 assert_eq!(config.deepseek_base_url(), "http://ollama.example/v1");
8048 assert_eq!(config.default_model(), "deepseek-coder-v2:16b");
8049 Ok(())
8050 }
8051
8052 #[test]
8053 fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {
8054 let _lock = lock_test_env();
8055 let nanos = SystemTime::now()
8056 .duration_since(UNIX_EPOCH)
8057 .unwrap()
8058 .as_nanos();
8059 let temp_root = env::temp_dir().join(format!(
8060 "codewhale-tui-or-env-key-{}-{}",
8061 std::process::id(),
8062 nanos
8063 ));
8064 fs::create_dir_all(&temp_root)?;
8065 let _guard = EnvGuard::new(&temp_root);
8066
8067 // Safety: test-only environment mutation guarded by a global mutex.
8068 unsafe {
8069 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
8070 env::set_var("OPENROUTER_API_KEY", "or-env-key");
8071 env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash");
8072 }
8073
8074 let config = Config::load(None, None)?;
8075 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
8076 assert_eq!(config.deepseek_api_key()?, "or-env-key");
8077 assert_eq!(config.default_model(), DEFAULT_OPENROUTER_FLASH_MODEL);
8078 Ok(())
8079 }
8080
8081 #[test]
8082 fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {
8083 let _lock = lock_test_env();
8084 let nanos = SystemTime::now()
8085 .duration_since(UNIX_EPOCH)
8086 .unwrap()
8087 .as_nanos();
8088 let temp_root = env::temp_dir().join(format!(
8089 "codewhale-tui-novita-env-key-{}-{}",
8090 std::process::id(),
8091 nanos
8092 ));
8093 fs::create_dir_all(&temp_root)?;
8094 let _guard = EnvGuard::new(&temp_root);
8095
8096 // Safety: test-only environment mutation guarded by a global mutex.
8097 unsafe {
8098 env::set_var("DEEPSEEK_PROVIDER", "novita");
8099 env::set_var("NOVITA_API_KEY", "novita-env-key");
8100 env::set_var("NOVITA_MODEL", "deepseek-v4-flash");
8101 }
8102
8103 let config = Config::load(None, None)?;
8104 assert_eq!(config.api_provider(), ApiProvider::Novita);
8105 assert_eq!(config.deepseek_api_key()?, "novita-env-key");
8106 assert_eq!(config.default_model(), DEFAULT_NOVITA_FLASH_MODEL);
8107 Ok(())
8108 }
8109
8110 #[test]
8111 fn fireworks_env_overrides_key_and_model() -> Result<()> {
8112 let _lock = lock_test_env();
8113 let nanos = SystemTime::now()
8114 .duration_since(UNIX_EPOCH)
8115 .unwrap()
8116 .as_nanos();
8117 let temp_root = env::temp_dir().join(format!(
8118 "codewhale-tui-fireworks-env-key-{}-{}",
8119 std::process::id(),
8120 nanos
8121 ));
8122 fs::create_dir_all(&temp_root)?;
8123 let _guard = EnvGuard::new(&temp_root);
8124
8125 // Safety: test-only environment mutation guarded by a global mutex.
8126 unsafe {
8127 env::set_var("DEEPSEEK_PROVIDER", "fireworks");
8128 env::set_var("FIREWORKS_API_KEY", "fw-env-key");
8129 env::set_var(
8130 "FIREWORKS_MODEL",
8131 "accounts/fireworks/models/account-specific-model",
8132 );
8133 }
8134
8135 let config = Config::load(None, None)?;
8136 assert_eq!(config.api_provider(), ApiProvider::Fireworks);
8137 assert_eq!(config.deepseek_api_key()?, "fw-env-key");
8138 assert_eq!(
8139 config.default_model(),
8140 "accounts/fireworks/models/account-specific-model"
8141 );
8142 Ok(())
8143 }
8144
8145 #[test]
8146 fn siliconflow_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
8147 let _lock = lock_test_env();
8148 let nanos = SystemTime::now()
8149 .duration_since(UNIX_EPOCH)
8150 .unwrap()
8151 .as_nanos();
8152 let temp_root = env::temp_dir().join(format!(
8153 "codewhale-tui-siliconflow-env-test-{}-{}",
8154 std::process::id(),
8155 nanos
8156 ));
8157 fs::create_dir_all(&temp_root)?;
8158 let _guard = EnvGuard::new(&temp_root);
8159
8160 // Safety: test-only environment mutation guarded by a global mutex.
8161 unsafe {
8162 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
8163 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
8164 env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
8165 env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
8166 }
8167
8168 let config = Config::load(None, None)?;
8169 assert_eq!(config.api_provider(), ApiProvider::Siliconflow);
8170 let error = config
8171 .deepseek_api_key()
8172 .expect_err("ambient key must not follow a custom endpoint");
8173 assert!(error.to_string().contains("must be bound explicitly"));
8174 assert!(!has_api_key(&config));
8175 assert_eq!(config.deepseek_base_url(), "https://sf-mirror.example/v1");
8176 assert_eq!(config.default_model(), "deepseek-v4-flash");
8177 Ok(())
8178 }
8179
8180 #[test]
8181 fn arcee_provider_uses_direct_defaults() -> Result<()> {
8182 let _lock = lock_test_env();
8183 let nanos = SystemTime::now()
8184 .duration_since(UNIX_EPOCH)
8185 .unwrap()
8186 .as_nanos();
8187 let temp_root = env::temp_dir().join(format!(
8188 "codewhale-tui-arcee-defaults-test-{}-{}",
8189 std::process::id(),
8190 nanos
8191 ));
8192 fs::create_dir_all(&temp_root)?;
8193 let _guard = EnvGuard::new(&temp_root);
8194
8195 unsafe {
8196 env::set_var("CODEWHALE_PROVIDER", "arcee");
8197 env::set_var("ARCEE_API_KEY", "arcee-env-key");
8198 }
8199
8200 let config = Config::load(None, None)?;
8201 assert_eq!(config.api_provider(), ApiProvider::Arcee);
8202 assert_eq!(config.deepseek_api_key()?, "arcee-env-key");
8203 assert_eq!(config.deepseek_base_url(), DEFAULT_ARCEE_BASE_URL);
8204 assert_eq!(config.default_model(), DEFAULT_ARCEE_MODEL);
8205 Ok(())
8206 }
8207
8208 #[test]
8209 fn arcee_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
8210 let _lock = lock_test_env();
8211 let nanos = SystemTime::now()
8212 .duration_since(UNIX_EPOCH)
8213 .unwrap()
8214 .as_nanos();
8215 let temp_root = env::temp_dir().join(format!(
8216 "codewhale-tui-arcee-env-test-{}-{}",
8217 std::process::id(),
8218 nanos
8219 ));
8220 fs::create_dir_all(&temp_root)?;
8221 let _guard = EnvGuard::new(&temp_root);
8222
8223 unsafe {
8224 env::set_var("CODEWHALE_PROVIDER", "arcee");
8225 env::set_var("ARCEE_API_KEY", "arcee-env-key");
8226 env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1");
8227 env::set_var("ARCEE_MODEL", "arcee-trinity-large-preview");
8228 }
8229
8230 let config = Config::load(None, None)?;
8231 assert_eq!(config.api_provider(), ApiProvider::Arcee);
8232 let error = config
8233 .deepseek_api_key()
8234 .expect_err("ambient key must not follow a custom endpoint");
8235 assert!(error.to_string().contains("must be bound explicitly"));
8236 assert!(!has_api_key(&config));
8237 assert_eq!(
8238 config.deepseek_base_url(),
8239 "https://arcee-mirror.example/api/v1"
8240 );
8241 assert_eq!(config.default_model(), "arcee-trinity-large-preview");
8242 Ok(())
8243 }
8244
8245 #[test]
8246 fn arcee_provider_table_configures_direct_route() -> Result<()> {
8247 let _lock = lock_test_env();
8248 let nanos = SystemTime::now()
8249 .duration_since(UNIX_EPOCH)
8250 .unwrap()
8251 .as_nanos();
8252 let temp_root = env::temp_dir().join(format!(
8253 "codewhale-tui-arcee-table-test-{}-{}",
8254 std::process::id(),
8255 nanos
8256 ));
8257 let config_dir = temp_root.join(".deepseek");
8258 fs::create_dir_all(&config_dir)?;
8259 let _guard = EnvGuard::new(&temp_root);
8260 fs::write(
8261 config_dir.join("config.toml"),
8262 r#"
8263 provider = "arcee"
8264
8265 [providers.arcee]
8266 api_key = "arcee-file-key"
8267 base_url = "https://api.arcee.ai/api/v1"
8268 model = "arcee-trinity-large-preview"
8269 "#,
8270 )?;
8271
8272 let config = Config::load(None, None)?;
8273 assert_eq!(config.api_provider(), ApiProvider::Arcee);
8274 assert_eq!(config.deepseek_api_key()?, "arcee-file-key");
8275 assert_eq!(config.deepseek_base_url(), DEFAULT_ARCEE_BASE_URL);
8276 assert_eq!(config.default_model(), ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
8277 Ok(())
8278 }
8279
8280 #[test]
8281 fn siliconflow_cn_base_url_env_normalizes_model_aliases() -> Result<()> {
8282 let _lock = lock_test_env();
8283 let nanos = SystemTime::now()
8284 .duration_since(UNIX_EPOCH)
8285 .unwrap()
8286 .as_nanos();
8287 let temp_root = env::temp_dir().join(format!(
8288 "codewhale-tui-siliconflow-cn-env-test-{}-{}",
8289 std::process::id(),
8290 nanos
8291 ));
8292 fs::create_dir_all(&temp_root)?;
8293 let _guard = EnvGuard::new(&temp_root);
8294
8295 // Safety: test-only environment mutation guarded by a global mutex.
8296 unsafe {
8297 env::set_var("CODEWHALE_PROVIDER", "siliconflow-CN");
8298 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
8299 env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
8300 env::set_var("SILICONFLOW_MODEL", "deepseek-reasoner");
8301 }
8302
8303 let config = Config::load(None, None)?;
8304 assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn);
8305 assert_eq!(config.deepseek_api_key()?, "sf-env-key");
8306 assert_eq!(config.deepseek_base_url(), "https://api.siliconflow.cn/v1");
8307 assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL);
8308 Ok(())
8309 }
8310
8311 #[test]
8312 fn openrouter_base_url_env_overrides_default() -> Result<()> {
8313 let _lock = lock_test_env();
8314 let nanos = SystemTime::now()
8315 .duration_since(UNIX_EPOCH)
8316 .unwrap()
8317 .as_nanos();
8318 let temp_root = env::temp_dir().join(format!(
8319 "codewhale-tui-or-base-url-{}-{}",
8320 std::process::id(),
8321 nanos
8322 ));
8323 fs::create_dir_all(&temp_root)?;
8324 let _guard = EnvGuard::new(&temp_root);
8325
8326 // Safety: test-only environment mutation guarded by a global mutex.
8327 unsafe {
8328 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
8329 env::set_var("OPENROUTER_BASE_URL", "https://or-mirror.example/v1");
8330 }
8331
8332 let config = Config::load(None, None)?;
8333 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
8334 assert_eq!(config.deepseek_base_url(), "https://or-mirror.example/v1");
8335 Ok(())
8336 }
8337
8338 #[test]
8339 fn openrouter_reads_provider_table_from_config_file() -> Result<()> {
8340 let _lock = lock_test_env();
8341 let nanos = SystemTime::now()
8342 .duration_since(UNIX_EPOCH)
8343 .unwrap()
8344 .as_nanos();
8345 let temp_root = env::temp_dir().join(format!(
8346 "codewhale-tui-or-table-{}-{}",
8347 std::process::id(),
8348 nanos
8349 ));
8350 fs::create_dir_all(&temp_root)?;
8351 let _guard = EnvGuard::new(&temp_root);
8352
8353 let config_path = temp_root.join(".deepseek").join("config.toml");
8354 ensure_parent_dir(&config_path)?;
8355 fs::write(
8356 &config_path,
8357 r#"provider = "openrouter"
8358
8359 [providers.openrouter]
8360 api_key = "or-table-key"
8361 base_url = "https://or-table.example/v1"
8362 "#,
8363 )?;
8364
8365 let config = Config::load(None, None)?;
8366 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
8367 assert_eq!(config.deepseek_api_key()?, "or-table-key");
8368 assert_eq!(config.deepseek_base_url(), "https://or-table.example/v1");
8369 Ok(())
8370 }
8371
8372 #[test]
8373 fn siliconflow_reads_provider_table_from_config_file() -> Result<()> {
8374 let _lock = lock_test_env();
8375 let nanos = SystemTime::now()
8376 .duration_since(UNIX_EPOCH)
8377 .unwrap()
8378 .as_nanos();
8379 let temp_root = env::temp_dir().join(format!(
8380 "codewhale-tui-siliconflow-table-{}-{}",
8381 std::process::id(),
8382 nanos
8383 ));
8384 fs::create_dir_all(&temp_root)?;
8385 let _guard = EnvGuard::new(&temp_root);
8386
8387 let config_path = temp_root.join(".deepseek").join("config.toml");
8388 ensure_parent_dir(&config_path)?;
8389 fs::write(
8390 &config_path,
8391 r#"provider = "siliconflow"
8392
8393 [providers.siliconflow]
8394 api_key = "sf-table-key"
8395 model = "deepseek-v4-flash"
8396 "#,
8397 )?;
8398
8399 let config = Config::load(None, None)?;
8400 assert_eq!(config.api_provider(), ApiProvider::Siliconflow);
8401 assert_eq!(config.deepseek_api_key()?, "sf-table-key");
8402 assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_BASE_URL);
8403 assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_FLASH_MODEL);
8404 Ok(())
8405 }
8406
8407 #[test]
8408 fn siliconflow_cn_reads_hyphenated_provider_table_from_config_file() -> Result<()> {
8409 let _lock = lock_test_env();
8410 let nanos = SystemTime::now()
8411 .duration_since(UNIX_EPOCH)
8412 .unwrap()
8413 .as_nanos();
8414 let temp_root = env::temp_dir().join(format!(
8415 "codewhale-tui-siliconflow-cn-table-{}-{}",
8416 std::process::id(),
8417 nanos
8418 ));
8419 fs::create_dir_all(&temp_root)?;
8420 let _guard = EnvGuard::new(&temp_root);
8421
8422 let config_path = temp_root.join(".deepseek").join("config.toml");
8423 ensure_parent_dir(&config_path)?;
8424 fs::write(
8425 &config_path,
8426 r#"provider = "siliconflow-CN"
8427
8428 [providers.siliconflow-CN]
8429 api_key = "sf-cn-table-key"
8430 base_url = "https://api.siliconflow.cn/v1"
8431 model = "deepseek-reasoner"
8432 "#,
8433 )?;
8434
8435 let config = Config::load(None, None)?;
8436 assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn);
8437 assert_eq!(config.deepseek_api_key()?, "sf-cn-table-key");
8438 assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_CN_BASE_URL);
8439 assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL);
8440 assert!(has_api_key_for(&config, ApiProvider::SiliconflowCn));
8441 Ok(())
8442 }
8443
8444 #[test]
8445 fn siliconflow_cn_preserves_reported_deepseek_prefixed_v4_route() -> Result<()> {
8446 let _lock = lock_test_env();
8447 let nanos = SystemTime::now()
8448 .duration_since(UNIX_EPOCH)
8449 .unwrap()
8450 .as_nanos();
8451 let temp_root = env::temp_dir().join(format!(
8452 "codewhale-tui-siliconflow-cn-v4-report-{}-{}",
8453 std::process::id(),
8454 nanos
8455 ));
8456 fs::create_dir_all(&temp_root)?;
8457 let _guard = EnvGuard::new(&temp_root);
8458
8459 let config_path = temp_root.join(".deepseek").join("config.toml");
8460 ensure_parent_dir(&config_path)?;
8461 fs::write(
8462 &config_path,
8463 r#"provider = "siliconflow-CN"
8464
8465 [providers.siliconflow-CN]
8466 api_key = "sf-cn-table-key"
8467 base_url = "https://api.siliconflow.cn/v1"
8468 model = "deepseek-ai/DeepSeek-V4-Pro"
8469 "#,
8470 )?;
8471
8472 let config = Config::load(None, None)?;
8473 assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn);
8474 assert_ne!(config.api_provider(), ApiProvider::Deepseek);
8475 assert_eq!(config.deepseek_api_key()?, "sf-cn-table-key");
8476 assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_CN_BASE_URL);
8477 assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL);
8478 assert_eq!(
8479 wire_model_for_provider(config.api_provider(), &config.default_model()),
8480 DEFAULT_SILICONFLOW_MODEL
8481 );
8482 Ok(())
8483 }
8484
8485 #[test]
8486 fn siliconflow_cn_falls_back_to_shared_siliconflow_table_when_unset() -> Result<()> {
8487 let _lock = lock_test_env();
8488 let nanos = SystemTime::now()
8489 .duration_since(UNIX_EPOCH)
8490 .unwrap()
8491 .as_nanos();
8492 let temp_root = env::temp_dir().join(format!(
8493 "codewhale-tui-siliconflow-cn-fallback-{}-{}",
8494 std::process::id(),
8495 nanos
8496 ));
8497 fs::create_dir_all(&temp_root)?;
8498 let _guard = EnvGuard::new(&temp_root);
8499
8500 let config_path = temp_root.join(".deepseek").join("config.toml");
8501 ensure_parent_dir(&config_path)?;
8502 fs::write(
8503 &config_path,
8504 r#"provider = "siliconflow-CN"
8505
8506 [providers.siliconflow]
8507 api_key = "sf-shared-key"
8508 base_url = "https://api.siliconflow.com/v1"
8509 model = "deepseek-chat"
8510
8511 [providers.siliconflow_cn]
8512 base_url = "https://api.siliconflow.cn/v1"
8513 "#,
8514 )?;
8515
8516 let config = Config::load(None, None)?;
8517 assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn);
8518 assert_eq!(config.deepseek_api_key()?, "sf-shared-key");
8519 assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_CN_BASE_URL);
8520 assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_FLASH_MODEL);
8521 assert!(active_provider_has_config_api_key(&config));
8522 Ok(())
8523 }
8524
8525 #[test]
8526 fn siliconflow_cn_env_overrides_write_cn_table_only() -> Result<()> {
8527 let _lock = lock_test_env();
8528 let nanos = SystemTime::now()
8529 .duration_since(UNIX_EPOCH)
8530 .unwrap()
8531 .as_nanos();
8532 let temp_root = env::temp_dir().join(format!(
8533 "codewhale-tui-siliconflow-cn-env-table-{}-{}",
8534 std::process::id(),
8535 nanos
8536 ));
8537 fs::create_dir_all(&temp_root)?;
8538 let _guard = EnvGuard::new(&temp_root);
8539
8540 let config_path = temp_root.join(".deepseek").join("config.toml");
8541 ensure_parent_dir(&config_path)?;
8542 fs::write(
8543 &config_path,
8544 r#"provider = "siliconflow-CN"
8545
8546 [providers.siliconflow]
8547 api_key = "sf-shared-key"
8548 base_url = "https://api.siliconflow.com/v1"
8549 model = "deepseek-reasoner"
8550 "#,
8551 )?;
8552 unsafe {
8553 env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
8554 env::set_var("SILICONFLOW_MODEL", "deepseek-chat");
8555 }
8556
8557 let config = Config::load(None, None)?;
8558 let providers = config.providers.as_ref().expect("providers");
8559 assert_eq!(
8560 providers.siliconflow.base_url.as_deref(),
8561 Some(DEFAULT_SILICONFLOW_BASE_URL)
8562 );
8563 assert_eq!(
8564 providers.siliconflow.model.as_deref(),
8565 Some(DEFAULT_SILICONFLOW_MODEL)
8566 );
8567 assert_eq!(
8568 providers.siliconflow_cn.base_url.as_deref(),
8569 Some(DEFAULT_SILICONFLOW_CN_BASE_URL)
8570 );
8571 assert_eq!(
8572 providers.siliconflow_cn.model.as_deref(),
8573 Some(DEFAULT_SILICONFLOW_FLASH_MODEL)
8574 );
8575 assert_eq!(config.deepseek_api_key()?, "sf-shared-key");
8576 assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_FLASH_MODEL);
8577 Ok(())
8578 }
8579
8580 #[test]
8581 fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {
8582 let _lock = lock_test_env();
8583 let nanos = SystemTime::now()
8584 .duration_since(UNIX_EPOCH)
8585 .unwrap()
8586 .as_nanos();
8587 let temp_root = env::temp_dir().join(format!(
8588 "codewhale-tui-or-custom-model-{}-{}",
8589 std::process::id(),
8590 nanos
8591 ));
8592 fs::create_dir_all(&temp_root)?;
8593 let _guard = EnvGuard::new(&temp_root);
8594
8595 let config_path = temp_root.join(".deepseek").join("config.toml");
8596 ensure_parent_dir(&config_path)?;
8597 fs::write(
8598 &config_path,
8599 r#"provider = "openrouter"
8600
8601 [providers.openrouter]
8602 api_key = "or-table-key"
8603 base_url = "https://gateway.example.com/v1"
8604 model = "DeepSeek-V4-Pro"
8605 "#,
8606 )?;
8607
8608 let config = Config::load(None, None)?;
8609 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
8610 assert_eq!(config.deepseek_api_key()?, "or-table-key");
8611 assert_eq!(config.deepseek_base_url(), "https://gateway.example.com/v1");
8612 assert_eq!(config.default_model(), "DeepSeek-V4-Pro");
8613 Ok(())
8614 }
8615
8616 #[test]
8617 fn novita_reads_provider_table_from_config_file() -> Result<()> {
8618 let _lock = lock_test_env();
8619 let nanos = SystemTime::now()
8620 .duration_since(UNIX_EPOCH)
8621 .unwrap()
8622 .as_nanos();
8623 let temp_root = env::temp_dir().join(format!(
8624 "codewhale-tui-novita-table-{}-{}",
8625 std::process::id(),
8626 nanos
8627 ));
8628 fs::create_dir_all(&temp_root)?;
8629 let _guard = EnvGuard::new(&temp_root);
8630
8631 let config_path = temp_root.join(".deepseek").join("config.toml");
8632 ensure_parent_dir(&config_path)?;
8633 fs::write(
8634 &config_path,
8635 r#"provider = "novita"
8636
8637 [providers.novita]
8638 api_key = "novita-table-key"
8639 "#,
8640 )?;
8641
8642 let config = Config::load(None, None)?;
8643 assert_eq!(config.api_provider(), ApiProvider::Novita);
8644 assert_eq!(config.deepseek_api_key()?, "novita-table-key");
8645 assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);
8646 Ok(())
8647 }
8648
8649 #[test]
8650 fn moonshot_kimi_import_is_api_key_only_and_never_reads_external_credentials() -> Result<()> {
8651 let _lock = lock_test_env();
8652 let nanos = SystemTime::now()
8653 .duration_since(UNIX_EPOCH)
8654 .unwrap()
8655 .as_nanos();
8656 let temp_root = env::temp_dir().join(format!(
8657 "codewhale-tui-kimi-code-oauth-key-{}-{}",
8658 std::process::id(),
8659 nanos
8660 ));
8661 fs::create_dir_all(&temp_root)?;
8662 let _guard = EnvGuard::new(&temp_root);
8663
8664 let kimi_code_home = temp_root.join(".kimi-code");
8665 let credential_dir = kimi_code_home.join("credentials");
8666 fs::create_dir_all(&credential_dir)?;
8667 unsafe { env::set_var("KIMI_CODE_HOME", &kimi_code_home) };
8668
8669 let credential = json!({
8670 "access_token": "must-never-be-read",
8671 "refresh_token": "must-never-be-used",
8672 "expires_at": SystemTime::now()
8673 .duration_since(UNIX_EPOCH)?
8674 .as_secs_f64()
8675 + 3600.0,
8676 "scope": "openid profile email",
8677 "token_type": "Bearer",
8678 });
8679 let credential_path = credential_dir.join("kimi-code.json");
8680 let credential_raw = serde_json::to_string(&credential)?;
8681 fs::write(&credential_path, &credential_raw)?;
8682
8683 let config_path = temp_root.join(".deepseek").join("config.toml");
8684 ensure_parent_dir(&config_path)?;
8685 fs::write(
8686 &config_path,
8687 r#"provider = "moonshot"
8688
8689 [providers.moonshot]
8690 auth_mode = "kimi_oauth"
8691 api_key = "stale-api-key"
8692 "#,
8693 )?;
8694
8695 let config = Config::load(None, None)?;
8696 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
8697 assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL);
8698 assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL);
8699 let error = config
8700 .deepseek_api_key()
8701 .expect_err("Kimi external OAuth credentials are never imported");
8702 assert!(error.to_string().contains("does not impersonate"));
8703 assert!(
8704 error
8705 .to_string()
8706 .contains(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
8707 );
8708 assert!(
8709 !error
8710 .to_string()
8711 .contains("https://platform.kimi.ai/console/api-keys")
8712 );
8713 assert!(!has_api_key_for(&config, ApiProvider::Moonshot));
8714 assert_eq!(
8715 fs::read_to_string(credential_path)?,
8716 credential_raw,
8717 "Codewhale must never read, refresh, or rewrite Kimi CLI credentials"
8718 );
8719 Ok(())
8720 }
8721
8722 #[test]
8723 fn moonshot_credential_help_keeps_direct_and_kimi_code_routes_distinct() {
8724 let direct =
8725 credential_help_for_provider_route(ApiProvider::Moonshot, DEFAULT_MOONSHOT_BASE_URL);
8726 assert_eq!(
8727 direct.credential_url,
8728 Some("https://platform.kimi.ai/console/api-keys")
8729 );
8730 assert_eq!(
8731 direct.docs_url,
8732 Some("https://platform.kimi.ai/docs/overview")
8733 );
8734
8735 let kimi_code =
8736 credential_help_for_provider_route(ApiProvider::Moonshot, DEFAULT_KIMI_CODE_BASE_URL);
8737 assert_eq!(
8738 kimi_code.credential_url,
8739 Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
8740 );
8741 assert_eq!(kimi_code.docs_url, None);
8742 assert!(kimi_code.guidance.contains("membership-plan API key"));
8743 assert!(
8744 kimi_code
8745 .guidance
8746 .contains("does not import Kimi CLI credentials")
8747 );
8748 }
8749
8750 #[test]
8751 fn codex_external_credentials_are_disabled_by_default_and_managed_fails_before_io() -> Result<()> {
8752 let _lock = lock_test_env();
8753 let temp = tempfile::tempdir()?;
8754 let temp_root = temp.path().canonicalize()?;
8755 let auth_path = temp_root.join("codex-auth.json");
8756 let token = crate::test_support::future_test_jwt("codex");
8757 let raw = serde_json::to_string_pretty(&json!({
8758 "tokens": {
8759 "access_token": token.clone(),
8760 "account_id": "acct-must-not-be-read",
8761 "refresh_token": "must-never-be-used"
8762 },
8763 "unknown": {"preserve": true}
8764 }))?;
8765 fs::write(&auth_path, &raw)?;
8766 let ambient_decoy = temp_root.join("ambient-decoy.json");
8767 let ambient_decoy_raw = r#"{"tokens":{"access_token":"must-not-be-read"}}"#;
8768 fs::write(&ambient_decoy, ambient_decoy_raw)?;
8769 let _auth_path = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &ambient_decoy);
8770 let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
8771 let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
8772 let _account = EnvVarGuard::remove("OPENAI_CODEX_ACCOUNT_ID");
8773 let _legacy_account = EnvVarGuard::remove("CODEX_ACCOUNT_ID");
8774
8775 let disabled = Config {
8776 provider: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
8777 ..Default::default()
8778 };
8779 crate::external_credentials::reset_side_effect_trap();
8780 assert!(!has_api_key_for(&disabled, ApiProvider::OpenaiCodex));
8781 let error = disabled
8782 .deepseek_api_key()
8783 .expect_err("external credentials default to disabled");
8784 assert!(error.to_string().contains("are disabled"));
8785 assert_eq!(disabled.codex_account_id(), None);
8786 assert_eq!(
8787 crate::external_credentials::side_effect_trap_counts(),
8788 (0, 0)
8789 );
8790
8791 let mut managed_consent = codewhale_config::ExternalCredentialConsentToml::read_only(
8792 codewhale_config::ProviderKind::OpenaiCodex,
8793 codewhale_config::ExternalCredentialSource::CodexCli,
8794 auth_path.clone(),
8795 );
8796 managed_consent.access = codewhale_config::ExternalCredentialAccess::Managed;
8797 let managed = Config {
8798 provider: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
8799 providers: Some(ProvidersConfig {
8800 openai_codex: ProviderConfig {
8801 auth_mode: Some("oauth".to_string()),
8802 external_credentials: Some(managed_consent),
8803 ..Default::default()
8804 },
8805 ..Default::default()
8806 }),
8807 ..Default::default()
8808 };
8809 crate::external_credentials::reset_side_effect_trap();
8810 assert!(!has_api_key_for(&managed, ApiProvider::OpenaiCodex));
8811 let error = managed
8812 .deepseek_api_key()
8813 .expect_err("managed access needs a preservation adapter");
8814 assert!(
8815 error
8816 .to_string()
8817 .contains("schema-safe preservation adapter")
8818 );
8819 assert_eq!(
8820 crate::external_credentials::side_effect_trap_counts(),
8821 (0, 0)
8822 );
8823 assert_eq!(fs::read_to_string(&auth_path)?, raw);
8824 Ok(())
8825 }
8826
8827 #[test]
8828 fn codex_read_only_consent_reads_exact_file_without_mutation() -> Result<()> {
8829 let _lock = lock_test_env();
8830 let temp = tempfile::tempdir()?;
8831 let temp_root = temp.path().canonicalize()?;
8832 let auth_path = temp_root.join("codex-auth.json");
8833 let token = crate::test_support::future_test_jwt("codex");
8834 let raw = serde_json::to_string_pretty(&json!({
8835 "tokens": {
8836 "access_token": token.clone(),
8837 "account_id": "acct-read-only",
8838 "refresh_token": "must-never-be-used",
8839 "future_field": ["preserve"]
8840 },
8841 "future_top_level": true
8842 }))?;
8843 fs::write(&auth_path, &raw)?;
8844 let ambient_decoy = temp_root.join("ambient-decoy.json");
8845 let ambient_decoy_raw = r#"{"tokens":{"access_token":"must-not-be-read"}}"#;
8846 fs::write(&ambient_decoy, ambient_decoy_raw)?;
8847 let _auth_path = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &ambient_decoy);
8848 let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
8849 let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
8850 let config = Config {
8851 provider: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
8852 providers: Some(ProvidersConfig {
8853 openai_codex: ProviderConfig {
8854 auth_mode: Some("oauth".to_string()),
8855 external_credentials: Some(
8856 codewhale_config::ExternalCredentialConsentToml::read_only(
8857 codewhale_config::ProviderKind::OpenaiCodex,
8858 codewhale_config::ExternalCredentialSource::CodexCli,
8859 auth_path.clone(),
8860 ),
8861 ),
8862 ..Default::default()
8863 },
8864 ..Default::default()
8865 }),
8866 ..Default::default()
8867 };
8868
8869 let mut inactive = config.clone();
8870 inactive.provider = Some(ApiProvider::Deepseek.as_str().to_string());
8871 crate::external_credentials::reset_side_effect_trap();
8872 assert!(inactive.external_credential_read_consent_configured(
8873 ApiProvider::OpenaiCodex,
8874 codewhale_config::ExternalCredentialSource::CodexCli,
8875 ));
8876 let dormant_error = inactive
8877 .external_credential_read_grant(
8878 ApiProvider::OpenaiCodex,
8879 codewhale_config::ExternalCredentialSource::CodexCli,
8880 &ambient_decoy,
8881 )
8882 .expect_err("inactive providers cannot mint external read capabilities");
8883 assert!(dormant_error.to_string().contains("explicitly selected"));
8884 assert_eq!(
8885 crate::external_credentials::side_effect_trap_counts(),
8886 (0, 0)
8887 );
8888
8889 let active_grant = config.external_credential_read_grant(
8890 ApiProvider::OpenaiCodex,
8891 codewhale_config::ExternalCredentialSource::CodexCli,
8892 &ambient_decoy,
8893 )?;
8894 assert_eq!(
8895 active_grant.path(),
8896 auth_path,
8897 "the selected route remains pinned to the persisted consent path"
8898 );
8899
8900 crate::external_credentials::reset_side_effect_trap();
8901 assert_eq!(config.deepseek_api_key()?, token);
8902 assert_eq!(
8903 crate::external_credentials::side_effect_trap_counts(),
8904 (1, 1)
8905 );
8906 assert_eq!(fs::read_to_string(&auth_path)?, raw);
8907 assert_eq!(fs::read_to_string(&ambient_decoy)?, ambient_decoy_raw);
8908
8909 drop(_access);
8910 let _process_access = EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "process-token");
8911 crate::external_credentials::reset_side_effect_trap();
8912 assert_eq!(config.deepseek_api_key()?, "process-token");
8913 assert_eq!(config.codex_account_id(), None);
8914 assert_eq!(
8915 crate::external_credentials::side_effect_trap_counts(),
8916 (0, 0),
8917 "process-scoped Codex auth must not be mixed with external-file metadata"
8918 );
8919 Ok(())
8920 }
8921
8922 #[test]
8923 fn moonshot_kimi_code_api_key_uses_coding_model() -> Result<()> {
8924 let _lock = lock_test_env();
8925 let nanos = SystemTime::now()
8926 .duration_since(UNIX_EPOCH)
8927 .unwrap()
8928 .as_nanos();
8929 let temp_root = env::temp_dir().join(format!(
8930 "codewhale-tui-kimi-code-key-{}-{}",
8931 std::process::id(),
8932 nanos
8933 ));
8934 fs::create_dir_all(&temp_root)?;
8935 let _guard = EnvGuard::new(&temp_root);
8936
8937 let config_path = temp_root.join(".deepseek").join("config.toml");
8938 ensure_parent_dir(&config_path)?;
8939 fs::write(
8940 &config_path,
8941 r#"provider = "moonshot"
8942
8943 [providers.moonshot]
8944 api_key = "kimi-code-key"
8945 base_url = "https://api.kimi.com/coding/v1"
8946 "#,
8947 )?;
8948
8949 let config = Config::load(None, None)?;
8950 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
8951 assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL);
8952 assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL);
8953 assert_eq!(config.deepseek_api_key()?, "kimi-code-key");
8954 assert!(has_api_key_for(&config, ApiProvider::Moonshot));
8955 Ok(())
8956 }
8957
8958 #[test]
8959 fn moonshot_kimi_code_missing_key_reports_membership_plan_console() -> Result<()> {
8960 let _lock = lock_test_env();
8961 let temp = tempfile::tempdir()?;
8962 let _guard = EnvGuard::new(temp.path());
8963 let config = Config {
8964 provider: Some(ApiProvider::Moonshot.as_str().to_string()),
8965 providers: Some(ProvidersConfig {
8966 moonshot: ProviderConfig {
8967 base_url: Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()),
8968 model: Some(KIMI_CODE_K3_MODEL.to_string()),
8969 ..Default::default()
8970 },
8971 ..Default::default()
8972 }),
8973 ..Default::default()
8974 };
8975
8976 let error = config
8977 .deepseek_api_key()
8978 .expect_err("Kimi Code route needs a membership-plan API key");
8979 let message = error.to_string();
8980 assert!(
8981 message.contains(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL),
8982 "{message}"
8983 );
8984 assert!(message.contains("api.kimi.com/coding/v1"), "{message}");
8985 assert!(
8986 message.contains("does not import Kimi CLI credentials"),
8987 "{message}"
8988 );
8989 assert!(!message.contains("https://platform.kimi.ai/console/api-keys"));
8990 Ok(())
8991 }
8992
8993 #[test]
8994 fn moonshot_kimi_code_saved_claude_k3_1m_alias_fails_with_api_model_guidance() -> Result<()> {
8995 let _lock = lock_test_env();
8996 let temp = tempfile::tempdir()?;
8997 let _guard = EnvGuard::new(temp.path());
8998 let config_path = temp.path().join(".deepseek").join("config.toml");
8999 ensure_parent_dir(&config_path)?;
9000 fs::write(
9001 &config_path,
9002 r#"provider = "moonshot"
9003
9004 [providers.moonshot]
9005 api_key = "kimi-code-key"
9006 base_url = "https://api.kimi.com/coding/v1"
9007 model = "k3[1m]"
9008 "#,
9009 )?;
9010
9011 let error = Config::load(None, None)
9012 .expect_err("saved Claude Code context hints must not become API model ids");
9013 let message = error.to_string();
9014 assert!(message.contains("model = \"k3\""), "{message}");
9015 assert!(message.contains("context_window = 1048576"), "{message}");
9016 assert!(message.contains("plan includes 1M context"), "{message}");
9017 assert!(message.contains("262144 safe default"), "{message}");
9018 Ok(())
9019 }
9020
9021 /// Env-var-only path: `CODEWHALE_BASE_URL=https://api.kimi.com/coding/v1`
9022 /// combined with `CODEWHALE_PROVIDER=moonshot` must trigger Kimi Code
9023 /// model selection even when the TOML has no `base_url`.
9024 #[test]
9025 fn moonshot_kimi_code_env_base_url_selects_coding_model() -> Result<()> {
9026 let _lock = lock_test_env();
9027 let nanos = SystemTime::now()
9028 .duration_since(UNIX_EPOCH)
9029 .unwrap()
9030 .as_nanos();
9031 let temp_root = env::temp_dir().join(format!(
9032 "codewhale-tui-kimi-code-env-url-{}-{}",
9033 std::process::id(),
9034 nanos
9035 ));
9036 fs::create_dir_all(&temp_root)?;
9037 let _guard = EnvGuard::new(&temp_root);
9038
9039 let config_path = temp_root.join(".deepseek").join("config.toml");
9040 ensure_parent_dir(&config_path)?;
9041 fs::write(
9042 &config_path,
9043 r#"[providers.moonshot]
9044 api_key = "kimi-code-env-key"
9045 "#,
9046 )?;
9047 // Safety: test-only env mutation guarded by lock_test_env().
9048 unsafe {
9049 env::set_var("CODEWHALE_PROVIDER", "moonshot");
9050 env::set_var("CODEWHALE_BASE_URL", "https://api.kimi.com/coding/v1");
9051 }
9052
9053 let config = Config::load(None, None)?;
9054 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
9055 assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL);
9056 assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL);
9057 assert_eq!(config.deepseek_api_key()?, "kimi-code-env-key");
9058 assert!(has_api_key_for(&config, ApiProvider::Moonshot));
9059 Ok(())
9060 }
9061
9062 /// Regression for issue #2160: a stale root `default_text_model` carried
9063 /// over from a DeepSeek setup must not steer the Kimi Code endpoint to
9064 /// `deepseek-v4-pro`. The user-facing trigger here is the legacy
9065 /// `DEEPSEEK_PROVIDER` env var (still produced by the `codewhale
9066 /// --provider moonshot` dispatcher for compat); the test also has a
9067 /// `CODEWHALE_PROVIDER` twin below for the public env path.
9068 #[test]
9069 fn moonshot_kimi_code_model_overrides_root_deepseek_default() -> Result<()> {
9070 let _lock = lock_test_env();
9071 let nanos = SystemTime::now()
9072 .duration_since(UNIX_EPOCH)
9073 .unwrap()
9074 .as_nanos();
9075 let temp_root = env::temp_dir().join(format!(
9076 "codewhale-tui-kimi-code-root-model-{}-{}",
9077 std::process::id(),
9078 nanos
9079 ));
9080 fs::create_dir_all(&temp_root)?;
9081 let _guard = EnvGuard::new(&temp_root);
9082
9083 let config_path = temp_root.join(".deepseek").join("config.toml");
9084 ensure_parent_dir(&config_path)?;
9085 fs::write(
9086 &config_path,
9087 r#"provider = "deepseek"
9088 default_text_model = "deepseek-v4-pro"
9089
9090 [providers.moonshot]
9091 api_key = "kimi-code-key"
9092 base_url = "https://api.kimi.com/coding/v1"
9093 "#,
9094 )?;
9095 // Safety: test-only env mutation guarded by lock_test_env().
9096 unsafe { env::set_var("DEEPSEEK_PROVIDER", "moonshot") };
9097
9098 let config = Config::load(None, None)?;
9099 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
9100 assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL);
9101 assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL);
9102 Ok(())
9103 }
9104
9105 /// Same regression as above, but driven by the public `CODEWHALE_PROVIDER`
9106 /// env var. Documents the recommended user-facing setup path: never
9107 /// `DEEPSEEK_PROVIDER=moonshot`, always `CODEWHALE_PROVIDER=moonshot`
9108 /// (or `codewhale --provider moonshot`, which also resolves through
9109 /// this code path internally).
9110 #[test]
9111 fn moonshot_kimi_code_model_resolves_via_codewhale_provider_env() -> Result<()> {
9112 let _lock = lock_test_env();
9113 let nanos = SystemTime::now()
9114 .duration_since(UNIX_EPOCH)
9115 .unwrap()
9116 .as_nanos();
9117 let temp_root = env::temp_dir().join(format!(
9118 "codewhale-tui-kimi-code-cw-env-{}-{}",
9119 std::process::id(),
9120 nanos
9121 ));
9122 fs::create_dir_all(&temp_root)?;
9123 let _guard = EnvGuard::new(&temp_root);
9124
9125 let config_path = temp_root.join(".deepseek").join("config.toml");
9126 ensure_parent_dir(&config_path)?;
9127 fs::write(
9128 &config_path,
9129 r#"provider = "deepseek"
9130 default_text_model = "deepseek-v4-pro"
9131
9132 [providers.moonshot]
9133 api_key = "kimi-code-key"
9134 base_url = "https://api.kimi.com/coding/v1"
9135 "#,
9136 )?;
9137 // Safety: test-only env mutation guarded by lock_test_env().
9138 unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
9139
9140 let config = Config::load(None, None)?;
9141 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
9142 assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL);
9143 assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL);
9144 Ok(())
9145 }
9146
9147 /// `CODEWHALE_PROVIDER` wins when both it and the legacy
9148 /// `DEEPSEEK_PROVIDER` are set, so a user adding the new alias to their
9149 /// shell isn't surprised by a stale legacy export.
9150 #[test]
9151 fn codewhale_provider_env_takes_precedence_over_deepseek_provider() -> Result<()> {
9152 let _lock = lock_test_env();
9153 let nanos = SystemTime::now()
9154 .duration_since(UNIX_EPOCH)
9155 .unwrap()
9156 .as_nanos();
9157 let temp_root = env::temp_dir().join(format!(
9158 "codewhale-tui-cw-vs-ds-provider-{}-{}",
9159 std::process::id(),
9160 nanos
9161 ));
9162 fs::create_dir_all(&temp_root)?;
9163 let _guard = EnvGuard::new(&temp_root);
9164
9165 let config_path = temp_root.join(".deepseek").join("config.toml");
9166 ensure_parent_dir(&config_path)?;
9167 fs::write(&config_path, "provider = \"deepseek\"\n")?;
9168 // Safety: test-only env mutation guarded by lock_test_env().
9169 unsafe {
9170 env::set_var("CODEWHALE_PROVIDER", "moonshot");
9171 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
9172 }
9173
9174 let config = Config::load(None, None)?;
9175 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
9176 Ok(())
9177 }
9178
9179 /// Moonshot Platform path: when [providers.moonshot] is empty (or
9180 /// missing) and no Kimi Code endpoint is configured, the resolver
9181 /// defaults to the Moonshot Platform base URL and the latest Kimi platform
9182 /// model. This is the "I have a Moonshot Platform API key, not a
9183 /// Kimi Code plan key" path.
9184 #[test]
9185 fn moonshot_platform_defaults_to_kimi_k27_code() -> Result<()> {
9186 let _lock = lock_test_env();
9187 let nanos = SystemTime::now()
9188 .duration_since(UNIX_EPOCH)
9189 .unwrap()
9190 .as_nanos();
9191 let temp_root = env::temp_dir().join(format!(
9192 "codewhale-tui-moonshot-platform-{}-{}",
9193 std::process::id(),
9194 nanos
9195 ));
9196 fs::create_dir_all(&temp_root)?;
9197 let _guard = EnvGuard::new(&temp_root);
9198
9199 let config_path = temp_root.join(".deepseek").join("config.toml");
9200 ensure_parent_dir(&config_path)?;
9201 fs::write(
9202 &config_path,
9203 r#"provider = "moonshot"
9204
9205 [providers.moonshot]
9206 api_key = "moonshot-platform-key"
9207 "#,
9208 )?;
9209
9210 let config = Config::load(None, None)?;
9211 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
9212 assert_eq!(config.deepseek_base_url(), DEFAULT_MOONSHOT_BASE_URL);
9213 assert_eq!(config.default_model(), DEFAULT_MOONSHOT_MODEL);
9214 assert_eq!(config.deepseek_api_key()?, "moonshot-platform-key");
9215 Ok(())
9216 }
9217
9218 #[test]
9219 fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {
9220 let _lock = lock_test_env();
9221 let nanos = SystemTime::now()
9222 .duration_since(UNIX_EPOCH)
9223 .unwrap()
9224 .as_nanos();
9225 let temp_root = env::temp_dir().join(format!(
9226 "codewhale-tui-has-key-{}-{}",
9227 std::process::id(),
9228 nanos
9229 ));
9230 fs::create_dir_all(&temp_root)?;
9231 let _guard = EnvGuard::new(&temp_root);
9232
9233 let mut config = Config::default();
9234 assert!(!has_api_key_for(&config, ApiProvider::Openai));
9235 assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));
9236 assert!(!has_api_key_for(&config, ApiProvider::Volcengine));
9237 assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
9238 assert!(!has_api_key_for(&config, ApiProvider::XiaomiMimo));
9239 assert!(!has_api_key_for(&config, ApiProvider::Siliconflow));
9240 assert!(
9241 has_api_key_for(&config, ApiProvider::Sglang),
9242 "SGLang is self-hosted and does not require a key by default"
9243 );
9244 assert!(
9245 has_api_key_for(&config, ApiProvider::Vllm),
9246 "vLLM is self-hosted and does not require a key by default"
9247 );
9248
9249 // Safety: test-only environment mutation guarded by a global mutex.
9250 unsafe {
9251 env::set_var("OPENROUTER_API_KEY", "or-env");
9252 env::set_var("OPENAI_API_KEY", "openai-env");
9253 env::set_var("WANJIE_API_KEY", "wanjie-env");
9254 env::set_var("ARK_API_KEY", "volc-env");
9255 env::set_var("MIMO_API_KEY", "mimo-env");
9256 env::set_var("SILICONFLOW_API_KEY", "sf-env");
9257 }
9258 assert!(has_api_key_for(&config, ApiProvider::Openai));
9259 assert!(has_api_key_for(&config, ApiProvider::WanjieArk));
9260 assert!(has_api_key_for(&config, ApiProvider::Volcengine));
9261 assert!(has_api_key_for(&config, ApiProvider::Openrouter));
9262 assert!(has_api_key_for(&config, ApiProvider::XiaomiMimo));
9263 assert!(has_api_key_for(&config, ApiProvider::Siliconflow));
9264 assert!(!has_api_key_for(&config, ApiProvider::Novita));
9265
9266 // Safety: test-only environment mutation guarded by a global mutex.
9267 unsafe {
9268 env::remove_var("OPENROUTER_API_KEY");
9269 env::remove_var("OPENAI_API_KEY");
9270 env::remove_var("WANJIE_API_KEY");
9271 env::remove_var("ARK_API_KEY");
9272 env::remove_var("MIMO_API_KEY");
9273 env::remove_var("SILICONFLOW_API_KEY");
9274 }
9275 let mut providers = ProvidersConfig::default();
9276 providers.openai.api_key = Some("file-openai".to_string());
9277 providers.wanjie_ark.api_key = Some("file-wanjie".to_string());
9278 providers.xiaomi_mimo.api_key = Some("file-mimo".to_string());
9279 providers.novita.api_key = Some("file-novita".to_string());
9280 providers.siliconflow.api_key = Some("file-siliconflow".to_string());
9281 config.providers = Some(providers);
9282 assert!(has_api_key_for(&config, ApiProvider::Openai));
9283 assert!(has_api_key_for(&config, ApiProvider::WanjieArk));
9284 assert!(has_api_key_for(&config, ApiProvider::XiaomiMimo));
9285 assert!(has_api_key_for(&config, ApiProvider::Novita));
9286 assert!(has_api_key_for(&config, ApiProvider::Siliconflow));
9287 assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
9288 Ok(())
9289 }
9290
9291 #[test]
9292 fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {
9293 let _lock = lock_test_env();
9294 let nanos = SystemTime::now()
9295 .duration_since(UNIX_EPOCH)
9296 .unwrap()
9297 .as_nanos();
9298 let temp_root = env::temp_dir().join(format!(
9299 "codewhale-tui-has-key-cn-{}-{}",
9300 std::process::id(),
9301 nanos
9302 ));
9303 fs::create_dir_all(&temp_root)?;
9304 let _guard = EnvGuard::new(&temp_root);
9305
9306 let mut providers = ProvidersConfig::default();
9307 providers.deepseek_cn.api_key = Some("cn-file-key".to_string());
9308 let config = Config {
9309 providers: Some(providers),
9310 ..Config::default()
9311 };
9312
9313 assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));
9314 Ok(())
9315 }
9316
9317 #[test]
9318 fn provider_auth_source_metadata_is_not_a_runtime_credential() -> Result<()> {
9319 let _lock = lock_test_env();
9320 let temp_root = tempfile::tempdir()?;
9321 let _guard = EnvGuard::new(temp_root.path());
9322 let mut providers = ProvidersConfig::default();
9323 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
9324 source: codewhale_config::AuthSourceKind::Command,
9325 command: vec!["secret-tool".to_string(), "lookup".to_string()],
9326 timeout_ms: Some(2000),
9327 secret_id: None,
9328 });
9329 let config = Config {
9330 provider: Some("openai".to_string()),
9331 providers: Some(providers),
9332 ..Config::default()
9333 };
9334
9335 assert!(!has_api_key_for(&config, ApiProvider::Openai));
9336 assert!(config.deepseek_api_key().is_err());
9337 Ok(())
9338 }
9339
9340 #[test]
9341 fn xai_oauth_selection_falls_back_to_explicit_api_key_without_external_io() -> Result<()> {
9342 let _lock = lock_test_env();
9343 let nanos = SystemTime::now()
9344 .duration_since(UNIX_EPOCH)
9345 .unwrap()
9346 .as_nanos();
9347 let temp_root = env::temp_dir().join(format!(
9348 "codewhale-tui-xai-auth-{}-{}",
9349 std::process::id(),
9350 nanos
9351 ));
9352 fs::create_dir_all(&temp_root)?;
9353 let auth_path = temp_root.join("auth.json");
9354 let _auth_path = EnvVarGuard::set("GROK_AUTH_PATH", auth_path.as_os_str());
9355 let _xai_key = EnvVarGuard::remove("XAI_API_KEY");
9356
9357 let mut providers = ProvidersConfig::default();
9358 providers.xai.api_key = Some("fake-xai-cfg-key".to_string());
9359 providers.xai.auth_mode = Some("oauth".to_string());
9360 let api_key_config = Config {
9361 provider: Some("xai".to_string()),
9362 providers: Some(providers),
9363 ..Config::default()
9364 };
9365 crate::external_credentials::reset_side_effect_trap();
9366 assert!(has_api_key_for(&api_key_config, ApiProvider::Xai));
9367 assert_eq!(api_key_config.deepseek_api_key()?, "fake-xai-cfg-key");
9368 assert_eq!(
9369 crate::external_credentials::side_effect_trap_counts(),
9370 (0, 0)
9371 );
9372
9373 fs::write(&auth_path, "{}")?;
9374 assert!(!has_api_key_for(&Config::default(), ApiProvider::Xai));
9375 fs::remove_dir_all(temp_root)?;
9376 Ok(())
9377 }
9378
9379 #[test]
9380 fn xai_invalid_owned_generation_blocks_external_and_uses_api_key_fallback() -> Result<()> {
9381 let _lock = lock_test_env();
9382 let root = tempfile::tempdir()?;
9383 let root = root.path().canonicalize()?;
9384 let external_path = root.join("grok-auth.json");
9385 let external_raw = r#"{"token":"external-owner-bytes-must-not-be-read"}"#;
9386 fs::write(&external_path, external_raw)?;
9387 let _home = EnvVarGuard::set("CODEWHALE_HOME", &root);
9388 let _auth_path = EnvVarGuard::set("GROK_AUTH_PATH", &external_path);
9389 let _xai_key = EnvVarGuard::remove("XAI_API_KEY");
9390 let _xai_base_url = EnvVarGuard::remove("XAI_BASE_URL");
9391
9392 let mut providers = ProvidersConfig::default();
9393 providers.xai.api_key = Some("fake-xai-cfg-key".to_string());
9394 providers.xai.auth_mode = Some("oauth".to_string());
9395 providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
9396 providers.xai.external_credentials =
9397 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9398 codewhale_config::ProviderKind::Xai,
9399 codewhale_config::ExternalCredentialSource::GrokCli,
9400 external_path.clone(),
9401 ));
9402 let config = Config {
9403 provider: Some(ApiProvider::Xai.as_str().to_string()),
9404 providers: Some(providers),
9405 ..Config::default()
9406 };
9407
9408 crate::external_credentials::reset_side_effect_trap();
9409 assert!(
9410 !crate::xai_oauth::credentials_present(&config),
9411 "an invalid owned generation pointer must not resolve external OAuth"
9412 );
9413 assert_eq!(config.deepseek_api_key()?, "fake-xai-cfg-key");
9414 assert_eq!(
9415 crate::external_credentials::side_effect_trap_counts(),
9416 (0, 0),
9417 "an unusable owned generation must not access the external Grok CLI"
9418 );
9419 assert_eq!(fs::read_to_string(external_path)?, external_raw);
9420 Ok(())
9421 }
9422
9423 #[test]
9424 fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {
9425 let _lock = lock_test_env();
9426 let config = Config {
9427 api_key: Some("root-config-key".to_string()),
9428 ..Config::default()
9429 };
9430
9431 assert!(has_api_key_for(&config, ApiProvider::Deepseek));
9432 assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));
9433 }
9434
9435 #[test]
9436 fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {
9437 let _lock = lock_test_env();
9438 let nanos = SystemTime::now()
9439 .duration_since(UNIX_EPOCH)
9440 .unwrap()
9441 .as_nanos();
9442 let temp_root = env::temp_dir().join(format!(
9443 "codewhale-tui-save-key-or-{}-{}",
9444 std::process::id(),
9445 nanos
9446 ));
9447 fs::create_dir_all(&temp_root)?;
9448 let _guard = EnvGuard::new(&temp_root);
9449 let config_path = temp_root.join(".deepseek").join("config.toml");
9450 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
9451 let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "local");
9452 let resolved_config_path = codewhale_config::resolve_config_path(None)?;
9453
9454 let path = save_api_key_for(ApiProvider::Openrouter, "or-saved-key")?;
9455 assert_eq!(path, resolved_config_path);
9456 let contents = fs::read_to_string(&path)?;
9457 let parsed: toml::Value = toml::from_str(&contents)?;
9458 assert_eq!(
9459 parsed
9460 .get("providers")
9461 .and_then(|p| p.get("openrouter"))
9462 .and_then(|t| t.get("api_key"))
9463 .and_then(toml::Value::as_str),
9464 Some("or-saved-key")
9465 );
9466 // Re-saving must not duplicate or wipe sibling tables.
9467 let novita_path = save_api_key_for(ApiProvider::Novita, "novita-saved-key")?;
9468 assert_eq!(novita_path.canonicalize()?, path.canonicalize()?);
9469 let contents = fs::read_to_string(&path)?;
9470 let parsed: toml::Value = toml::from_str(&contents)?;
9471 assert_eq!(
9472 parsed
9473 .get("providers")
9474 .and_then(|p| p.get("openrouter"))
9475 .and_then(|t| t.get("api_key"))
9476 .and_then(toml::Value::as_str),
9477 Some("or-saved-key")
9478 );
9479 assert_eq!(
9480 parsed
9481 .get("providers")
9482 .and_then(|p| p.get("novita"))
9483 .and_then(|t| t.get("api_key"))
9484 .and_then(toml::Value::as_str),
9485 Some("novita-saved-key")
9486 );
9487 for (provider, key) in [
9488 (ApiProvider::Openai, "openai-saved-key"),
9489 (ApiProvider::WanjieArk, "wanjie-saved-key"),
9490 (ApiProvider::Fireworks, "fireworks-saved-key"),
9491 (ApiProvider::XiaomiMimo, "mimo-saved-key"),
9492 (ApiProvider::Siliconflow, "sf-saved-key"),
9493 (ApiProvider::Sglang, "sglang-saved-key"),
9494 ] {
9495 assert_eq!(
9496 save_api_key_for(provider, key)?.canonicalize()?,
9497 path.canonicalize()?
9498 );
9499 }
9500 let contents = fs::read_to_string(&path)?;
9501 let parsed: toml::Value = toml::from_str(&contents)?;
9502 assert_eq!(
9503 parsed
9504 .get("providers")
9505 .and_then(|p| p.get("openai"))
9506 .and_then(|t| t.get("api_key"))
9507 .and_then(toml::Value::as_str),
9508 Some("openai-saved-key")
9509 );
9510 assert_eq!(
9511 parsed
9512 .get("providers")
9513 .and_then(|p| p.get("wanjie_ark"))
9514 .and_then(|t| t.get("api_key"))
9515 .and_then(toml::Value::as_str),
9516 Some("wanjie-saved-key")
9517 );
9518 assert_eq!(
9519 parsed
9520 .get("providers")
9521 .and_then(|p| p.get("fireworks"))
9522 .and_then(|t| t.get("api_key"))
9523 .and_then(toml::Value::as_str),
9524 Some("fireworks-saved-key")
9525 );
9526 assert_eq!(
9527 parsed
9528 .get("providers")
9529 .and_then(|p| p.get("xiaomi_mimo"))
9530 .and_then(|t| t.get("api_key"))
9531 .and_then(toml::Value::as_str),
9532 Some("mimo-saved-key")
9533 );
9534 assert_eq!(
9535 parsed
9536 .get("providers")
9537 .and_then(|p| p.get("siliconflow"))
9538 .and_then(|t| t.get("api_key"))
9539 .and_then(toml::Value::as_str),
9540 Some("sf-saved-key")
9541 );
9542 assert_eq!(
9543 parsed
9544 .get("providers")
9545 .and_then(|p| p.get("sglang"))
9546 .and_then(|t| t.get("api_key"))
9547 .and_then(toml::Value::as_str),
9548 Some("sglang-saved-key")
9549 );
9550 save_api_key_for(ApiProvider::SiliconflowCn, "sf-cn-saved-key")?;
9551 let contents = fs::read_to_string(&path)?;
9552 let parsed: toml::Value = toml::from_str(&contents)?;
9553 assert_eq!(
9554 parsed
9555 .get("providers")
9556 .and_then(|p| p.get("siliconflow_cn"))
9557 .and_then(|t| t.get("api_key"))
9558 .and_then(toml::Value::as_str),
9559 Some("sf-cn-saved-key")
9560 );
9561 assert_eq!(
9562 parsed
9563 .get("providers")
9564 .and_then(|p| p.get("siliconflow"))
9565 .and_then(|t| t.get("api_key"))
9566 .and_then(toml::Value::as_str),
9567 Some("sf-saved-key")
9568 );
9569 Ok(())
9570 }
9571
9572 #[test]
9573 fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {
9574 let _lock = lock_test_env();
9575 let nanos = SystemTime::now()
9576 .duration_since(UNIX_EPOCH)
9577 .unwrap()
9578 .as_nanos();
9579 let temp_root = env::temp_dir().join(format!(
9580 "codewhale-tui-save-key-cn-{}-{}",
9581 std::process::id(),
9582 nanos
9583 ));
9584 fs::create_dir_all(&temp_root)?;
9585 let _guard = EnvGuard::new(&temp_root);
9586 let config_path = temp_root.join(".deepseek").join("config.toml");
9587 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
9588 let _secret_backend = EnvVarGuard::set("DEEPSEEK_SECRET_BACKEND", "local");
9589
9590 let path = save_api_key_for(ApiProvider::DeepseekCN, "cn-saved-key")?;
9591 assert_eq!(path, config_path);
9592 let contents = fs::read_to_string(&path)?;
9593 let parsed: toml::Value = toml::from_str(&contents)?;
9594
9595 assert_eq!(
9596 parsed.get("api_key").and_then(toml::Value::as_str),
9597 Some("cn-saved-key")
9598 );
9599 Ok(())
9600 }
9601
9602 #[test]
9603 fn modelstudio_variants_share_one_secret_slot_and_key_availability() -> Result<()> {
9604 let _lock = lock_test_env();
9605 let temp_root = tempfile::tempdir()?;
9606 let _guard = EnvGuard::new(temp_root.path());
9607 let codewhale_home = temp_root.path().join("codewhale-home");
9608 let config_path = codewhale_home.join("config.toml");
9609 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
9610 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
9611 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
9612 let _ms_env = EnvVarGuard::remove("MODELSTUDIO_API_KEY");
9613 let _dashscope_env = EnvVarGuard::remove("DASHSCOPE_API_KEY");
9614 let _cli_source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
9615 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
9616
9617 let variants = [
9618 ApiProvider::ModelstudioTokenPlan,
9619 ApiProvider::ModelstudioTokenPlanAnthropic,
9620 ApiProvider::ModelstudioCodingPlan,
9621 ApiProvider::ModelstudioCodingPlanAnthropic,
9622 ];
9623 for variant in variants {
9624 assert_eq!(
9625 provider_secret_store_slot(variant),
9626 "modelstudio-token-plan",
9627 "{variant:?} must share the family's one credential slot"
9628 );
9629 }
9630
9631 // Saving on the Token Plan variant writes the single family slot only.
9632 save_api_key_for(ApiProvider::ModelstudioTokenPlan, "ms-family-key")?;
9633 let secrets = codewhale_secrets::Secrets::auto_detect();
9634 assert_eq!(
9635 secrets.get("modelstudio-token-plan")?,
9636 Some("ms-family-key".to_string())
9637 );
9638 assert_eq!(secrets.get("modelstudio-coding-plan")?, None);
9639
9640 // Every variant — active or not — resolves the family key, so the picker
9641 // badge stops showing three bogus "missing key" rows after one save.
9642 let inactive_variants = Config::load(Some(config_path.clone()), None)?;
9643 for variant in variants {
9644 assert_eq!(
9645 provider_secret_store_api_key(&inactive_variants, variant).as_deref(),
9646 Some("ms-family-key"),
9647 "{variant:?} must read the family slot"
9648 );
9649 assert!(
9650 has_api_key_for(&inactive_variants, variant),
9651 "{variant:?} key-availability badge must resolve the family key"
9652 );
9653 }
9654
9655 // Saving on any sibling variant overwrites the same shared slot.
9656 save_api_key_for(
9657 ApiProvider::ModelstudioCodingPlanAnthropic,
9658 "ms-family-key-v2",
9659 )?;
9660 assert_eq!(
9661 secrets.get("modelstudio-token-plan")?,
9662 Some("ms-family-key-v2".to_string())
9663 );
9664 assert_eq!(secrets.get("modelstudio-coding-plan-anthropic")?, None);
9665 let reloaded = Config::load(Some(config_path), None)?;
9666 for variant in variants {
9667 assert_eq!(
9668 provider_secret_store_api_key(&reloaded, variant).as_deref(),
9669 Some("ms-family-key-v2"),
9670 "{variant:?} must follow the family slot across saves"
9671 );
9672 }
9673 Ok(())
9674 }
9675
9676 #[test]
9677 fn nvidia_nim_reads_facade_provider_table() -> Result<()> {
9678 let _lock = lock_test_env();
9679 let nanos = SystemTime::now()
9680 .duration_since(UNIX_EPOCH)
9681 .unwrap()
9682 .as_nanos();
9683 let temp_root = env::temp_dir().join(format!(
9684 "codewhale-tui-nim-provider-table-test-{}-{}",
9685 std::process::id(),
9686 nanos
9687 ));
9688 fs::create_dir_all(&temp_root)?;
9689 let _guard = EnvGuard::new(&temp_root);
9690
9691 let config_path = temp_root.join(".deepseek").join("config.toml");
9692 ensure_parent_dir(&config_path)?;
9693 fs::write(
9694 &config_path,
9695 r#"provider = "nvidia-nim"
9696 default_text_model = "deepseek-v4-flash"
9697
9698 [providers.nvidia_nim]
9699 api_key = "nim-table-key"
9700 base_url = "https://nim-table.example/v1"
9701 model = "deepseek-v4-pro"
9702 "#,
9703 )?;
9704
9705 let config = Config::load(None, None)?;
9706 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
9707 assert_eq!(config.deepseek_api_key()?, "nim-table-key");
9708 assert_eq!(config.deepseek_base_url(), "https://nim-table.example/v1");
9709 // Custom base URL preserves the user-specified model name; normalisation
9710 // is skipped because the gateway expects the model name as-provided.
9711 assert_eq!(config.default_model(), "deepseek-v4-pro");
9712 Ok(())
9713 }
9714
9715 #[test]
9716 fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {
9717 let _lock = lock_test_env();
9718 let nanos = SystemTime::now()
9719 .duration_since(UNIX_EPOCH)
9720 .unwrap()
9721 .as_nanos();
9722 let temp_root = env::temp_dir().join(format!(
9723 "codewhale-tui-nim-root-key-precedence-test-{}-{}",
9724 std::process::id(),
9725 nanos
9726 ));
9727 fs::create_dir_all(&temp_root)?;
9728 let _guard = EnvGuard::new(&temp_root);
9729
9730 let config_path = temp_root.join(".deepseek").join("config.toml");
9731 ensure_parent_dir(&config_path)?;
9732 fs::write(
9733 &config_path,
9734 r#"api_key = "codewhale-root-key"
9735 provider = "nvidia-nim"
9736
9737 [providers.nvidia_nim]
9738 api_key = "nim-table-key"
9739 base_url = "https://integrate.api.nvidia.com/v1"
9740 model = "deepseek-ai/deepseek-v4-pro"
9741 "#,
9742 )?;
9743
9744 let config = Config::load(None, None)?;
9745 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
9746 assert_eq!(config.deepseek_api_key()?, "nim-table-key");
9747 Ok(())
9748 }
9749
9750 // ========================================================================
9751 // Provider Capability Matrix tests
9752 // ========================================================================
9753
9754 #[test]
9755 fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {
9756 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-pro");
9757 assert_eq!(
9758 cap.context_window,
9759 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9760 );
9761 assert_eq!(cap.max_output, Some(384_000));
9762 assert!(cap.thinking_supported);
9763 assert!(cap.cache_telemetry_supported);
9764 assert_eq!(
9765 cap.request_payload_mode,
9766 RequestPayloadMode::ChatCompletions
9767 );
9768 }
9769
9770 #[test]
9771 fn provider_capability_deepseek_anthropic_uses_messages_payload() {
9772 let cap = provider_capability(
9773 ApiProvider::DeepseekAnthropic,
9774 DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
9775 );
9776 assert_eq!(
9777 cap.context_window,
9778 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9779 );
9780 assert_eq!(cap.max_output, Some(384_000));
9781 assert!(cap.thinking_supported);
9782 assert!(!cap.cache_telemetry_supported);
9783 assert_eq!(
9784 cap.request_payload_mode,
9785 RequestPayloadMode::AnthropicMessages
9786 );
9787 assert!(cap.alias_deprecation.is_none());
9788 }
9789
9790 #[test]
9791 fn provider_capability_openmodel_uses_messages_payload() {
9792 let cap = provider_capability(ApiProvider::Openmodel, DEFAULT_OPENMODEL_MODEL);
9793 assert_eq!(cap.resolved_model, DEFAULT_OPENMODEL_MODEL);
9794 assert_eq!(
9795 cap.context_window,
9796 crate::models::context_window_for_model(DEFAULT_OPENMODEL_MODEL).unwrap_or(200_000)
9797 );
9798 assert_eq!(
9799 cap.max_output,
9800 Some(crate::models::max_output_tokens_for_model(DEFAULT_OPENMODEL_MODEL).unwrap_or(64_000))
9801 );
9802 assert!(!cap.cache_telemetry_supported);
9803 assert_eq!(
9804 cap.request_payload_mode,
9805 RequestPayloadMode::AnthropicMessages
9806 );
9807 assert!(provider_passes_model_through(ApiProvider::Openmodel));
9808 }
9809
9810 #[test]
9811 fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {
9812 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-flash");
9813 assert_eq!(
9814 cap.context_window,
9815 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9816 );
9817 assert_eq!(cap.max_output, Some(384_000));
9818 assert!(cap.thinking_supported);
9819 assert!(cap.cache_telemetry_supported);
9820 }
9821
9822 #[test]
9823 fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {
9824 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-chat");
9825 assert_eq!(
9826 cap.context_window,
9827 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9828 );
9829 assert_eq!(cap.max_output, Some(384_000));
9830 assert!(cap.thinking_supported);
9831 assert!(cap.cache_telemetry_supported);
9832
9833 let deprecation = cap
9834 .alias_deprecation
9835 .as_ref()
9836 .expect("alias deprecation metadata");
9837 assert_eq!(deprecation.alias, "deepseek-chat");
9838 assert_eq!(deprecation.replacement, "deepseek-v4-flash");
9839 assert_eq!(deprecation.retirement_date, "2026-07-24");
9840 assert_eq!(deprecation.retirement_utc, "2026-07-24T15:59:00Z");
9841 }
9842
9843 #[test]
9844 fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {
9845 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-reasoner");
9846 assert_eq!(
9847 cap.context_window,
9848 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9849 );
9850 assert_eq!(cap.max_output, Some(384_000));
9851 assert!(cap.thinking_supported);
9852 assert!(cap.cache_telemetry_supported);
9853
9854 let deprecation = cap
9855 .alias_deprecation
9856 .as_ref()
9857 .expect("alias deprecation metadata");
9858 assert_eq!(deprecation.alias, "deepseek-reasoner");
9859 assert_eq!(deprecation.replacement, "deepseek-v4-flash");
9860 }
9861
9862 #[test]
9863 fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {
9864 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-flash");
9865 assert!(cap.alias_deprecation.is_none());
9866 }
9867
9868 #[test]
9869 fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {
9870 let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);
9871 assert_eq!(
9872 cap.context_window,
9873 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9874 );
9875 assert_eq!(cap.max_output, Some(384_000));
9876 assert!(cap.thinking_supported);
9877 assert!(cap.cache_telemetry_supported);
9878 assert_eq!(
9879 cap.request_payload_mode,
9880 RequestPayloadMode::ChatCompletions
9881 );
9882 }
9883
9884 #[test]
9885 fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {
9886 let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
9887 assert_eq!(
9888 cap.context_window,
9889 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9890 );
9891 assert_eq!(cap.max_output, Some(384_000));
9892 assert!(cap.thinking_supported);
9893 assert!(cap.cache_telemetry_supported);
9894 }
9895
9896 #[test]
9897 fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {
9898 let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);
9899 assert_eq!(
9900 cap.context_window,
9901 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
9902 );
9903 assert_eq!(cap.max_output, Some(384_000));
9904 assert!(cap.thinking_supported);
9905 // OpenRouter does not return DeepSeek prompt-cache telemetry.
9906 assert!(!cap.cache_telemetry_supported);
9907 assert_eq!(
9908 cap.request_payload_mode,
9909 RequestPayloadMode::ChatCompletions
9910 );
9911 }
9912
9913 #[test]
9914 fn provider_capability_openai_codex_uses_responses_payload() {
9915 let cap = provider_capability(ApiProvider::OpenaiCodex, DEFAULT_OPENAI_CODEX_MODEL);
9916 assert_eq!(cap.provider, ApiProvider::OpenaiCodex);
9917 assert_eq!(cap.resolved_model, DEFAULT_OPENAI_CODEX_MODEL);
9918 assert_eq!(
9919 cap.context_window,
9920 OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS
9921 );
9922 assert_eq!(cap.max_output, Some(4096));
9923 assert!(cap.thinking_supported);
9924 assert!(!cap.cache_telemetry_supported);
9925 assert_eq!(cap.request_payload_mode, RequestPayloadMode::Responses);
9926 }
9927
9928 #[test]
9929 fn invalid_provider_auth_source_is_not_explicit_configuration() {
9930 let entry = ProviderConfig {
9931 auth: Some(codewhale_config::ProviderAuthSourceToml {
9932 source: codewhale_config::AuthSourceKind::Command,
9933 command: Vec::new(),
9934 timeout_ms: None,
9935 secret_id: None,
9936 }),
9937 ..ProviderConfig::default()
9938 };
9939
9940 assert!(!provider_config_is_explicit(&entry));
9941 }
9942
9943 #[test]
9944 fn provider_capability_openrouter_recent_large_models_are_reasoning_aware() {
9945 for (model, expected_window, expected_output) in [
9946 (
9947 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
9948 262_144,
9949 262_144,
9950 ),
9951 (OPENROUTER_QWEN_3_6_FLASH_MODEL, 1_000_000, 65_536),
9952 // Output caps vendor-verified at 65,536 (MODEL_PROVIDER_AUDIT A2/D-7).
9953 (OPENROUTER_QWEN_3_6_35B_A3B_MODEL, 262_144, 65_536),
9954 (OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL, 262_144, 65_536),
9955 (OPENROUTER_QWEN_3_6_27B_MODEL, 262_144, 65_536),
9956 (OPENROUTER_QWEN_3_6_PLUS_MODEL, 1_000_000, 65_536),
9957 (OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL, 1_000_000, 131_072),
9958 (OPENROUTER_MINIMAX_M3_MODEL, 1_000_000, 524_288),
9959 (OPENROUTER_MINIMAX_M2_7_MODEL, 204_800, 131_072),
9960 (OPENROUTER_GLM_5_1_MODEL, 202_752, 131_072),
9961 (OPENROUTER_GLM_5_2_MODEL, 1_000_000, 131_072),
9962 (OPENROUTER_NEMOTRON_3_ULTRA_MODEL, 1_000_000, 16_384),
9963 ] {
9964 let cap = provider_capability(ApiProvider::Openrouter, model);
9965
9966 assert_eq!(cap.context_window, expected_window);
9967 assert_eq!(cap.max_output, Some(expected_output));
9968 assert!(cap.thinking_supported);
9969 assert!(!cap.cache_telemetry_supported);
9970 assert_eq!(
9971 cap.request_payload_mode,
9972 RequestPayloadMode::ChatCompletions
9973 );
9974 }
9975 }
9976
9977 #[test]
9978 fn openrouter_nemotron_ultra_aliases_resolve_to_live_id() {
9979 assert_eq!(
9980 OPENROUTER_NEMOTRON_3_ULTRA_MODEL,
9981 "nvidia/nemotron-3-ultra-550b-a55b"
9982 );
9983 assert_ne!(OPENROUTER_NEMOTRON_3_ULTRA_MODEL, "nvidia/nemotron-3-ultra");
9984
9985 for alias in [
9986 "nemotron-3-ultra",
9987 "nvidia/nemotron-3-ultra",
9988 "nvidia-nemotron-3-ultra",
9989 ] {
9990 assert_eq!(
9991 normalize_model_name_for_provider(ApiProvider::Openrouter, alias).as_deref(),
9992 Some(OPENROUTER_NEMOTRON_3_ULTRA_MODEL)
9993 );
9994 }
9995 }
9996
9997 #[test]
9998 fn provider_capability_arcee_direct_models_use_api_docs_shape() {
9999 let thinking_cap = provider_capability(ApiProvider::Arcee, DEFAULT_ARCEE_MODEL);
10000 assert_eq!(thinking_cap.context_window, 262_144);
10001 assert_eq!(thinking_cap.max_output, Some(262_144));
10002 assert!(thinking_cap.thinking_supported);
10003 assert!(!thinking_cap.cache_telemetry_supported);
10004 assert_eq!(
10005 thinking_cap.request_payload_mode,
10006 RequestPayloadMode::ChatCompletions
10007 );
10008
10009 let preview = provider_capability(ApiProvider::Arcee, ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
10010 assert_eq!(preview.context_window, 262_144);
10011 assert_eq!(preview.max_output, None);
10012 assert!(!preview.thinking_supported);
10013
10014 let mini = provider_capability(ApiProvider::Arcee, ARCEE_TRINITY_MINI_MODEL);
10015 assert_eq!(mini.context_window, 128_000);
10016 // Trinity Mini's upstream output limit is unknown, and ProviderCapability
10017 // now says so instead of fabricating a 4K request fallback.
10018 assert_eq!(mini.max_output, None);
10019 assert_eq!(
10020 crate::models::max_output_tokens_for_model(ARCEE_TRINITY_MINI_MODEL),
10021 None
10022 );
10023 assert!(mini.thinking_supported);
10024 assert!(!mini.cache_telemetry_supported);
10025 assert_eq!(
10026 mini.request_payload_mode,
10027 RequestPayloadMode::ChatCompletions
10028 );
10029 }
10030
10031 #[test]
10032 fn provider_capability_marks_exact_inkling_route_as_reasoning() {
10033 let cap = provider_capability(ApiProvider::Together, TOGETHER_INKLING_MODEL);
10034 assert!(cap.thinking_supported);
10035 assert_eq!(
10036 crate::models::context_window_for_model(TOGETHER_INKLING_MODEL),
10037 None
10038 );
10039 assert_eq!(
10040 crate::models::max_output_tokens_for_model(TOGETHER_INKLING_MODEL),
10041 None
10042 );
10043 }
10044
10045 #[test]
10046 fn provider_capability_xiaomi_mimo_has_thinking_no_cache() {
10047 let cap = provider_capability(ApiProvider::XiaomiMimo, DEFAULT_XIAOMI_MIMO_MODEL);
10048 assert_eq!(cap.context_window, 1_000_000);
10049 assert_eq!(cap.max_output, Some(131_072));
10050 assert!(cap.thinking_supported);
10051 assert!(!cap.cache_telemetry_supported);
10052 assert_eq!(
10053 cap.request_payload_mode,
10054 RequestPayloadMode::ChatCompletions
10055 );
10056
10057 let omni = provider_capability(ApiProvider::XiaomiMimo, XIAOMI_MIMO_V2_5_OMNI_MODEL);
10058 assert_eq!(omni.context_window, 1_000_000);
10059 assert_eq!(omni.max_output, Some(131_072));
10060 assert!(omni.thinking_supported);
10061 assert!(!omni.cache_telemetry_supported);
10062 }
10063
10064 #[test]
10065 fn provider_capability_novita_v4_pro_has_thinking_no_cache() {
10066 let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);
10067 assert_eq!(
10068 cap.context_window,
10069 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
10070 );
10071 assert_eq!(cap.max_output, Some(384_000));
10072 assert!(cap.thinking_supported);
10073 assert!(!cap.cache_telemetry_supported);
10074 }
10075
10076 #[test]
10077 fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {
10078 let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);
10079 assert_eq!(
10080 cap.context_window,
10081 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
10082 );
10083 assert_eq!(cap.max_output, Some(384_000));
10084 assert!(cap.thinking_supported);
10085 assert!(!cap.cache_telemetry_supported);
10086 }
10087
10088 #[test]
10089 fn provider_capability_siliconflow_v4_pro_has_thinking_no_cache() {
10090 let cap = provider_capability(ApiProvider::Siliconflow, DEFAULT_SILICONFLOW_MODEL);
10091 assert_eq!(
10092 cap.context_window,
10093 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
10094 );
10095 assert_eq!(cap.max_output, Some(384_000));
10096 assert!(cap.thinking_supported);
10097 assert!(!cap.cache_telemetry_supported);
10098 assert_eq!(
10099 cap.request_payload_mode,
10100 RequestPayloadMode::ChatCompletions
10101 );
10102 }
10103
10104 #[test]
10105 fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {
10106 let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);
10107 assert_eq!(
10108 cap.context_window,
10109 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
10110 );
10111 assert_eq!(cap.max_output, Some(384_000));
10112 assert!(cap.thinking_supported);
10113 assert!(!cap.cache_telemetry_supported);
10114 }
10115
10116 #[test]
10117 fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {
10118 let cap = provider_capability(ApiProvider::Openai, "glm-5");
10119 assert_eq!(
10120 cap.context_window,
10121 crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
10122 );
10123 assert_eq!(cap.max_output, None);
10124 assert!(!cap.thinking_supported);
10125 assert!(!cap.cache_telemetry_supported);
10126 assert_eq!(
10127 cap.request_payload_mode,
10128 RequestPayloadMode::ChatCompletions
10129 );
10130 }
10131
10132 #[test]
10133 fn provider_capability_atlascloud_v4_model_resolves_model_metadata() {
10134 // #3023: Atlascloud uses the generic model-based path, so its default
10135 // DeepSeek V4 model resolves the real V4 metadata instead of the old
10136 // hardcoded legacy floor.
10137 let cap = provider_capability(ApiProvider::Atlascloud, "deepseek-ai/deepseek-v4-flash");
10138 assert_eq!(
10139 cap.context_window,
10140 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
10141 );
10142 assert_eq!(cap.max_output, Some(384_000));
10143 assert!(cap.thinking_supported);
10144 assert!(!cap.cache_telemetry_supported);
10145 assert_eq!(
10146 cap.request_payload_mode,
10147 RequestPayloadMode::ChatCompletions
10148 );
10149 }
10150
10151 #[test]
10152 fn provider_capability_moonshot_default_model_resolves_kimi_metadata() {
10153 let cap = provider_capability(ApiProvider::Moonshot, DEFAULT_MOONSHOT_MODEL);
10154 assert_eq!(cap.context_window, 262_144);
10155 assert_eq!(cap.max_output, Some(32_768));
10156 assert!(cap.thinking_supported);
10157 assert!(!cap.cache_telemetry_supported);
10158 assert_eq!(
10159 cap.request_payload_mode,
10160 RequestPayloadMode::ChatCompletions
10161 );
10162 }
10163
10164 #[test]
10165 fn provider_capability_kimi_membership_ids_report_unknown_output_ceiling() {
10166 // The `kimi-for-coding` family is membership-only: the membership catalog
10167 // owns its output limits, so the static matrix must say "unknown" rather
10168 // than fabricating a ceiling. A placeholder here is not cosmetic — it
10169 // becomes a hard request clamp in `route_budget`.
10170 for model in ["kimi-for-coding", "kimi-for-coding-highspeed"] {
10171 let cap = provider_capability(ApiProvider::Moonshot, model);
10172 assert_eq!(cap.context_window, 262_144, "{model}");
10173 assert_eq!(cap.max_output, None, "{model}");
10174 assert!(cap.thinking_supported, "{model}");
10175
10176 // Unknown is *omitted* on the wire, never serialized as a number.
10177 let json = serde_json::to_value(&cap).expect("capability serializes");
10178 assert!(
10179 json.get("max_output").is_none(),
10180 "{model}: unknown output ceiling must not be serialized: {json}"
10181 );
10182 let round_tripped: ProviderCapability =
10183 serde_json::from_value(json).expect("capability round-trips with an absent max_output");
10184 assert_eq!(round_tripped, cap, "{model}");
10185 }
10186
10187 // The direct-platform K2.7 Code route does publish 32K, and keeps it.
10188 assert_eq!(
10189 provider_capability(ApiProvider::Moonshot, "kimi-k2.7-code").max_output,
10190 Some(32_768)
10191 );
10192 }
10193
10194 #[test]
10195 fn provider_capability_zai_defaults_to_5_2_and_tracks_5_1_and_turbo() {
10196 // GLM-5.2 is now the default direct Z.AI model (1M context window).
10197 let default = provider_capability(ApiProvider::Zai, DEFAULT_ZAI_MODEL);
10198 assert_eq!(default.resolved_model, DEFAULT_ZAI_MODEL);
10199 assert_eq!(default.resolved_model, ZAI_GLM_5_2_MODEL);
10200 assert_eq!(default.context_window, 1_000_000);
10201 assert_eq!(default.max_output, Some(131_072));
10202 assert!(default.thinking_supported);
10203 assert!(!default.cache_telemetry_supported);
10204
10205 // GLM-5.1 remains available as an explicit model (smaller window).
10206 let v51 = provider_capability(ApiProvider::Zai, ZAI_GLM_5_1_MODEL);
10207 assert_eq!(v51.resolved_model, ZAI_GLM_5_1_MODEL);
10208 assert_eq!(v51.context_window, 202_752);
10209 assert_eq!(v51.max_output, Some(131_072));
10210 assert!(v51.thinking_supported);
10211
10212 // GLM-5-Turbo is the faster sub-agent sibling.
10213 let turbo = provider_capability(ApiProvider::Zai, ZAI_GLM_5_TURBO_MODEL);
10214 assert_eq!(turbo.resolved_model, ZAI_GLM_5_TURBO_MODEL);
10215 }
10216
10217 #[test]
10218 fn provider_capability_minimax_direct_models_use_api_docs_shape() {
10219 let m3 = provider_capability(ApiProvider::Minimax, DEFAULT_MINIMAX_MODEL);
10220 assert_eq!(m3.context_window, 1_000_000);
10221 assert_eq!(m3.max_output, Some(524_288));
10222 assert!(m3.thinking_supported);
10223 assert!(!m3.cache_telemetry_supported);
10224 assert_eq!(m3.request_payload_mode, RequestPayloadMode::ChatCompletions);
10225
10226 for model in [
10227 MINIMAX_M2_7_MODEL,
10228 MINIMAX_M2_7_HIGHSPEED_MODEL,
10229 MINIMAX_M2_5_MODEL,
10230 MINIMAX_M2_5_HIGHSPEED_MODEL,
10231 MINIMAX_M2_1_MODEL,
10232 MINIMAX_M2_1_HIGHSPEED_MODEL,
10233 MINIMAX_M2_MODEL,
10234 ] {
10235 let cap = provider_capability(ApiProvider::Minimax, model);
10236 assert_eq!(cap.context_window, 204_800, "{model}");
10237 assert!(cap.thinking_supported, "{model}");
10238 assert!(!cap.cache_telemetry_supported, "{model}");
10239 assert_eq!(
10240 cap.request_payload_mode,
10241 RequestPayloadMode::ChatCompletions
10242 );
10243 }
10244 }
10245
10246 #[test]
10247 fn provider_capability_minimax_anthropic_uses_messages_shape() {
10248 for model in [DEFAULT_MINIMAX_MODEL, MINIMAX_M2_7_MODEL] {
10249 let cap = provider_capability(ApiProvider::MinimaxAnthropic, model);
10250 assert!(cap.thinking_supported, "{model}");
10251 assert!(!cap.cache_telemetry_supported, "{model}");
10252 assert_eq!(
10253 cap.request_payload_mode,
10254 RequestPayloadMode::AnthropicMessages
10255 );
10256 }
10257 }
10258
10259 #[test]
10260 fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {
10261 let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);
10262 assert_eq!(
10263 cap.context_window,
10264 crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
10265 );
10266 assert_eq!(cap.max_output, None);
10267 assert!(cap.thinking_supported);
10268 assert!(!cap.cache_telemetry_supported);
10269 assert_eq!(
10270 cap.request_payload_mode,
10271 RequestPayloadMode::ChatCompletions
10272 );
10273 }
10274
10275 #[test]
10276 fn provider_capability_ollama_deepseek_tag_uses_deepseek_heuristic() {
10277 // #3023: known model families resolve through models.rs lookups even
10278 // on Ollama — a legacy DeepSeek tag gets the 128K heuristic window.
10279 let cap = provider_capability(ApiProvider::Ollama, "deepseek-v3.1:671b");
10280 assert_eq!(
10281 cap.context_window,
10282 crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
10283 );
10284 assert_eq!(cap.max_output, None);
10285 assert!(!cap.thinking_supported);
10286 assert!(!cap.cache_telemetry_supported);
10287 assert_eq!(
10288 cap.request_payload_mode,
10289 RequestPayloadMode::ChatCompletions
10290 );
10291 }
10292
10293 #[test]
10294 fn provider_capability_ollama_unknown_model_falls_back_to_8192() {
10295 let cap = provider_capability(ApiProvider::Ollama, "llama3.2:3b");
10296 assert_eq!(cap.context_window, 8192);
10297 assert_eq!(cap.max_output, None);
10298 assert!(!cap.thinking_supported);
10299 assert!(!cap.cache_telemetry_supported);
10300 assert_eq!(
10301 cap.request_payload_mode,
10302 RequestPayloadMode::ChatCompletions
10303 );
10304 }
10305
10306 #[test]
10307 fn provider_capability_non_v4_model_has_smaller_window() {
10308 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-coder");
10309 assert_eq!(
10310 cap.context_window,
10311 crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
10312 );
10313 assert_eq!(cap.max_output, None);
10314 assert!(!cap.thinking_supported);
10315 }
10316
10317 #[test]
10318 fn provider_capability_roundtrip_serialization() {
10319 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-pro");
10320 let json = serde_json::to_value(&cap).unwrap();
10321 let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();
10322 assert_eq!(cap, deserialized);
10323 }
10324
10325 #[test]
10326 fn status_item_balance_available_only_for_deepseek_providers() {
10327 // Balance item should only be offered for DeepSeek / DeepSeekCN.
10328 assert!(StatusItem::Balance.is_available_for(ApiProvider::Deepseek));
10329 assert!(StatusItem::Balance.is_available_for(ApiProvider::DeepseekCN));
10330 // Sanity: all other known providers should hide the Balance toggle.
10331 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Openrouter));
10332 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Novita));
10333 assert!(!StatusItem::Balance.is_available_for(ApiProvider::NvidiaNim));
10334 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Fireworks));
10335 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Sglang));
10336 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Vllm));
10337 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Ollama));
10338 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Openai));
10339 assert!(!StatusItem::Balance.is_available_for(ApiProvider::Atlascloud));
10340 // Other StatusItem variants should be available everywhere.
10341 assert!(StatusItem::Mode.is_available_for(ApiProvider::Ollama));
10342 }
10343
10344 #[test]
10345 fn status_items_deser_ignores_unknown_variants() {
10346 // Simulate a stable build reading config written by a dev build that
10347 // knows about items the stable build doesn't (e.g. "balance" or a
10348 // future "cost_saving" chip).
10349 let toml_str = r#"
10350 alternate_screen = "auto"
10351 status_items = ["mode", "model", "unknown_future_item", "cost", "another_unknown", "status"]
10352 "#;
10353 let tui: TuiConfig = toml::from_str(toml_str).expect("should parse without error");
10354 let items = tui.status_items.expect("status_items should be Some");
10355 assert_eq!(items.len(), 4, "unknown items should be silently dropped");
10356 assert_eq!(items[0], StatusItem::Mode);
10357 assert_eq!(items[1], StatusItem::Model);
10358 assert_eq!(items[2], StatusItem::Cost);
10359 assert_eq!(items[3], StatusItem::Status);
10360 }
10361
10362 #[test]
10363 fn status_items_deser_allows_missing_field() {
10364 let toml_str = r#"
10365 locale = "zh-Hans"
10366 mouse_capture = false
10367 "#;
10368 let tui: TuiConfig = toml::from_str(toml_str).expect("missing status_items should parse");
10369 assert_eq!(tui.status_items, None);
10370 }
10371
10372 #[test]
10373 fn huggingface_provider_aliases_parse() {
10374 for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] {
10375 assert_eq!(ApiProvider::parse(alias), Some(ApiProvider::Huggingface));
10376 }
10377 }
10378
10379 #[test]
10380 fn invalid_provider_error_lists_huggingface() {
10381 let config = Config {
10382 provider: Some("not-a-provider".to_string()),
10383 ..Default::default()
10384 };
10385 let err = config.validate().expect_err("unknown provider should fail");
10386 let message = err.to_string();
10387 assert!(message.contains("Invalid provider 'not-a-provider'"));
10388 assert!(message.contains("huggingface"));
10389 }
10390
10391 #[test]
10392 fn huggingface_provider_uses_direct_defaults() -> Result<()> {
10393 let _lock = lock_test_env();
10394 let nanos = SystemTime::now()
10395 .duration_since(UNIX_EPOCH)
10396 .unwrap()
10397 .as_nanos();
10398 let temp_root = env::temp_dir().join(format!(
10399 "codewhale-tui-huggingface-defaults-test-{}-{}",
10400 std::process::id(),
10401 nanos
10402 ));
10403 fs::create_dir_all(&temp_root)?;
10404 let _guard = EnvGuard::new(&temp_root);
10405
10406 unsafe {
10407 env::set_var("CODEWHALE_PROVIDER", "huggingface");
10408 env::set_var("HUGGINGFACE_API_KEY", "hf-env-key");
10409 }
10410
10411 let config = Config::load(None, None)?;
10412 assert_eq!(config.api_provider(), ApiProvider::Huggingface);
10413 assert_eq!(config.deepseek_api_key()?, "hf-env-key");
10414 assert_eq!(config.deepseek_base_url(), DEFAULT_HUGGINGFACE_BASE_URL);
10415 assert_eq!(config.default_model(), DEFAULT_HUGGINGFACE_MODEL);
10416 Ok(())
10417 }
10418
10419 #[test]
10420 fn huggingface_hf_token_env_api_key_resolves() -> Result<()> {
10421 let _lock = lock_test_env();
10422 let nanos = SystemTime::now()
10423 .duration_since(UNIX_EPOCH)
10424 .unwrap()
10425 .as_nanos();
10426 let temp_root = env::temp_dir().join(format!(
10427 "codewhale-tui-huggingface-hf-token-test-{}-{}",
10428 std::process::id(),
10429 nanos
10430 ));
10431 fs::create_dir_all(&temp_root)?;
10432 let _guard = EnvGuard::new(&temp_root);
10433
10434 unsafe {
10435 env::set_var("CODEWHALE_PROVIDER", "huggingface");
10436 env::set_var("HF_TOKEN", "hf-token-value");
10437 }
10438
10439 let config = Config::load(None, None)?;
10440 assert_eq!(config.api_provider(), ApiProvider::Huggingface);
10441 assert_eq!(config.deepseek_api_key()?, "hf-token-value");
10442 Ok(())
10443 }
10444
10445 #[test]
10446 fn huggingface_missing_key_error_mentions_env_fallbacks() -> Result<()> {
10447 let _lock = lock_test_env();
10448 let nanos = SystemTime::now()
10449 .duration_since(UNIX_EPOCH)
10450 .unwrap()
10451 .as_nanos();
10452 let temp_root = env::temp_dir().join(format!(
10453 "codewhale-tui-huggingface-missing-key-test-{}-{}",
10454 std::process::id(),
10455 nanos
10456 ));
10457 fs::create_dir_all(&temp_root)?;
10458 let _guard = EnvGuard::new(&temp_root);
10459
10460 let config = Config {
10461 provider: Some("huggingface".to_string()),
10462 ..Default::default()
10463 };
10464
10465 config.validate()?;
10466 let err = config.deepseek_api_key().expect_err("missing key");
10467 let message = err.to_string();
10468 assert!(message.contains("Hugging Face API key not found"));
10469 assert!(message.contains("https://huggingface.co/settings/tokens"));
10470 assert!(message.contains("HUGGINGFACE_API_KEY"));
10471 assert!(message.contains("HF_TOKEN"));
10472 Ok(())
10473 }
10474
10475 #[test]
10476 fn huggingface_custom_env_urls_do_not_inherit_ambient_keys() -> Result<()> {
10477 let _lock = lock_test_env();
10478 let nanos = SystemTime::now()
10479 .duration_since(UNIX_EPOCH)
10480 .unwrap()
10481 .as_nanos();
10482 let temp_root = env::temp_dir().join(format!(
10483 "codewhale-tui-huggingface-env-test-{}-{}",
10484 std::process::id(),
10485 nanos
10486 ));
10487
10488 {
10489 let long_form_root = temp_root.join("long-form");
10490 fs::create_dir_all(&long_form_root)?;
10491 let _guard = EnvGuard::new(&long_form_root);
10492
10493 unsafe {
10494 env::set_var("CODEWHALE_PROVIDER", "huggingface");
10495 env::set_var("HUGGINGFACE_API_KEY", "hf-env-key");
10496 env::set_var("HF_TOKEN", "hf-token-fallback");
10497 env::set_var("HUGGINGFACE_BASE_URL", "https://custom-hf.example/v1");
10498 env::set_var("HF_BASE_URL", "https://fallback-hf.example/v1");
10499 env::set_var("HUGGINGFACE_MODEL", "meta-llama/Llama-3-70B");
10500 env::set_var("HF_MODEL", "fallback/model");
10501 }
10502
10503 let config = Config::load(None, None)?;
10504 assert_eq!(config.api_provider(), ApiProvider::Huggingface);
10505 let error = config
10506 .deepseek_api_key()
10507 .expect_err("ambient key must not follow a custom endpoint");
10508 assert!(error.to_string().contains("must be bound explicitly"));
10509 assert!(!has_api_key(&config));
10510 assert_eq!(config.deepseek_base_url(), "https://custom-hf.example/v1");
10511 assert_eq!(config.default_model(), "meta-llama/Llama-3-70B");
10512 }
10513
10514 {
10515 let short_form_root = temp_root.join("short-form");
10516 fs::create_dir_all(&short_form_root)?;
10517 let _guard = EnvGuard::new(&short_form_root);
10518
10519 unsafe {
10520 env::set_var("CODEWHALE_PROVIDER", "huggingface");
10521 env::set_var("HF_TOKEN", "hf-env-key");
10522 env::set_var("HF_BASE_URL", "https://custom-hf.example/v1");
10523 env::set_var("HF_MODEL", "meta-llama/Llama-3-70B");
10524 }
10525
10526 let config = Config::load(None, None)?;
10527 assert_eq!(config.api_provider(), ApiProvider::Huggingface);
10528 let error = config
10529 .deepseek_api_key()
10530 .expect_err("ambient key must not follow a custom endpoint");
10531 assert!(error.to_string().contains("must be bound explicitly"));
10532 assert!(!has_api_key(&config));
10533 assert_eq!(config.deepseek_base_url(), "https://custom-hf.example/v1");
10534 assert_eq!(config.default_model(), "meta-llama/Llama-3-70B");
10535 }
10536 Ok(())
10537 }
10538
10539 #[test]
10540 fn notifications_parse_custom_completion_sound_file() {
10541 let config: Config = toml::from_str(
10542 r#"
10543 [notifications]
10544 completion_sound = "file"
10545 sound_file = "E:\\google\\downloads\\xm4114.wav"
10546 "#,
10547 )
10548 .expect("custom completion sound config should parse");
10549
10550 let notifications = config.notifications_config();
10551 assert_eq!(notifications.completion_sound, CompletionSound::File);
10552 assert_eq!(
10553 notifications.sound_file.as_deref(),
10554 Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav"))
10555 );
10556 }
10557
10558 #[test]
10559 fn notifications_parse_event_sound_table() {
10560 let config: Config = toml::from_str(
10561 r#"
10562 [notifications.event_sound]
10563 enabled = true
10564 events = ["turn-complete", "bogus-event", "approval-needed"]
10565 min_interval_ms = 500
10566 quiet = true
10567 "#,
10568 )
10569 .expect("event sound config should parse");
10570
10571 let notifications = config.notifications_config();
10572 assert_eq!(
10573 notifications.event_sound,
10574 EventSoundConfig {
10575 enabled: true,
10576 events: vec![
10577 "turn-complete".to_string(),
10578 "bogus-event".to_string(),
10579 "approval-needed".to_string(),
10580 ],
10581 min_interval_ms: 500,
10582 quiet: true,
10583 }
10584 );
10585 }
10586
10587 #[test]
10588 fn notifications_event_sound_defaults_when_table_absent() {
10589 let config: Config = toml::from_str("[notifications]\nmethod = \"off\"\n")
10590 .expect("bare notifications table should parse");
10591
10592 let event_sound = config.notifications_config().event_sound;
10593 assert_eq!(event_sound, EventSoundConfig::default());
10594 assert!(!event_sound.enabled);
10595 assert_eq!(
10596 event_sound.events,
10597 vec!["turn-complete".to_string(), "approval-needed".to_string()]
10598 );
10599 assert_eq!(event_sound.min_interval_ms, 2000);
10600 assert!(!event_sound.quiet);
10601 }
10602
10603 #[test]
10604 fn notifications_parse_quiet_and_event_categories() {
10605 let config: Config = toml::from_str(
10606 r#"
10607 [notifications]
10608 quiet = true
10609
10610 [notifications.events]
10611 approval-needed = false
10612 model-notify = false
10613 "#,
10614 )
10615 .expect("quiet + events config should parse");
10616
10617 let notifications = config.notifications_config();
10618 assert!(notifications.quiet);
10619 let events = notifications.events;
10620 assert!(!events.approval_needed);
10621 assert!(!events.model_notify);
10622 // Unlisted categories keep their enabled default.
10623 assert!(events.turn_complete);
10624 assert!(events.subagent_terminal);
10625 assert!(events.input_needed);
10626 assert!(events.elevation_needed);
10627 }
10628
10629 #[test]
10630 fn notifications_quiet_and_events_default_off_and_all_enabled() {
10631 let config: Config = toml::from_str("[notifications]\nmethod = \"auto\"\n")
10632 .expect("bare notifications table should parse");
10633
10634 let notifications = config.notifications_config();
10635 assert!(!notifications.quiet);
10636 assert_eq!(notifications.events, NotificationEventsConfig::default());
10637 assert!(notifications.events.turn_complete);
10638 assert!(notifications.events.model_notify);
10639 }
10640
10641 #[test]
10642 fn huggingface_short_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
10643 let _lock = lock_test_env();
10644 let nanos = SystemTime::now()
10645 .duration_since(UNIX_EPOCH)
10646 .unwrap()
10647 .as_nanos();
10648 let temp_root = env::temp_dir().join(format!(
10649 "codewhale-tui-huggingface-short-env-test-{}-{}",
10650 std::process::id(),
10651 nanos
10652 ));
10653 fs::create_dir_all(&temp_root)?;
10654 let _guard = EnvGuard::new(&temp_root);
10655
10656 unsafe {
10657 env::set_var("CODEWHALE_PROVIDER", "hf");
10658 env::set_var("HF_TOKEN", "hf-token-value");
10659 env::set_var("HF_BASE_URL", "https://short-hf.example/v1");
10660 env::set_var("HF_MODEL", "org/short-model");
10661 }
10662
10663 let config = Config::load(None, None)?;
10664 assert_eq!(config.api_provider(), ApiProvider::Huggingface);
10665 let error = config
10666 .deepseek_api_key()
10667 .expect_err("ambient key must not follow a custom endpoint");
10668 assert!(error.to_string().contains("must be bound explicitly"));
10669 assert!(!has_api_key(&config));
10670 assert_eq!(config.deepseek_base_url(), "https://short-hf.example/v1");
10671 assert_eq!(config.default_model(), "org/short-model");
10672 Ok(())
10673 }
10674
10675 // === #1519 custom OpenAI-compatible provider slice ===
10676
10677 #[test]
10678 fn custom_provider_flatten_map_parses_alongside_named_provider() {
10679 // A custom `[providers.my_thing]` table lands in the flatten map while a
10680 // built-in `[providers.openai]` table still binds its named field.
10681 let config: Config = toml::from_str(
10682 r#"
10683 provider = "my_thing"
10684
10685 [providers.openai]
10686 api_key = "openai-key"
10687
10688 [providers.my_thing]
10689 kind = "openai-compatible"
10690 base_url = "https://api.example.com/v1"
10691 model = "custom-model-v1"
10692 api_key_env = "EXAMPLE_API_KEY"
10693 "#,
10694 )
10695 .expect("config with a custom provider table should parse");
10696
10697 let providers = config.providers.as_ref().expect("providers table present");
10698 // Built-in named field still works.
10699 assert_eq!(providers.openai.api_key.as_deref(), Some("openai-key"));
10700 // The custom entry is captured by name in the flatten map.
10701 let custom = providers
10702 .custom_provider_config("my_thing")
10703 .expect("custom entry parsed into flatten map");
10704 assert_eq!(custom.kind.as_deref(), Some("openai-compatible"));
10705 assert_eq!(
10706 custom.base_url.as_deref(),
10707 Some("https://api.example.com/v1")
10708 );
10709 assert_eq!(custom.model.as_deref(), Some("custom-model-v1"));
10710 assert_eq!(custom.api_key_env.as_deref(), Some("EXAMPLE_API_KEY"));
10711 assert!(custom.is_openai_compatible_custom());
10712 // A built-in provider name never leaks into the custom map.
10713 assert!(providers.custom_provider_config("openai").is_none());
10714 }
10715
10716 #[test]
10717 fn api_provider_returns_custom_for_custom_name_and_deepseek_for_junk() {
10718 // Names a real custom table → Custom (the #1519 silent-misroute fix).
10719 let mut custom = HashMap::new();
10720 custom.insert(
10721 "my_thing".to_string(),
10722 ProviderConfig {
10723 kind: Some("openai-compatible".to_string()),
10724 base_url: Some("https://api.example.com/v1".to_string()),
10725 ..Default::default()
10726 },
10727 );
10728 let config = Config {
10729 provider: Some("my_thing".to_string()),
10730 providers: Some(ProvidersConfig {
10731 custom,
10732 ..Default::default()
10733 }),
10734 ..Config::default()
10735 };
10736 assert_eq!(config.api_provider(), ApiProvider::Custom);
10737 config
10738 .validate()
10739 .expect("named custom providers should pass config validation");
10740
10741 // Genuine junk that matches no built-in provider AND no custom table →
10742 // falls back to DeepSeek, exactly as before this slice.
10743 let junk = Config {
10744 provider: Some("totally-not-a-provider".to_string()),
10745 ..Config::default()
10746 };
10747 assert_eq!(junk.api_provider(), ApiProvider::Deepseek);
10748 assert!(
10749 junk.validate().is_err(),
10750 "invalid provider names should still fail validation"
10751 );
10752 }
10753
10754 #[test]
10755 fn custom_provider_kind_only_accepts_openai_compatible() {
10756 let ok = ProviderConfig {
10757 kind: Some("openai-compatible".to_string()),
10758 ..Default::default()
10759 };
10760 assert!(ok.is_openai_compatible_custom());
10761
10762 // Underscore spelling and case are tolerated.
10763 let underscore = ProviderConfig {
10764 kind: Some("OpenAI_Compatible".to_string()),
10765 ..Default::default()
10766 };
10767 assert!(underscore.is_openai_compatible_custom());
10768
10769 // Any other declared wire format is rejected (callers error on these).
10770 let other = ProviderConfig {
10771 kind: Some("anthropic-messages".to_string()),
10772 ..Default::default()
10773 };
10774 assert!(!other.is_openai_compatible_custom());
10775
10776 // Built-in providers leave `kind` unset.
10777 assert!(!ProviderConfig::default().is_openai_compatible_custom());
10778 }
10779
10780 #[test]
10781 fn custom_provider_base_url_and_model_resolve_from_named_table() {
10782 let mut custom = HashMap::new();
10783 custom.insert(
10784 "my_thing".to_string(),
10785 ProviderConfig {
10786 kind: Some("openai-compatible".to_string()),
10787 base_url: Some("https://api.example.com/v1".to_string()),
10788 model: Some("custom-model-v1".to_string()),
10789 ..Default::default()
10790 },
10791 );
10792 let config = Config {
10793 provider: Some("my_thing".to_string()),
10794 providers: Some(ProvidersConfig {
10795 custom,
10796 ..Default::default()
10797 }),
10798 ..Config::default()
10799 };
10800
10801 // Resolution reads the named table, not a DeepSeek default.
10802 assert_eq!(config.api_provider(), ApiProvider::Custom);
10803 assert_eq!(config.deepseek_base_url(), "https://api.example.com/v1");
10804 assert_eq!(config.default_model(), "custom-model-v1");
10805 }
10806
10807 fn session_custom_provider_config(name: &str, kind: &str, base_url: &str) -> Config {
10808 let mut custom = HashMap::new();
10809 custom.insert(
10810 name.to_string(),
10811 ProviderConfig {
10812 kind: Some(kind.to_string()),
10813 base_url: Some(base_url.to_string()),
10814 model: Some("local-model".to_string()),
10815 ..Default::default()
10816 },
10817 );
10818 Config {
10819 provider: Some(name.to_string()),
10820 providers: Some(ProvidersConfig {
10821 custom,
10822 ..Default::default()
10823 }),
10824 ..Config::default()
10825 }
10826 }
10827
10828 #[test]
10829 fn session_provider_identity_preserves_exact_named_custom_key() {
10830 let config = session_custom_provider_config(
10831 "lm-studio",
10832 "openai-compatible",
10833 "http://127.0.0.1:1234/v1",
10834 );
10835
10836 assert_eq!(
10837 config.provider_identity_for(ApiProvider::Custom),
10838 "lm-studio"
10839 );
10840 assert_eq!(
10841 config
10842 .resolve_provider_identity("lm-studio")
10843 .expect("exact custom identity"),
10844 ProviderIdentity {
10845 provider: ApiProvider::Custom,
10846 key: "lm-studio".to_string(),
10847 exact_id: Some("lm-studio".to_string()),
10848 }
10849 );
10850 assert_eq!(
10851 config
10852 .resolve_provider_identity("openrouter")
10853 .expect("built-in identity"),
10854 ProviderIdentity {
10855 provider: ApiProvider::Openrouter,
10856 key: "openrouter".to_string(),
10857 exact_id: Some("openrouter".to_string()),
10858 }
10859 );
10860 let migrated = config
10861 .resolve_provider_identity("custom")
10862 .expect("released generic custom record migrates to sole live named route");
10863 assert_eq!(
10864 migrated,
10865 ProviderIdentity {
10866 provider: ApiProvider::Custom,
10867 key: "lm-studio".to_string(),
10868 exact_id: Some("lm-studio".to_string()),
10869 }
10870 );
10871 }
10872
10873 #[test]
10874 fn literal_custom_table_round_trips_as_exact_historical_route() {
10875 let config =
10876 session_custom_provider_config("custom", "openai-compatible", "http://127.0.0.1:1234/v1");
10877
10878 assert_eq!(config.api_provider(), ApiProvider::Custom);
10879 assert!(!config.uses_legacy_literal_custom_route());
10880 let identity = config
10881 .resolve_provider_identity("custom")
10882 .expect("exact [providers.custom] identity");
10883 assert_eq!(identity.key, "custom");
10884 let route = crate::route_runtime::resolve_runtime_route(
10885 &config,
10886 identity.provider,
10887 Some("local-model"),
10888 )
10889 .expect("resolve exact literal table")
10890 .validate()
10891 .expect("preflight exact literal table");
10892 assert_eq!(route.identity.key, "custom");
10893 assert_eq!(route.client.base_url(), "http://127.0.0.1:1234/v1");
10894 assert_eq!(
10895 route
10896 .config
10897 .resolve_provider_identity(&route.identity.key)
10898 .expect("repeat exact literal table resolution"),
10899 identity
10900 );
10901 }
10902
10903 #[test]
10904 fn persisted_custom_fields_distinguish_legacy_root_from_exact_literal_table() {
10905 let table_only =
10906 session_custom_provider_config("custom", "openai-compatible", "http://127.0.0.1:1234/v1");
10907 let table_only_error = table_only
10908 .resolve_persisted_provider_identity(Some("custom"), None)
10909 .expect_err("id-less custom records authorize only the legacy root route");
10910 assert!(
10911 table_only_error.contains("root-level"),
10912 "{table_only_error}"
10913 );
10914 assert!(table_only_error.contains("fall back"), "{table_only_error}");
10915
10916 let mut coexist = table_only.clone();
10917 coexist.base_url = Some("http://127.0.0.1:18180/v1".to_string());
10918 coexist.default_text_model = Some("legacy-root-model".to_string());
10919 let root = coexist
10920 .resolve_persisted_provider_identity(Some("custom"), None)
10921 .expect("id-less record remains bound to the root route");
10922 assert_eq!(root.provider, ApiProvider::Custom);
10923 assert_eq!(root.key, "custom");
10924 assert_eq!(root.exact_id, None);
10925 let root_route = crate::route_runtime::resolve_runtime_route_for_identity(
10926 &coexist,
10927 &root,
10928 Some("legacy-root-model"),
10929 )
10930 .expect("scope root identity")
10931 .validate()
10932 .expect("validate root identity");
10933 assert_eq!(root_route.client.base_url(), "http://127.0.0.1:18180/v1");
10934 assert_eq!(root_route.identity.exact_id, None);
10935
10936 let exact_table = coexist
10937 .resolve_persisted_provider_identity(Some("custom"), Some("custom"))
10938 .expect("additive exact id intentionally selects the table");
10939 assert_eq!(exact_table.provider, ApiProvider::Custom);
10940 assert_eq!(exact_table.key, "custom");
10941 assert_eq!(exact_table.exact_id.as_deref(), Some("custom"));
10942
10943 let root_only = Config {
10944 provider: Some("custom".to_string()),
10945 base_url: Some("http://127.0.0.1:18180/v1".to_string()),
10946 default_text_model: Some("legacy-root-model".to_string()),
10947 ..Config::default()
10948 };
10949 let exact_error = root_only
10950 .resolve_persisted_provider_identity(Some("custom"), Some("custom"))
10951 .expect_err("exact table record cannot fall back to a legacy root route");
10952 assert!(exact_error.contains("[providers.custom]"), "{exact_error}");
10953 assert!(exact_error.contains("will not fall back"), "{exact_error}");
10954 let exact_route_error = crate::route_runtime::resolve_runtime_route_for_identity(
10955 &root_only,
10956 &exact_table,
10957 Some("table-model"),
10958 )
10959 .expect_err("runtime route must revalidate exact table provenance");
10960 assert!(
10961 exact_route_error.contains("[providers.custom]"),
10962 "{exact_route_error}"
10963 );
10964 }
10965
10966 #[test]
10967 fn persisted_empty_custom_id_never_falls_back_to_legacy_root() {
10968 let mut config =
10969 session_custom_provider_config("custom", "openai-compatible", "http://127.0.0.1:18181/v1");
10970 config.base_url = Some("http://127.0.0.1:18180/v1".to_string());
10971 config.default_text_model = Some("legacy-root-model".to_string());
10972
10973 for malformed_id in ["", " "] {
10974 let error = config
10975 .resolve_persisted_provider_identity(Some("custom"), Some(malformed_id))
10976 .expect_err("an explicit empty exact id must never authorize the root route");
10977 assert!(error.contains("empty exact provider id"), "{error}");
10978 assert!(error.contains("will not guess or fall back"), "{error}");
10979 }
10980
10981 let root = config
10982 .resolve_persisted_provider_identity(Some("custom"), None)
10983 .expect("a genuinely missing id retains legacy root compatibility");
10984 assert_eq!(root.exact_id, None);
10985 let exact = config
10986 .resolve_persisted_provider_identity(Some("custom"), Some("custom"))
10987 .expect("a non-empty exact id selects the literal table");
10988 assert_eq!(exact.exact_id.as_deref(), Some("custom"));
10989 }
10990
10991 #[test]
10992 fn persisted_provider_pair_never_collapses_builtin_into_same_key_custom_route() {
10993 let config =
10994 session_custom_provider_config("openai", "openai-compatible", "http://127.0.0.1:1234/v1");
10995 assert_eq!(
10996 config
10997 .resolve_provider_identity("openai")
10998 .expect("raw exact identity intentionally prefers custom"),
10999 ProviderIdentity {
11000 provider: ApiProvider::Custom,
11001 key: "openai".to_string(),
11002 exact_id: Some("openai".to_string()),
11003 }
11004 );
11005
11006 for provider_id in [None, Some("openai")] {
11007 let error = config
11008 .resolve_persisted_provider_identity(Some("openai"), provider_id)
11009 .expect_err("built-in record must not be captured by the custom table");
11010 assert!(error.contains("requires built-in 'openai'"), "{error}");
11011 assert!(error.contains("shadows"), "{error}");
11012 assert!(error.contains("will not guess or fall back"), "{error}");
11013 }
11014
11015 let exact_custom = config
11016 .resolve_persisted_provider_identity(Some("custom"), Some("openai"))
11017 .expect("custom kind plus exact id intentionally selects the table");
11018 assert_eq!(exact_custom.provider, ApiProvider::Custom);
11019 assert_eq!(exact_custom.key, "openai");
11020
11021 let mismatch = config
11022 .resolve_persisted_provider_identity(Some("openrouter"), Some("openai"))
11023 .expect_err("mismatched built-in kind/id pair must fail closed");
11024 assert!(mismatch.contains("mismatched fields"), "{mismatch}");
11025 }
11026
11027 #[test]
11028 fn case_colliding_custom_table_preserves_exact_spelling_across_receipts() {
11029 let config =
11030 session_custom_provider_config("CUSTOM", "openai-compatible", "http://127.0.0.1:5678/v1");
11031
11032 assert_eq!(config.api_provider(), ApiProvider::Custom);
11033 assert_eq!(config.provider_identity_for(ApiProvider::Custom), "CUSTOM");
11034 let identity = config
11035 .resolve_provider_identity("CUSTOM")
11036 .expect("exact case-colliding custom identity");
11037 assert_eq!(identity.key, "CUSTOM");
11038 let route = crate::route_runtime::resolve_runtime_route(
11039 &config,
11040 identity.provider,
11041 Some("local-model"),
11042 )
11043 .expect("resolve case-colliding custom table")
11044 .validate()
11045 .expect("preflight case-colliding custom table");
11046 assert_eq!(route.identity.key, "CUSTOM");
11047 assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
11048 }
11049
11050 #[test]
11051 fn legacy_literal_custom_identity_requires_one_valid_root_route() {
11052 let _lock = lock_test_env();
11053 let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
11054 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
11055 let legacy = Config {
11056 provider: Some("custom".to_string()),
11057 api_key: Some("legacy-root-key".to_string()),
11058 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
11059 default_text_model: Some("local-legacy-model".to_string()),
11060 ..Config::default()
11061 };
11062
11063 assert_eq!(
11064 legacy
11065 .resolve_provider_identity("custom")
11066 .expect("unchanged legacy root route"),
11067 ProviderIdentity {
11068 provider: ApiProvider::Custom,
11069 key: "custom".to_string(),
11070 exact_id: None,
11071 }
11072 );
11073 assert_eq!(legacy.deepseek_base_url(), "http://127.0.0.1:1234/v1");
11074 assert_eq!(legacy.default_model(), "local-legacy-model");
11075 assert_eq!(legacy.deepseek_api_key().unwrap(), "legacy-root-key");
11076
11077 let mut named = session_custom_provider_config(
11078 "lm-studio",
11079 "openai-compatible",
11080 "https://api.example.com/v1",
11081 );
11082 named.api_key = Some("must-not-leak-to-named-route".to_string());
11083 let named_key_error = named
11084 .deepseek_api_key()
11085 .expect_err("root legacy key must never authorize a named custom route")
11086 .to_string();
11087 assert!(named_key_error.contains("lm-studio"), "{named_key_error}");
11088 assert!(!named_key_error.contains("must-not-leak"));
11089
11090 let mut ambiguous_named = named.clone();
11091 ambiguous_named
11092 .providers
11093 .as_mut()
11094 .expect("providers")
11095 .custom
11096 .insert(
11097 "vllm-local".to_string(),
11098 ProviderConfig {
11099 kind: Some("openai-compatible".to_string()),
11100 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
11101 model: Some("other-local-model".to_string()),
11102 ..ProviderConfig::default()
11103 },
11104 );
11105 let ambiguous_named_error = ambiguous_named
11106 .resolve_provider_identity("custom")
11107 .expect_err("generic released record cannot choose between named routes");
11108 assert!(
11109 ambiguous_named_error.contains("valid named routes: 2"),
11110 "{ambiguous_named_error}"
11111 );
11112 assert!(ambiguous_named_error.contains("will not guess or fall back"));
11113
11114 let mut missing_model = legacy.clone();
11115 missing_model.default_text_model = None;
11116 let model_error = missing_model
11117 .resolve_provider_identity("custom")
11118 .expect_err("legacy root route needs an explicit model");
11119 assert!(model_error.contains("default_text_model"), "{model_error}");
11120
11121 let mut auto_model = legacy.clone();
11122 auto_model.default_text_model = Some("auto".to_string());
11123 let auto_error = auto_model
11124 .resolve_provider_identity("custom")
11125 .expect_err("legacy root route cannot guess an auto model");
11126 assert!(auto_error.contains("not `auto`"), "{auto_error}");
11127
11128 let mut invalid_url = legacy.clone();
11129 invalid_url.base_url = Some("not a provider URL".to_string());
11130 let url_error = invalid_url
11131 .resolve_provider_identity("custom")
11132 .expect_err("legacy root route needs a valid endpoint");
11133 assert!(url_error.contains("base_url"), "{url_error}");
11134 assert!(url_error.contains("will not fall back"), "{url_error}");
11135
11136 let mut ambiguous = legacy.clone();
11137 ambiguous.providers = Some(ProvidersConfig {
11138 custom: HashMap::from([(
11139 "CUSTOM".to_string(),
11140 ProviderConfig {
11141 kind: Some("openai-compatible".to_string()),
11142 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
11143 model: Some("table-model".to_string()),
11144 ..ProviderConfig::default()
11145 },
11146 )]),
11147 ..ProvidersConfig::default()
11148 });
11149 let ambiguous_error = ambiguous
11150 .resolve_provider_identity("custom")
11151 .expect_err("root and table routes cannot share the generic identity");
11152 assert!(
11153 ambiguous_error.contains("[providers.custom]") && ambiguous_error.contains("ambiguous"),
11154 "{ambiguous_error}"
11155 );
11156
11157 let removed_named = legacy
11158 .resolve_provider_identity("lm-studio")
11159 .expect_err("a removed named route must not fall back to legacy custom");
11160 assert!(removed_named.contains("[providers.lm-studio]"));
11161 assert!(removed_named.contains("will not fall back"));
11162 }
11163
11164 #[test]
11165 fn legacy_literal_custom_env_overrides_preserve_root_route_shape() -> Result<()> {
11166 let _lock = lock_test_env();
11167 let nanos = SystemTime::now()
11168 .duration_since(UNIX_EPOCH)
11169 .unwrap()
11170 .as_nanos();
11171 let temp_root = env::temp_dir().join(format!(
11172 "codewhale-tui-legacy-custom-env-{}-{}",
11173 std::process::id(),
11174 nanos
11175 ));
11176 fs::create_dir_all(&temp_root)?;
11177 let _guard = EnvGuard::new(&temp_root);
11178 let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
11179 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
11180
11181 let config_path = temp_root.join(".deepseek").join("config.toml");
11182 ensure_parent_dir(&config_path)?;
11183 fs::write(
11184 &config_path,
11185 r#"provider = "custom"
11186 api_key = "legacy-root-key"
11187 base_url = "http://127.0.0.1:18184/v1"
11188 default_text_model = "legacy-model"
11189 "#,
11190 )?;
11191 // Safety: test-only env mutation guarded by lock_test_env().
11192 unsafe {
11193 env::set_var("CODEWHALE_BASE_URL", "http://127.0.0.1:18185/v1");
11194 env::set_var("CODEWHALE_MODEL", "env-legacy-model");
11195 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Legacy-Route=kept");
11196 }
11197
11198 let config = Config::load(None, None)?;
11199
11200 assert!(config.uses_legacy_literal_custom_route());
11201 assert!(
11202 config
11203 .providers
11204 .as_ref()
11205 .is_none_or(|providers| !providers.custom.contains_key("custom"))
11206 );
11207 assert_eq!(config.deepseek_base_url(), "http://127.0.0.1:18185/v1");
11208 assert_eq!(config.default_model(), "env-legacy-model");
11209 assert_eq!(
11210 config.deepseek_api_key()?,
11211 "",
11212 "an env-selected keyless loopback route must not inherit the file-owned root key"
11213 );
11214 assert!(!active_provider_has_config_api_key(&config));
11215 assert_eq!(
11216 config
11217 .http_headers()
11218 .get("X-Legacy-Route")
11219 .map(String::as_str),
11220 Some("kept")
11221 );
11222 for _ in 0..2 {
11223 assert_eq!(
11224 config
11225 .resolve_provider_identity("custom")
11226 .expect("legacy route remains repeatedly resolvable")
11227 .key,
11228 "custom"
11229 );
11230 }
11231 Ok(())
11232 }
11233
11234 #[test]
11235 fn session_provider_identity_fails_closed_for_removed_or_invalid_custom_table() {
11236 let removed = Config::default();
11237 let missing = removed
11238 .resolve_provider_identity("lm-studio")
11239 .expect_err("removed provider must fail closed");
11240 assert!(missing.contains("[providers.lm-studio]"));
11241 assert!(missing.contains("will not fall back"));
11242
11243 let invalid_kind = session_custom_provider_config(
11244 "lm-studio",
11245 "anthropic-messages",
11246 "http://127.0.0.1:1234/v1",
11247 );
11248 let kind_error = invalid_kind
11249 .resolve_provider_identity("lm-studio")
11250 .expect_err("unsupported custom wire kind must fail closed");
11251 assert!(kind_error.contains("kind = \"openai-compatible\""));
11252
11253 let invalid_url =
11254 session_custom_provider_config("lm-studio", "openai-compatible", "not a provider URL");
11255 let url_error = invalid_url
11256 .resolve_provider_identity("lm-studio")
11257 .expect_err("invalid custom URL must fail closed");
11258 assert!(url_error.contains("base_url"));
11259 assert!(url_error.contains("will not fall back"));
11260 }
11261
11262 #[test]
11263 fn picker_consent_persists_only_confirmed_exact_scope_and_revoke_is_one_step() {
11264 let dir = tempfile::TempDir::new().expect("tempdir");
11265 let config_path = dir.path().join("config.toml");
11266 let external_path = dir.path().join("codex-auth.json");
11267 std::fs::write(
11268 &config_path,
11269 "# preserve operator comment\n[providers.openai_codex]\nmodel = \"gpt-5-codex\" # preserve model\n",
11270 )
11271 .expect("seed config");
11272 let mut live = Config {
11273 provider: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
11274 ..Config::default()
11275 };
11276
11277 crate::external_credentials::reset_side_effect_trap();
11278 persist_external_credential_consent_for_at(
11279 Some(&config_path),
11280 &mut live,
11281 ApiProvider::OpenaiCodex,
11282 codewhale_config::ProviderKind::OpenaiCodex,
11283 codewhale_config::ExternalCredentialSource::CodexCli,
11284 &external_path,
11285 )
11286 .expect("persist confirmed consent");
11287 let saved = std::fs::read_to_string(&config_path).expect("saved config");
11288 assert!(saved.contains("# preserve operator comment"));
11289 assert!(saved.contains("model = \"gpt-5-codex\" # preserve model"));
11290 assert!(saved.contains("access = \"read_only\""));
11291 assert!(saved.contains("source = \"codex_cli\""));
11292 assert!(saved.contains(&external_path.display().to_string()));
11293 let consent = live
11294 .provider_config_for(ApiProvider::OpenaiCodex)
11295 .and_then(|entry| entry.external_credentials.as_ref())
11296 .expect("live consent");
11297 assert_eq!(consent.path, external_path);
11298 assert_eq!(
11299 crate::external_credentials::complete_side_effect_trap_counts(),
11300 (0, 0, 0, 0, 0),
11301 "grant persistence must not inspect the disclosed external path"
11302 );
11303
11304 revoke_external_credential_consent_for_at(
11305 Some(&config_path),
11306 &mut live,
11307 ApiProvider::OpenaiCodex,
11308 )
11309 .expect("one-step revoke");
11310 let revoked = std::fs::read_to_string(&config_path).expect("revoked config");
11311 assert!(!revoked.contains("external_credentials"));
11312 assert!(
11313 live.provider_config_for(ApiProvider::OpenaiCodex)
11314 .and_then(|entry| entry.external_credentials.as_ref())
11315 .is_none()
11316 );
11317 assert_eq!(
11318 crate::external_credentials::complete_side_effect_trap_counts(),
11319 (0, 0, 0, 0, 0)
11320 );
11321 }
11322
11323 /// Every provider must accept the model ids it advertises for itself.
11324 ///
11325 /// Regression for #4829: `validate()` checked `default_text_model` against the
11326 /// DeepSeek-only normalizer, so a config our own setup wizard writes
11327 /// (`provider = "zai"`, `default_text_model = "GLM-5.2"`) was rejected on every
11328 /// startup — the CLI could not launch at all. This asserts the equal-treatment
11329 /// contract from CLAUDE.md: no provider's own models are second-class.
11330 #[test]
11331 fn validate_accepts_every_providers_own_advertised_models() {
11332 for &provider in ApiProvider::all() {
11333 for model in model_completion_names_for_provider(provider) {
11334 let config = Config {
11335 provider: Some(provider.as_str().to_string()),
11336 default_text_model: Some(model.to_string()),
11337 ..Default::default()
11338 };
11339 assert!(
11340 config.validate().is_ok(),
11341 "provider {} rejected its own advertised model {model}: {:?}",
11342 provider.as_str(),
11343 config.validate().unwrap_err().to_string(),
11344 );
11345 }
11346 }
11347 }
11348
11349 /// The exact config that bricked the CLI in the field.
11350 #[test]
11351 fn validate_accepts_zai_glm_model_from_setup_wizard() {
11352 let config = Config {
11353 provider: Some("zai".to_string()),
11354 default_text_model: Some(DEFAULT_ZAI_MODEL.to_string()),
11355 ..Default::default()
11356 };
11357 config
11358 .validate()
11359 .expect("setup-wizard zai/GLM config must validate");
11360 }
11361
11362 /// The official DeepSeek gate is the one legitimate per-family rejection and
11363 /// must survive the fix — this is what keeps the validation meaningful.
11364 #[test]
11365 fn validate_still_rejects_unknown_model_on_official_deepseek() {
11366 let config = Config {
11367 provider: Some("deepseek".to_string()),
11368 default_text_model: Some("definitely-not-a-deepseek-model".to_string()),
11369 ..Default::default()
11370 };
11371 let err = config
11372 .validate()
11373 .expect_err("official DeepSeek must reject foreign ids")
11374 .to_string();
11375 assert!(
11376 err.contains("definitely-not-a-deepseek-model") && err.contains("deepseek"),
11377 "error should name the model and the active provider, got: {err}"
11378 );
11379 }
11380
11381 #[test]
11382 fn native_memory_backend_owns_explicit_path() {
11383 let tmp = tempfile::tempdir().unwrap();
11384 let legacy = tmp.path().join("legacy-memory.md");
11385 let config = Config {
11386 memory_path: Some(legacy.to_string_lossy().into_owned()),
11387 memory: Some(MemoryConfig {
11388 backend: Some(MemoryBackend::Native),
11389 ..Default::default()
11390 }),
11391 ..Default::default()
11392 };
11393 assert_eq!(config.memory_backend(), MemoryBackend::Native);
11394 assert!(config.memory_enabled());
11395 assert_eq!(
11396 config.memory_path(),
11397 tmp.path().join("memory/global/MEMORY.md")
11398 );
11399 }
11400
11401 /// Pins the v0.9.4 memory consolidation: with `[memory] enabled = true`
11402 /// (no explicit backend), the resolved memory path is always the native
11403 /// `memory/global/MEMORY.md` layout, so `NativeMemoryStore::from_global_path`
11404 /// accepts it and the deleted legacy single-file branch can never be taken.
11405 #[test]
11406 fn enabled_memory_always_resolves_to_native_store_path() {
11407 let tmp = tempfile::tempdir().unwrap();
11408 let config: Config = toml::from_str(
11409 r#"
11410 [memory]
11411 enabled = true
11412 "#,
11413 )
11414 .expect("parse enabled memory config");
11415 assert_eq!(config.memory_backend(), MemoryBackend::Native);
11416 let path = config.memory_path();
11417 assert!(
11418 path.ends_with("memory/global/MEMORY.md"),
11419 "enabled memory must resolve to the native layout, got {}",
11420 path.display()
11421 );
11422 assert!(
11423 crate::native_memory::NativeMemoryStore::from_global_path(&path).is_some(),
11424 "native store must accept the resolved memory path"
11425 );
11426
11427 // Even an explicitly configured single-file path is re-rooted into the
11428 // native layout under the native backend.
11429 let custom = Config {
11430 memory_path: Some(tmp.path().join("memory.md").to_string_lossy().into_owned()),
11431 memory: Some(MemoryConfig {
11432 enabled: Some(true),
11433 ..Default::default()
11434 }),
11435 ..Default::default()
11436 };
11437 let custom_path = custom.memory_path();
11438 assert!(
11439 crate::native_memory::NativeMemoryStore::from_global_path(&custom_path).is_some(),
11440 "custom memory paths are re-rooted into the native layout, got {}",
11441 custom_path.display()
11442 );
11443 }
11444
11445 /// v0.9.1 kimi-k3 dogfood report: a dogfood user ran `codewhale --provider moonshot --model kimi-k3`
11446 /// and the session kept reporting `kimi-k2.7-code`. The `--model` flag reaches
11447 /// this binary as `CODEWHALE_MODEL`, so the route it produces is asserted here
11448 /// end to end: the effective model, the endpoint, and the id that goes on the
11449 /// wire must all be the one the user named.
11450 #[test]
11451 fn cli_model_flag_selects_kimi_k3_on_the_moonshot_platform_route() -> Result<()> {
11452 let _lock = lock_test_env();
11453 let nanos = SystemTime::now()
11454 .duration_since(UNIX_EPOCH)
11455 .unwrap()
11456 .as_nanos();
11457 let temp_root = env::temp_dir().join(format!(
11458 "codewhale-tui-kimi-k3-cli-{}-{nanos}",
11459 std::process::id()
11460 ));
11461 fs::create_dir_all(&temp_root)?;
11462 let _guard = EnvGuard::new(&temp_root);
11463
11464 // `EnvGuard` points DEEPSEEK_CONFIG_PATH at `<home>/.deepseek/config.toml`.
11465 let config_path = temp_root.join(".deepseek").join("config.toml");
11466 ensure_parent_dir(&config_path)?;
11467 fs::write(
11468 &config_path,
11469 "provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\n",
11470 )?;
11471 // Safety: test-only env mutation guarded by lock_test_env().
11472 unsafe {
11473 env::set_var("CODEWHALE_PROVIDER", "moonshot");
11474 env::set_var("CODEWHALE_MODEL", MOONSHOT_KIMI_K3_MODEL);
11475 }
11476
11477 let config = Config::load(None, None)?;
11478
11479 assert_eq!(config.api_provider(), ApiProvider::Moonshot);
11480 assert_eq!(config.default_model(), MOONSHOT_KIMI_K3_MODEL);
11481 assert_eq!(config.deepseek_base_url(), DEFAULT_MOONSHOT_BASE_URL);
11482 assert_eq!(
11483 wire_model_for_provider_route(
11484 ApiProvider::Moonshot,
11485 &config.deepseek_base_url(),
11486 &config.default_model(),
11487 ),
11488 MOONSHOT_KIMI_K3_MODEL,
11489 "the id the user named must be the id on the wire"
11490 );
11491 assert_eq!(
11492 explicit_launch_model_override().as_deref(),
11493 Some(MOONSHOT_KIMI_K3_MODEL),
11494 "an explicit --model must remain recognizable as an explicit request"
11495 );
11496 assert_eq!(
11497 moonshot_k3_route_display_name(&config.deepseek_base_url(), &config.default_model()),
11498 Some("Moonshot direct / kimi-k3")
11499 );
11500 Ok(())
11501 }
11502
11503 // === Identity-owned endpoint resolution (provider-truth regressions) ===
11504 //
11505 // Every test here is offline and env-locked. No credential is invented and no
11506 // provider is contacted: the assertions are about which host string a route
11507 // resolves to, and about the classifications derived from it.
11508
11509 /// A managed-config guard pointing at a path that does not exist, so an
11510 /// operator-installed managed file on the developer's machine cannot leak into
11511 /// these route assertions.
11512 fn no_managed_config(root: &std::path::Path) -> EnvVarGuard {
11513 EnvVarGuard::set(
11514 "DEEPSEEK_MANAGED_CONFIG_PATH",
11515 root.join("absent-managed.toml"),
11516 )
11517 }
11518
11519 fn custom_placeholder_base_url() -> String {
11520 normalize_base_url(
11521 codewhale_config::ProviderKind::Custom
11522 .provider()
11523 .default_base_url(),
11524 )
11525 }
11526
11527 #[test]
11528 fn env_owned_deepseek_root_base_url_does_not_reach_the_deepseek_cn_sibling() -> Result<()> {
11529 let _lock = lock_test_env();
11530 let temp_root = tempfile::tempdir()?;
11531 let _guard = EnvGuard::new(temp_root.path());
11532 let _managed = no_managed_config(temp_root.path());
11533 let config_path = temp_root.path().join("config.toml");
11534 fs::write(&config_path, "provider = \"deepseek\"\n")?;
11535 let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
11536
11537 let config = Config::load(Some(config_path), None)?;
11538
11539 // The env override owns the route it was addressed to.
11540 assert_eq!(config.api_provider(), ApiProvider::Deepseek);
11541 assert_eq!(
11542 config.deepseek_base_url(),
11543 "https://env-gateway.example.test/v1"
11544 );
11545 assert!(config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
11546
11547 // The sibling identity shares the same legacy root field but is a
11548 // different route: it must fall through to its own canonical endpoint.
11549 assert_eq!(
11550 config.base_url_for_route(ApiProvider::DeepseekCN),
11551 DEFAULT_DEEPSEEKCN_BASE_URL
11552 );
11553 assert!(!config.provider_uses_custom_endpoint(ApiProvider::DeepseekCN));
11554 assert!(!config.model_ids_pass_through_for_provider(ApiProvider::DeepseekCN));
11555 Ok(())
11556 }
11557
11558 #[test]
11559 fn env_owned_deepseek_cn_root_base_url_does_not_reach_the_deepseek_sibling() -> Result<()> {
11560 let _lock = lock_test_env();
11561 let temp_root = tempfile::tempdir()?;
11562 let _guard = EnvGuard::new(temp_root.path());
11563 let _managed = no_managed_config(temp_root.path());
11564 let config_path = temp_root.path().join("config.toml");
11565 fs::write(&config_path, "provider = \"deepseek-cn\"\n")?;
11566 let _base = EnvVarGuard::set(
11567 "DEEPSEEK_BASE_URL",
11568 "https://cn-env-gateway.example.test/v1",
11569 );
11570
11571 let config = Config::load(Some(config_path), None)?;
11572
11573 assert_eq!(config.api_provider(), ApiProvider::DeepseekCN);
11574 assert_eq!(
11575 config.deepseek_base_url(),
11576 "https://cn-env-gateway.example.test/v1"
11577 );
11578 assert!(config.provider_uses_custom_endpoint(ApiProvider::DeepseekCN));
11579
11580 assert_eq!(
11581 config.base_url_for_route(ApiProvider::Deepseek),
11582 DEFAULT_DEEPSEEK_BASE_URL
11583 );
11584 assert!(!config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
11585 Ok(())
11586 }
11587
11588 #[test]
11589 fn file_owned_legacy_root_base_url_stays_shared_by_both_deepseek_identities() -> Result<()> {
11590 let _lock = lock_test_env();
11591 let temp_root = tempfile::tempdir()?;
11592 let _guard = EnvGuard::new(temp_root.path());
11593 let _managed = no_managed_config(temp_root.path());
11594 let config_path = temp_root.path().join("config.toml");
11595 fs::write(
11596 &config_path,
11597 "provider = \"deepseek\"\nbase_url = \"https://file-gateway.example.test/v1\"\n",
11598 )?;
11599
11600 let config = Config::load(Some(config_path), None)?;
11601
11602 // No environment write, so the root field is the user's own. Both
11603 // identities keep reading it, exactly as they always have.
11604 for provider in [ApiProvider::Deepseek, ApiProvider::DeepseekCN] {
11605 assert_eq!(
11606 config.base_url_for_route(provider),
11607 "https://file-gateway.example.test/v1",
11608 "{provider:?} must keep the file-owned legacy root endpoint"
11609 );
11610 assert!(config.provider_uses_custom_endpoint(provider));
11611 }
11612 Ok(())
11613 }
11614
11615 #[test]
11616 fn managed_overlay_keeps_pinned_children_off_the_ambient_generic_host() -> Result<()> {
11617 let _lock = lock_test_env();
11618 let temp_root = tempfile::tempdir()?;
11619 let _guard = EnvGuard::new(temp_root.path());
11620 let config_path = temp_root.path().join("config.toml");
11621 let managed_path = temp_root.path().join("managed.toml");
11622 fs::write(&config_path, "provider = \"deepseek\"\n")?;
11623 fs::write(
11624 &managed_path,
11625 "provider = \"openrouter\"\n\n[providers.openrouter]\nbase_url = \"https://managed-gateway.example.test/v1\"\n",
11626 )?;
11627 let _managed = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &managed_path);
11628 let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
11629
11630 let config = Config::load(Some(config_path), None)?;
11631
11632 // Managed routing is authoritative for the active route.
11633 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
11634 assert_eq!(
11635 config.deepseek_base_url(),
11636 "https://managed-gateway.example.test/v1"
11637 );
11638
11639 // The receipt must say "nobody owns the generic override" rather than
11640 // being cleared: a cleared receipt reads as "never met the environment
11641 // layer" and re-enables the generic fallback for every pinned child.
11642 assert_eq!(config.base_url_env_receipt, BaseUrlEnvReceipt::NoOwner);
11643 assert_eq!(config.root_base_url_owner, BaseUrlEnvReceipt::NoOwner);
11644 for provider in [
11645 ApiProvider::Moonshot,
11646 ApiProvider::Zai,
11647 ApiProvider::Minimax,
11648 ApiProvider::Deepseek,
11649 ApiProvider::DeepseekCN,
11650 ] {
11651 assert_eq!(
11652 config.base_url_for_route(provider),
11653 provider.default_base_url(),
11654 "{provider:?} must not borrow the ambient generic host under managed routing"
11655 );
11656 assert!(!config.provider_uses_custom_endpoint(provider));
11657 }
11658 Ok(())
11659 }
11660
11661 #[test]
11662 fn named_custom_children_resolve_by_identity_not_by_the_active_custom_route() -> Result<()> {
11663 let _lock = lock_test_env();
11664 let temp_root = tempfile::tempdir()?;
11665 let _guard = EnvGuard::new(temp_root.path());
11666 let _managed = no_managed_config(temp_root.path());
11667 let config_path = temp_root.path().join("config.toml");
11668 fs::write(
11669 &config_path,
11670 r#"provider = "acme"
11671
11672 [providers.acme]
11673 base_url = "https://acme.example.test/v1"
11674 model = "acme-1"
11675
11676 [providers.beta]
11677 base_url = "https://beta.example.test/v1"
11678 model = "beta-1"
11679 "#,
11680 )?;
11681
11682 let config = Config::load(Some(config_path), None)?;
11683
11684 assert_eq!(config.api_provider(), ApiProvider::Custom);
11685 assert_eq!(config.deepseek_base_url(), "https://acme.example.test/v1");
11686 // A pinned child of the other named custom table resolves its own host.
11687 assert_eq!(
11688 config.base_url_for_route_identity(ApiProvider::Custom, "beta"),
11689 "https://beta.example.test/v1"
11690 );
11691 assert!(config.custom_identity_is_resolvable("beta"));
11692 Ok(())
11693 }
11694
11695 #[test]
11696 fn missing_custom_identity_fails_closed_instead_of_reading_the_active_custom() -> Result<()> {
11697 let _lock = lock_test_env();
11698 let temp_root = tempfile::tempdir()?;
11699 let _guard = EnvGuard::new(temp_root.path());
11700 let _managed = no_managed_config(temp_root.path());
11701 let config_path = temp_root.path().join("config.toml");
11702 fs::write(
11703 &config_path,
11704 r#"provider = "acme"
11705
11706 [providers.acme]
11707 base_url = "https://acme.example.test/v1"
11708 model = "acme-1"
11709 "#,
11710 )?;
11711
11712 let config = Config::load(Some(config_path), None)?;
11713 let placeholder = custom_placeholder_base_url();
11714
11715 // A removed/renamed table, an empty identity, and the literal `custom`
11716 // key on a config that is not the legacy root-literal route all fail
11717 // closed to the descriptor placeholder — never to the active custom host.
11718 for identity in ["ghost", "", " ", "custom"] {
11719 let resolved = config.base_url_for_route_identity(ApiProvider::Custom, identity);
11720 assert_eq!(
11721 resolved, placeholder,
11722 "identity {identity:?} must not resolve to the active custom endpoint"
11723 );
11724 assert_ne!(resolved, "https://acme.example.test/v1");
11725 assert!(!config.custom_identity_is_resolvable(identity));
11726 }
11727 Ok(())
11728 }
11729
11730 #[test]
11731 fn legacy_literal_custom_root_endpoint_belongs_only_to_the_literal_identity() -> Result<()> {
11732 let _lock = lock_test_env();
11733 let temp_root = tempfile::tempdir()?;
11734 let _guard = EnvGuard::new(temp_root.path());
11735 let _managed = no_managed_config(temp_root.path());
11736 let config_path = temp_root.path().join("config.toml");
11737 fs::write(
11738 &config_path,
11739 r#"provider = "custom"
11740 base_url = "https://legacy-root.example.test/v1"
11741 default_text_model = "legacy-1"
11742 "#,
11743 )?;
11744
11745 let config = Config::load(Some(config_path), None)?;
11746
11747 assert!(config.uses_legacy_literal_custom_route());
11748 assert_eq!(
11749 config.base_url_for_route_identity(ApiProvider::Custom, "custom"),
11750 "https://legacy-root.example.test/v1"
11751 );
11752 // A differently named custom child must not inherit the legacy root.
11753 assert_eq!(
11754 config.base_url_for_route_identity(ApiProvider::Custom, "acme"),
11755 custom_placeholder_base_url()
11756 );
11757 Ok(())
11758 }
11759
11760 /// The bare `k3` id belongs to the Kimi Code coding-plan endpoint. A config
11761 /// that selects it there must resolve, and must be labelled as the membership
11762 /// product rather than the direct platform one (v0.9.1 kimi-k3 dogfood report).
11763 #[test]
11764 fn config_selects_bare_k3_on_the_kimi_code_route() {
11765 let config = Config {
11766 provider: Some("moonshot".to_string()),
11767 providers: Some(ProvidersConfig {
11768 moonshot: ProviderConfig {
11769 api_key: Some("k".to_string()),
11770 base_url: Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()),
11771 model: Some(KIMI_CODE_K3_MODEL.to_string()),
11772 ..ProviderConfig::default()
11773 },
11774 ..ProvidersConfig::default()
11775 }),
11776 ..Config::default()
11777 };
11778
11779 assert_eq!(config.default_model(), KIMI_CODE_K3_MODEL);
11780 assert_eq!(
11781 wire_model_for_provider_route(
11782 ApiProvider::Moonshot,
11783 &config.deepseek_base_url(),
11784 &config.default_model(),
11785 ),
11786 KIMI_CODE_K3_MODEL
11787 );
11788 assert_eq!(
11789 moonshot_k3_route_display_name(&config.deepseek_base_url(), &config.default_model()),
11790 Some("Kimi Code membership / k3")
11791 );
11792 }
11793
11794 /// Neither K3 id may be silently served by the other product's endpoint. The
11795 /// two are different plans with different context windows, so an unservable
11796 /// pairing has to fail loudly and name both routes (v0.9.1 kimi-k3 dogfood report).
11797 #[test]
11798 fn k3_and_kimi_k3_never_cross_products_and_fail_visibly() {
11799 let crossed = validate_kimi_code_api_model_id(
11800 ApiProvider::Moonshot,
11801 DEFAULT_KIMI_CODE_BASE_URL,
11802 MOONSHOT_KIMI_K3_MODEL,
11803 )
11804 .expect_err("kimi-k3 is not a Kimi Code model id");
11805 assert!(crossed.contains("api.kimi.com/coding/v1"), "{crossed}");
11806 assert!(crossed.contains("api.moonshot.ai/v1"), "{crossed}");
11807 assert!(crossed.contains(KIMI_CODE_K3_MODEL), "{crossed}");
11808
11809 let reversed = validate_kimi_code_api_model_id(
11810 ApiProvider::Moonshot,
11811 DEFAULT_MOONSHOT_BASE_URL,
11812 KIMI_CODE_K3_MODEL,
11813 )
11814 .expect_err("bare k3 is not a direct-platform model id");
11815 assert!(reversed.contains("api.moonshot.ai/v1"), "{reversed}");
11816 assert!(reversed.contains("api.kimi.com/coding/v1"), "{reversed}");
11817 assert!(reversed.contains(MOONSHOT_KIMI_K3_MODEL), "{reversed}");
11818
11819 // The exact-route predicates stay disjoint.
11820 assert!(is_exact_direct_moonshot_k3_route(
11821 ApiProvider::Moonshot,
11822 DEFAULT_MOONSHOT_BASE_URL,
11823 MOONSHOT_KIMI_K3_MODEL
11824 ));
11825 assert!(!is_exact_kimi_code_k3_route(
11826 ApiProvider::Moonshot,
11827 DEFAULT_MOONSHOT_BASE_URL,
11828 MOONSHOT_KIMI_K3_MODEL
11829 ));
11830 assert!(is_exact_kimi_code_k3_route(
11831 ApiProvider::Moonshot,
11832 DEFAULT_KIMI_CODE_BASE_URL,
11833 KIMI_CODE_K3_MODEL
11834 ));
11835 assert!(!is_exact_direct_moonshot_k3_route(
11836 ApiProvider::Moonshot,
11837 DEFAULT_KIMI_CODE_BASE_URL,
11838 KIMI_CODE_K3_MODEL
11839 ));
11840 }
11841
11842 #[test]
11843 fn unknown_models_pass_through_on_canonical_moonshot_endpoints() {
11844 for base_url in [DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_MOONSHOT_BASE_URL] {
11845 validate_kimi_code_api_model_id(ApiProvider::Moonshot, base_url, "future-kimi-model")
11846 .expect("unknown model IDs remain provider-owned");
11847 }
11848 }
11849
11850 #[test]
11851 fn dispatch_endpoint_and_billing_receipts_agree_for_every_resolved_route() -> Result<()> {
11852 let _lock = lock_test_env();
11853 let temp_root = tempfile::tempdir()?;
11854 let _guard = EnvGuard::new(temp_root.path());
11855 let _managed = no_managed_config(temp_root.path());
11856 let config_path = temp_root.path().join("config.toml");
11857 fs::write(&config_path, "provider = \"deepseek\"\n")?;
11858 let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
11859
11860 let config = Config::load(Some(config_path), None)?;
11861
11862 // `for_route` reads the ambient config; `for_dispatched_route` reads the
11863 // endpoint the client is actually built from. After the resolver became
11864 // identity-aware these must not be able to disagree for the active route.
11865 let provider = config.api_provider();
11866 let resolved = config.deepseek_base_url();
11867 assert_eq!(
11868 crate::route_billing::for_route(&config, provider),
11869 crate::route_billing::for_dispatched_route(
11870 &config,
11871 crate::route_billing::DispatchedRoute {
11872 provider,
11873 base_url: &resolved,
11874 },
11875 )
11876 );
11877
11878 // A pinned cross-provider child bills from its own resolved endpoint,
11879 // which is its canonical host — not the session's env-selected gateway.
11880 for child in [ApiProvider::DeepseekCN, ApiProvider::Moonshot] {
11881 let child_base = config.base_url_for_route(child);
11882 assert_eq!(child_base, child.default_base_url(), "{child:?}");
11883 assert_eq!(
11884 crate::route_billing::for_dispatched_route(
11885 &config,
11886 crate::route_billing::DispatchedRoute {
11887 provider: child,
11888 base_url: &child_base,
11889 },
11890 ),
11891 crate::route_billing::for_route(&config, child),
11892 "{child:?} ambient and dispatch billing receipts must agree"
11893 );
11894 }
11895 Ok(())
11896 }
11897
11898 #[test]
11899 fn readiness_and_inventory_classify_the_resolved_route_not_the_session_host() -> Result<()> {
11900 let _lock = lock_test_env();
11901 let temp_root = tempfile::tempdir()?;
11902 let _guard = EnvGuard::new(temp_root.path());
11903 let _managed = no_managed_config(temp_root.path());
11904 let config_path = temp_root.path().join("config.toml");
11905 fs::write(&config_path, "provider = \"deepseek\"\n")?;
11906 let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "http://127.0.0.1:11434/v1");
11907
11908 let config = Config::load(Some(config_path), None)?;
11909
11910 // Readiness: the active route is on a local custom host and classifies as
11911 // keyless-local; the sibling identity is still the canonical hosted
11912 // endpoint and must not inherit that classification.
11913 assert_eq!(
11914 crate::provider_readiness::credential_state_for_provider(&config, ApiProvider::Deepseek),
11915 crate::provider_readiness::CredentialState::Local
11916 );
11917 assert_ne!(
11918 crate::provider_readiness::credential_state_for_provider(&config, ApiProvider::DeepseekCN),
11919 crate::provider_readiness::CredentialState::Local
11920 );
11921
11922 // Inventory: the runtime route the picker/inventory reads is built by
11923 // re-pointing a clone of this config, so it must resolve the sibling's own
11924 // canonical endpoint.
11925 let route = crate::route_runtime::resolve_runtime_route(&config, ApiProvider::DeepseekCN, None)
11926 .expect("deepseek-cn runtime route");
11927 assert_eq!(
11928 route.candidate.endpoint().base_url,
11929 DEFAULT_DEEPSEEKCN_BASE_URL
11930 );
11931 assert_eq!(
11932 route.config.deepseek_base_url(),
11933 DEFAULT_DEEPSEEKCN_BASE_URL
11934 );
11935
11936 // And a canonical/default endpoint is never reported as custom.
11937 assert!(!config.provider_uses_custom_endpoint(ApiProvider::DeepseekCN));
11938 assert!(config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
11939 Ok(())
11940 }
11941
11942 #[test]
11943 fn configured_inactive_provider_reads_its_secret_store_key() -> Result<()> {
11944 let _lock = lock_test_env();
11945 let temp = tempfile::tempdir()?;
11946 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
11947 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
11948 let _moonshot = EnvVarGuard::remove("MOONSHOT_API_KEY");
11949 let _kimi = EnvVarGuard::remove("KIMI_API_KEY");
11950
11951 // The state guided setup leaves behind: auth_mode saved to config, the
11952 // key saved to the secret store only — and the operator then switches
11953 // the active provider away (#5033).
11954 let providers = ProvidersConfig {
11955 moonshot: ProviderConfig {
11956 auth_mode: Some("api_key".to_string()),
11957 ..Default::default()
11958 },
11959 ..Default::default()
11960 };
11961 let config = Config {
11962 provider: Some("deepseek".to_string()),
11963 providers: Some(providers),
11964 ..Default::default()
11965 };
11966
11967 assert!(
11968 !has_api_key_for(&config, ApiProvider::Moonshot),
11969 "no stored key yet: the configured provider must still read as unconfigured"
11970 );
11971
11972 codewhale_secrets::Secrets::auto_detect().set("moonshot", "kimi-test-credential")?;
11973 assert!(
11974 has_api_key_for(&config, ApiProvider::Moonshot),
11975 "a configured-but-inactive provider with a stored key must read as configured (#5033)"
11976 );
11977 Ok(())
11978 }
11979
11980 /// A self-hosted OpenAI-compatible gateway owns its model namespace, including
11981 /// its casing. A WeChat-community user configured `DeepSeek-V4-Flash` against
11982 /// their company's internal endpoint and reported the id coming back lowercase
11983 /// — an id that endpoint does not serve. Every stage from the parsed config to
11984 /// the resolved wire id must hand the string back byte-for-byte.
11985 #[test]
11986 fn custom_endpoint_model_id_survives_verbatim_through_the_route() {
11987 const MODEL: &str = "DeepSeek-V4-Flash";
11988 let shapes: [(&str, &str); 6] = [
11989 (
11990 "provider-scoped deepseek table with a custom base_url",
11991 r#"
11992 provider = "deepseek"
11993 [providers.deepseek]
11994 base_url = "https://llm.corp.internal/v1"
11995 api_key = "k"
11996 model = "DeepSeek-V4-Flash"
11997 "#,
11998 ),
11999 (
12000 "deepseek table with no explicit provider key",
12001 r#"
12002 [providers.deepseek]
12003 base_url = "https://llm.corp.internal/v1"
12004 api_key = "k"
12005 model = "DeepSeek-V4-Flash"
12006 "#,
12007 ),
12008 (
12009 "root base_url with the root default_text_model",
12010 r#"
12011 base_url = "https://llm.corp.internal/v1"
12012 api_key = "k"
12013 default_text_model = "DeepSeek-V4-Flash"
12014 "#,
12015 ),
12016 (
12017 "anthropic dialect on a custom deepseek endpoint",
12018 r#"
12019 provider = "deepseek"
12020 [providers.deepseek]
12021 base_url = "https://llm.corp.internal/anthropic"
12022 api_key = "k"
12023 model = "DeepSeek-V4-Flash"
12024 wire = "anthropic"
12025 "#,
12026 ),
12027 (
12028 "openai-compatible provider table",
12029 r#"
12030 provider = "openai"
12031 [providers.openai]
12032 base_url = "https://llm.corp.internal/v1"
12033 api_key = "k"
12034 model = "DeepSeek-V4-Flash"
12035 "#,
12036 ),
12037 (
12038 "literal custom provider on a root base_url",
12039 r#"
12040 provider = "custom"
12041 base_url = "https://llm.corp.internal/v1"
12042 api_key = "k"
12043 default_text_model = "DeepSeek-V4-Flash"
12044 "#,
12045 ),
12046 ];
12047
12048 for (label, body) in shapes {
12049 let mut config: Config = toml::from_str(body).expect("config parses");
12050 normalize_model_config(&mut config);
12051 let provider = config.api_provider();
12052 let base_url = config.base_url_for_route(provider);
12053 assert!(
12054 provider_preserves_custom_base_url_model(provider, &base_url),
12055 "{label}: {base_url} must classify as a custom endpoint"
12056 );
12057
12058 let stored = config
12059 .provider_config_for(provider)
12060 .and_then(|entry| entry.model.clone())
12061 .or_else(|| config.default_text_model.clone())
12062 .unwrap_or_default();
12063 assert_eq!(stored, MODEL, "{label}: config load rewrote the stored id");
12064
12065 let resolved = config.default_model();
12066 assert_eq!(resolved, MODEL, "{label}: default_model() rewrote the id");
12067
12068 assert_eq!(
12069 wire_model_for_provider_route(provider, &base_url, &resolved),
12070 MODEL,
12071 "{label}: the wire id must match the configured id byte-for-byte"
12072 );
12073
12074 let route = crate::route_runtime::resolve_runtime_route(&config, provider, Some(&resolved))
12075 .unwrap_or_else(|err| panic!("{label}: route resolution failed: {err}"));
12076 assert_eq!(
12077 route.model, MODEL,
12078 "{label}: the resolved route rewrote the id"
12079 );
12080 }
12081 }
12082
12083 /// Preserving the user's spelling must not turn alias resolution
12084 /// case-sensitive: normalize for comparison, never mutate what is stored or
12085 /// sent. A first-party/catalog route still canonicalizes to its documented id.
12086 #[test]
12087 fn model_alias_matching_stays_case_insensitive() {
12088 // Mixed-case aliases still resolve to the provider's documented wire id.
12089 assert_eq!(
12090 normalize_model_name_for_provider(ApiProvider::NvidiaNim, "DeepSeek-V4-Pro").as_deref(),
12091 Some(DEFAULT_NVIDIA_NIM_MODEL)
12092 );
12093 assert_eq!(
12094 normalize_model_name_for_provider(ApiProvider::Openrouter, "DeepSeek-V4-Flash").as_deref(),
12095 Some(DEFAULT_OPENROUTER_FLASH_MODEL)
12096 );
12097 assert_eq!(
12098 canonical_model_id_for_provider(ApiProvider::Zai, "GLM-5.1").as_deref(),
12099 canonical_model_id_for_provider(ApiProvider::Zai, "glm-5.1").as_deref()
12100 );
12101
12102 // A first-party DeepSeek route still migrates the retired aliases,
12103 // whatever case they are typed in.
12104 for alias in ["deepseek-chat", "DeepSeek-Chat", "DEEPSEEK-REASONER"] {
12105 assert_eq!(
12106 wire_model_for_provider_route(ApiProvider::Deepseek, "https://api.deepseek.com", alias),
12107 DEEPSEEK_ALIAS_REPLACEMENT,
12108 "first-party DeepSeek must canonicalize {alias}"
12109 );
12110 }
12111
12112 // The same alias on a custom endpoint keeps the user's exact spelling:
12113 // that endpoint owns both the id and its meaning.
12114 assert_eq!(
12115 wire_model_for_provider_route(
12116 ApiProvider::Deepseek,
12117 "https://llm.corp.internal/v1",
12118 "DeepSeek-Chat"
12119 ),
12120 "DeepSeek-Chat"
12121 );
12122 }
12123
12124 /// The remembered `/model` pick outranks `config.toml` on the next launch. It
12125 /// must not silently restyle the configured id: two spellings of one model are
12126 /// the config file's call, a genuinely different model stays the memory's.
12127 #[test]
12128 fn remembered_model_pick_defers_to_the_configured_spelling() {
12129 assert_eq!(
12130 prefer_configured_model_spelling("DeepSeek-V4-Flash", "deepseek-v4-flash".to_string()),
12131 "DeepSeek-V4-Flash",
12132 "a case-only disagreement belongs to config.toml"
12133 );
12134 assert_eq!(
12135 prefer_configured_model_spelling(" DeepSeek-V4-Flash ", "DEEPSEEK-V4-FLASH".to_string()),
12136 "DeepSeek-V4-Flash"
12137 );
12138 assert_eq!(
12139 prefer_configured_model_spelling("DeepSeek-V4-Flash", "deepseek-v4-pro".to_string()),
12140 "deepseek-v4-pro",
12141 "a different model is still a real remembered selection"
12142 );
12143 assert_eq!(
12144 prefer_configured_model_spelling("DeepSeek-V4-Flash", "auto".to_string()),
12145 "auto"
12146 );
12147 assert_eq!(
12148 prefer_configured_model_spelling("DeepSeek-V4-Flash", "DeepSeek-V4-Flash".to_string()),
12149 "DeepSeek-V4-Flash"
12150 );
12151 }
12152
12152 lines RUST