1use std::path::{Path, PathBuf};
21
22use crate::config::Config;
23use crate::vendor::{AuthKind, VendorId};
24
25pub struct Probes<'a> {
29 pub env_set: &'a dyn Fn(&str) -> bool,
31 pub exists: &'a dyn Fn(&Path) -> bool,
33 pub keychain_has_claude: &'a dyn Fn() -> bool,
38 pub keychain_has_claude_for: &'a dyn Fn(&Path) -> bool,
42 pub commandcode_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
47 pub opencode_go_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
52 pub cline_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
58pub struct VendorStatus {
59 pub id: &'static str,
61 pub name: &'static str,
63 pub short_name: &'static str,
64 pub kind: AuthKind,
65 pub enabled: bool,
67 pub configured: bool,
70 pub needs_credential: bool,
75 pub env: String,
78 pub login: &'static str,
81}
82
83pub fn statuses(cfg: &Config) -> Vec<VendorStatus> {
85 let probes = Probes {
86 env_set: &|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()),
87 exists: &|path| path.exists(),
88 keychain_has_claude: &keychain_has_claude,
89 keychain_has_claude_for: &keychain_has_claude_for,
90 commandcode_signed_in: &|paths| {
91 paths
92 .iter()
93 .any(|path| crate::commandcode::creds::read_from(path).is_some())
94 },
95 opencode_go_signed_in: &|paths| {
96 paths
97 .iter()
98 .any(|path| crate::opencode_go::creds::read_key_from(path).is_some())
99 },
100 cline_signed_in: &|paths| {
101 paths
102 .iter()
103 .any(|path| crate::cline::creds::read_from(path).is_some())
104 },
105 };
106 statuses_with(cfg, &probes)
107}
108
109pub fn statuses_with(cfg: &Config, probes: &Probes) -> Vec<VendorStatus> {
113 VendorId::all()
114 .iter()
115 .copied()
116 .map(|id| {
117 let needs_credential = id != VendorId::Antigravity;
119 VendorStatus {
120 id: id.slug(),
121 name: id.display_name(),
122 short_name: id.short_name(),
123 kind: id.auth_kind(),
124 enabled: cfg.is_enabled(id),
125 configured: !needs_credential || credential_present(cfg, id, probes),
126 needs_credential,
127 env: cfg.api_key_env_for(id).to_string(),
128 login: id.login_command(),
129 }
130 })
131 .collect()
132}
133
134fn credential_present(cfg: &Config, id: VendorId, probes: &Probes) -> bool {
139 let env = cfg.api_key_env_for(id);
140 if !env.is_empty() && (probes.env_set)(env) {
141 return true;
142 }
143 if cfg.inline_api_key(id).is_some() {
144 return true;
145 }
146 if cfg.api_key_accounts(id).is_some_and(|accounts| {
147 accounts.iter().any(|account| {
148 account
149 .api_key_env
150 .as_deref()
151 .filter(|name| !name.is_empty())
152 .is_some_and(|name| (probes.env_set)(name))
153 || account.api_key.as_deref().is_some_and(|k| !k.is_empty())
154 })
155 }) {
156 return true;
157 }
158 match id {
159 VendorId::Anthropic => {
163 let default_or_explicit = any_exists(probes, [anthropic_credentials_path(cfg)]);
164 let keychain =
165 cfg.anthropic.credentials_path.is_none() && (probes.keychain_has_claude)();
166 default_or_explicit
167 || keychain
168 || cfg.anthropic.all_accounts().iter().any(|account| {
169 (probes.exists)(&account.credentials_path)
170 || (probes.keychain_has_claude_for)(&account.config_dir())
171 })
172 }
173 VendorId::Openai => {
174 any_exists(probes, [cfg.openai.resolve_auth_path(None)])
175 || cfg
176 .openai
177 .accounts
178 .iter()
179 .any(|account| (probes.exists)(&account.codex_auth_path))
180 }
181 VendorId::Copilot => {
182 any_exists(probes, [crate::copilot::credentials::default_hosts_path()])
183 }
184 VendorId::CommandCode => {
185 match crate::commandcode::creds::effective_paths(cfg.commandcode.auth_paths.as_deref())
186 {
187 Ok(paths) => (probes.commandcode_signed_in)(&paths),
188 Err(_) => false,
189 }
190 }
191 VendorId::Cline => {
192 let paths = match cfg.cline.auth_paths.as_deref() {
193 Some(paths) if !paths.is_empty() => paths.to_vec(),
194 _ => crate::cline::creds::default_paths().unwrap_or_default(),
195 };
196 (probes.cline_signed_in)(&paths)
197 }
198 VendorId::OpenCodeGo => {
199 match crate::opencode_go::creds::effective_paths(cfg.opencode_go.auth_paths.as_deref())
200 {
201 Ok(paths) => (probes.opencode_go_signed_in)(&paths),
202 Err(_) => false,
203 }
204 }
205 VendorId::NousResearch => {
206 (probes.exists)(&crate::nous::credentials::default_credentials_path())
207 }
208 VendorId::Kimi => any_exists(probes, [kimi_credentials_path(cfg)]),
210 VendorId::Cursor => any_exists(
213 probes,
214 [
215 cfg.cursor
216 .db_path
217 .clone()
218 .map_or_else(crate::cursor::db::default_db_path, Ok),
219 cfg.cursor
220 .agent_auth_path
221 .clone()
222 .map_or_else(crate::cursor::db::default_agent_auth_path, Ok),
223 ],
224 ),
225 VendorId::Kiro => any_exists(
226 probes,
227 [cfg.kiro
228 .db_path
229 .clone()
230 .map_or_else(crate::kiro::db::default_db_path, Ok)],
231 ),
232 VendorId::Supergrok => (probes.exists)(&cfg.supergrok.grok_binary),
235 VendorId::Grokbot => any_exists(probes, [crate::grokbot::secrets_path(&cfg.grokbot)]),
237 VendorId::ModelStudio => {
239 any_exists(probes, [crate::modelstudio::config_path(&cfg.modelstudio)])
240 }
241 VendorId::Devin => {
242 any_exists(probes, [crate::devin::credentials_path(&cfg.devin)])
243 || cfg
244 .devin
245 .accounts
246 .iter()
247 .any(|a| (probes.exists)(&a.credentials_path))
248 }
249 VendorId::Droid => any_exists(probes, [crate::droid::keyring_path()]),
250 VendorId::MuseCode => any_exists(probes, [crate::muse::creds::path(&cfg.muse)]),
251 VendorId::Antigravity => true,
253 VendorId::AnthropicApi
256 | VendorId::Zai
257 | VendorId::Openrouter
258 | VendorId::Deepseek
259 | VendorId::Deepinfra
260 | VendorId::Kilo
261 | VendorId::Novita
262 | VendorId::Synthetic
263 | VendorId::Moonshot
264 | VendorId::Grok
265 | VendorId::Minimax
266 | VendorId::Ollama
267 | VendorId::OrcaRouter
268 | VendorId::Lyceum => false,
269 }
270}
271
272fn anthropic_credentials_path(cfg: &Config) -> crate::error::Result<PathBuf> {
273 match &cfg.anthropic.credentials_path {
274 Some(path) => Ok(path.clone()),
275 None => crate::anthropic::creds::default_path(),
276 }
277}
278
279fn kimi_credentials_path(cfg: &Config) -> crate::error::Result<PathBuf> {
280 match &cfg.kimi.credentials_path {
281 Some(path) => Ok(path.clone()),
282 None => Ok(crate::kimi::oauth::credentials_path_in(
283 &crate::cache::home_dir()?,
284 )),
285 }
286}
287
288fn any_exists<const N: usize>(probes: &Probes, paths: [crate::error::Result<PathBuf>; N]) -> bool {
292 paths
293 .iter()
294 .filter_map(|path| path.as_ref().ok())
295 .any(|path| (probes.exists)(path))
296}
297
298#[cfg(target_os = "macos")]
299fn keychain_has_claude() -> bool {
300 matches!(crate::anthropic::keychain::read_raw(), Ok(Some(_)))
301}
302
303#[cfg(not(target_os = "macos"))]
304fn keychain_has_claude() -> bool {
305 false
306}
307
308#[cfg(target_os = "macos")]
309fn keychain_has_claude_for(config_dir: &Path) -> bool {
310 matches!(
311 crate::anthropic::keychain::read_raw_for(config_dir),
312 Ok(Some(_))
313 )
314}
315
316#[cfg(not(target_os = "macos"))]
317fn keychain_has_claude_for(_config_dir: &Path) -> bool {
318 false
319}
320
321pub fn collect_json() -> Result<String, String> {
323 let cfg = Config::load().map_err(|error| error.user_message())?;
324 serde_json::to_string(&serde_json::json!({"vendors": statuses(&cfg)}))
325 .map_err(|error| error.to_string())
326}
327
328pub fn run(json: bool) -> i32 {
330 let cfg = match Config::load() {
331 Ok(cfg) => cfg,
332 Err(error) => {
333 eprintln!("vendors: {error}");
334 return 1;
335 }
336 };
337 let rows = statuses(&cfg);
338 if json {
339 match serde_json::to_string(&serde_json::json!({"vendors": rows})) {
340 Ok(text) => println!("{text}"),
341 Err(error) => {
342 eprintln!("vendors: {error}");
343 return 1;
344 }
345 }
346 return 0;
347 }
348 for row in rows {
349 let state = if !row.enabled {
350 "off"
351 } else if row.configured {
352 "ready"
353 } else {
354 "needs credential"
355 };
356 println!("{:<14} {:<10} {}", row.id, row.kind.as_str(), state);
357 }
358 0
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::tui::settings::KEY_VENDORS;
365
366 fn probes<'a>(
369 env: &'a dyn Fn(&str) -> bool,
370 exists: &'a dyn Fn(&Path) -> bool,
371 commandcode_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
372 ) -> Probes<'a> {
373 probes_full(env, exists, commandcode_signed_in, &|_| false)
374 }
375
376 fn probes_full<'a>(
379 env: &'a dyn Fn(&str) -> bool,
380 exists: &'a dyn Fn(&Path) -> bool,
381 commandcode_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
382 opencode_go_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
383 ) -> Probes<'a> {
384 Probes {
385 env_set: env,
386 exists,
387 keychain_has_claude: &|| false,
388 keychain_has_claude_for: &|_| false,
389 commandcode_signed_in,
390 opencode_go_signed_in,
391 cline_signed_in: &|_| false,
392 }
393 }
394
395 fn bare<'a>() -> Probes<'a> {
396 probes(&|_| false, &|_| false, &|_| false)
397 }
398
399 fn row(rows: &[VendorStatus], id: &str) -> VendorStatus {
400 rows.iter()
401 .find(|row| row.id == id)
402 .unwrap_or_else(|| panic!("{id} is missing from the catalog"))
403 .clone()
404 }
405
406 #[test]
411 fn every_provider_has_exactly_one_row_in_canonical_order() {
412 let rows = statuses_with(&Config::default(), &bare());
413 let ids: Vec<&str> = rows.iter().map(|row| row.id).collect();
414 let expected: Vec<&str> = VendorId::all().iter().map(|id| id.slug()).collect();
415 assert_eq!(ids, expected);
416 }
417
418 #[test]
419 fn a_key_vendor_is_configured_by_its_environment_variable() {
420 let cfg = Config::default();
421 let set = |name: &str| name == "ZAI_API_KEY";
422 let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
423 assert!(row(&rows, "zai").configured);
424 assert!(!row(&rows, "deepseek").configured);
425 }
426
427 #[test]
428 fn an_api_key_env_override_is_the_variable_both_reported_and_read() {
429 let mut cfg = Config::default();
430 cfg.zai.api_key_env = "WORK_ZAI_KEY".to_string();
431 let set = |name: &str| name == "WORK_ZAI_KEY";
432 let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
433 let zai = row(&rows, "zai");
434 assert_eq!(
435 zai.env, "WORK_ZAI_KEY",
436 "the row names the effective variable"
437 );
438 assert!(zai.configured, "and is satisfied by it, not by the default");
439
440 let stale = |name: &str| name == "ZAI_API_KEY";
442 let rows = statuses_with(&cfg, &probes(&stale, &|_| false, &|_| false));
443 assert!(!row(&rows, "zai").configured);
444 }
445
446 #[test]
450 fn a_cline_api_key_env_override_is_the_variable_both_reported_and_read() {
451 let mut cfg = Config::default();
452 cfg.cline.api_key_env = "WORK_CLINE_KEY".to_string();
453 let set = |name: &str| name == "WORK_CLINE_KEY";
454 let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
455 let cline = row(&rows, "cline");
456 assert_eq!(
457 cline.env, "WORK_CLINE_KEY",
458 "the row names the effective variable"
459 );
460 assert!(cline.configured, "and is satisfied by it, not the CLI");
461
462 let stale = |name: &str| name == "CLINE_API_KEY";
463 let rows = statuses_with(&cfg, &probes(&stale, &|_| false, &|_| false));
464 assert!(!row(&rows, "cline").configured);
465 }
466
467 #[test]
468 fn an_inline_key_configures_without_the_environment() {
469 let mut cfg = Config::default();
470 cfg.zai.api_key = Some("sk-inline".to_string());
471 let rows = statuses_with(&cfg, &bare());
472 assert!(row(&rows, "zai").configured);
473 }
474
475 #[test]
476 fn an_empty_inline_key_is_not_a_credential() {
477 let mut cfg = Config::default();
478 cfg.zai.api_key = Some(String::new());
479 let rows = statuses_with(&cfg, &bare());
480 assert!(!row(&rows, "zai").configured);
481 }
482
483 #[test]
484 fn a_named_api_key_account_counts_as_configured() {
485 let mut cfg = Config::default();
486 cfg.openrouter.accounts.push(crate::config::ApiKeyAccount {
487 label: "work".into(),
488 api_key_env: None,
489 api_key: Some("sk-named".into()),
490 management_api_key_env: None,
491 });
492 let rows = statuses_with(&cfg, &bare());
493 assert!(row(&rows, "openrouter").configured);
494 assert!(!row(&rows, "deepseek").configured);
495 }
496
497 #[test]
498 fn a_named_api_key_account_with_env_counts_as_configured() {
499 let mut cfg = Config::default();
500 cfg.deepseek.accounts.push(crate::config::ApiKeyAccount {
501 label: "work".into(),
502 api_key_env: Some("DEEPSEEK_WORK_API_KEY".into()),
503 api_key: None,
504 management_api_key_env: None,
505 });
506 let set = |name: &str| name == "DEEPSEEK_WORK_API_KEY";
507 let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
508 assert!(row(&rows, "deepseek").configured);
509 assert!(!row(&rows, "zai").configured);
510 }
511
512 #[test]
516 fn antigravity_has_nothing_to_configure() {
517 let rows = statuses_with(&Config::default(), &bare());
518 let agy = row(&rows, "antigravity");
519 assert!(!agy.needs_credential);
520 assert!(agy.configured);
521 assert_eq!(agy.env, "");
522 assert_eq!(agy.login, "");
523 }
524
525 #[test]
529 fn a_keychain_only_claude_login_counts_as_configured() {
530 let cfg = Config::default();
531 let with_keychain = Probes {
532 env_set: &|_| false,
533 exists: &|_| false,
534 keychain_has_claude: &|| true,
535 keychain_has_claude_for: &|_| false,
536 commandcode_signed_in: &|_| false,
537 opencode_go_signed_in: &|_| false,
538 cline_signed_in: &|_| false,
539 };
540 assert!(row(&statuses_with(&cfg, &with_keychain), "anthropic").configured);
541 assert!(!row(&statuses_with(&cfg, &bare()), "anthropic").configured);
542 }
543
544 #[test]
545 fn an_anthropic_credentials_path_override_counts_as_configured() {
546 let mut cfg = Config::default();
547 let custom = PathBuf::from("/custom/anthropic/.credentials.json");
548 cfg.anthropic.credentials_path = Some(custom.clone());
549 let exists = |path: &Path| path == custom;
550 let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
551 assert!(row(&rows, "anthropic").configured);
552 }
553
554 #[test]
555 fn an_anthropic_named_account_counts_as_configured() {
556 let mut cfg = Config::default();
557 let custom = PathBuf::from("/accounts/work/.credentials.json");
558 cfg.anthropic
559 .accounts
560 .push(crate::config::AnthropicAccount {
561 label: "work".into(),
562 credentials_path: custom.clone(),
563 });
564 let exists = |path: &Path| path == custom;
565 let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
566 assert!(row(&rows, "anthropic").configured);
567 }
568
569 #[test]
570 fn a_keychain_only_anthropic_named_account_counts_as_configured() {
571 let mut cfg = Config::default();
572 let custom = PathBuf::from("/accounts/work/.credentials.json");
573 cfg.anthropic
574 .accounts
575 .push(crate::config::AnthropicAccount {
576 label: "work".into(),
577 credentials_path: custom,
578 });
579 let with_named_keychain = Probes {
580 env_set: &|_| false,
581 exists: &|_| false,
582 keychain_has_claude: &|| false,
583 keychain_has_claude_for: &|dir| dir == Path::new("/accounts/work"),
584 commandcode_signed_in: &|_| false,
585 opencode_go_signed_in: &|_| false,
586 cline_signed_in: &|_| false,
587 };
588 assert!(row(&statuses_with(&cfg, &with_named_keychain), "anthropic").configured);
589 assert!(!row(&statuses_with(&cfg, &bare()), "anthropic").configured);
590 }
591
592 #[test]
593 fn an_oauth_provider_with_no_artifact_names_the_command_that_fixes_it() {
594 let rows = statuses_with(&Config::default(), &bare());
595 let codex = row(&rows, "openai");
596 assert_eq!(codex.kind, AuthKind::Oauth);
597 assert!(!codex.configured);
598 assert_eq!(codex.login, "codex login");
599 }
600
601 #[test]
602 fn opencode_go_cli_auth_file_counts_as_configured() {
603 let mut cfg = Config::default();
604 cfg.opencode_go
605 .auth_paths
606 .replace(vec![PathBuf::from("/custom/opencode/auth.json")]);
607 let signed_in = |paths: &[PathBuf]| {
608 paths
609 .iter()
610 .any(|path| path.ends_with("opencode/auth.json"))
611 };
612 let rows = statuses_with(
613 &cfg,
614 &probes_full(&|_| false, &|_| false, &|_| false, &signed_in),
615 );
616 assert!(row(&rows, "opencode-go").configured);
617 assert!(!row(&statuses_with(&cfg, &bare()), "opencode-go").configured);
618 }
619
620 #[test]
621 fn devin_catalog_row_uses_only_its_configured_local_credential_file() {
622 let mut cfg = Config::default();
623 let path = PathBuf::from("/fixture/devin/credentials.toml");
624 cfg.devin.credentials_path = Some(path.clone());
625 let exists = |candidate: &Path| candidate == path;
626 let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
627 let devin = row(&rows, "devin");
628 assert_eq!(devin.kind, AuthKind::Local);
629 assert!(devin.needs_credential);
630 assert!(devin.configured);
631 assert_eq!(devin.env, "");
632 assert_eq!(devin.login, "");
633 }
634
635 #[test]
636 fn an_openai_codex_auth_path_override_counts_as_configured() {
637 let mut cfg = Config::default();
638 let custom = PathBuf::from("/custom/openai/auth.json");
639 cfg.openai.codex_auth_path = Some(custom.clone());
640 let exists = |path: &Path| path == custom;
641 let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
642 assert!(row(&rows, "openai").configured);
643 }
644
645 #[test]
646 fn an_openai_named_account_counts_as_configured() {
647 let mut cfg = Config::default();
648 let custom = PathBuf::from("/accounts/work/auth.json");
649 cfg.openai.accounts.push(crate::config::OpenAiAccount {
650 label: "work".into(),
651 codex_auth_path: custom.clone(),
652 });
653 let exists = |path: &Path| path == custom;
654 let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
655 assert!(row(&rows, "openai").configured);
656 }
657
658 #[test]
659 fn a_devin_named_account_counts_as_configured() {
660 let mut cfg = Config::default();
661 let custom = PathBuf::from("/accounts/work/devin-credentials.toml");
662 cfg.devin.accounts.push(crate::config::DevinAccount {
663 label: "work".into(),
664 credentials_path: custom.clone(),
665 });
666 let exists = |path: &Path| path == custom;
667 let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
668 assert!(row(&rows, "devin").configured);
669 }
670
671 #[test]
674 fn enabled_follows_config_not_the_credential() {
675 let mut cfg = Config::default();
676 cfg.zai.enabled = false;
677 let set = |name: &str| name == "ZAI_API_KEY";
678 let zai = row(
679 &statuses_with(&cfg, &probes(&set, &|_| false, &|_| false)),
680 "zai",
681 );
682 assert!(!zai.enabled, "switched off in config");
683 assert!(zai.configured, "but its key is still there");
684 }
685
686 #[test]
691 fn a_shared_harness_keystore_is_not_a_commandcode_login() {
692 let mut cfg = Config::default();
693 cfg.commandcode.auth_paths = Some(vec![PathBuf::from("/home/x/.pi/agent/auth.json")]);
694
695 let keystore_only = statuses_with(&cfg, &probes(&|_| false, &|_| true, &|_| false));
696 assert!(
697 !row(&keystore_only, "commandcode").configured,
698 "a pi login for another provider is not a Command Code login"
699 );
700
701 let signed_in = statuses_with(&cfg, &probes(&|_| false, &|_| true, &|_| true));
702 assert!(row(&signed_in, "commandcode").configured);
703 }
704
705 #[test]
708 fn commandcode_configuration_follows_the_configured_auth_paths() {
709 let mut cfg = Config::default();
710 let custom = PathBuf::from("/opt/commandcode/auth.json");
711 cfg.commandcode.auth_paths = Some(vec![custom.clone()]);
712
713 let rows = statuses_with(
714 &cfg,
715 &probes(&|_| false, &|_| false, &|paths| paths == [custom.clone()]),
716 );
717 assert!(row(&rows, "commandcode").configured);
718 }
719
720 #[test]
723 fn cline_cli_auth_file_counts_as_configured() {
724 let mut cfg = Config::default();
725 let custom = PathBuf::from("/opt/cline/auth.json");
726 cfg.cline.auth_paths = Some(vec![custom.clone()]);
727
728 let signed_in = Probes {
729 env_set: &|_| false,
730 exists: &|_| false,
731 keychain_has_claude: &|| false,
732 keychain_has_claude_for: &|_| false,
733 commandcode_signed_in: &|_| false,
734 opencode_go_signed_in: &|_| false,
735 cline_signed_in: &|paths| paths == [custom.clone()],
736 };
737 assert!(row(&statuses_with(&cfg, &signed_in), "cline").configured);
738 assert!(!row(&statuses_with(&cfg, &bare()), "cline").configured);
739 }
740
741 #[test]
744 fn every_key_provider_names_a_variable_and_every_oauth_one_a_login() {
745 let cfg = Config::default();
746 for row in statuses_with(&cfg, &bare()) {
747 match row.kind {
748 AuthKind::ApiKey => assert!(
749 !row.env.is_empty(),
750 "{} authenticates by key but names no variable",
751 row.id
752 ),
753 AuthKind::Oauth => assert!(
754 !row.login.is_empty(),
755 "{} authenticates by login but names no command",
756 row.id
757 ),
758 AuthKind::Local => {}
759 }
760 }
761 }
762
763 #[test]
768 fn the_settings_key_form_covers_only_catalog_key_providers() {
769 for kv in KEY_VENDORS {
770 assert_eq!(
771 kv.id.auth_kind(),
772 AuthKind::ApiKey,
773 "{} has a key field in Settings but is not a key provider",
774 kv.id.slug()
775 );
776 assert!(
777 !kv.id.api_key_env().is_empty(),
778 "{} has a key field in Settings but names no variable",
779 kv.id.slug()
780 );
781 }
782 }
783
784 #[test]
785 fn the_json_document_is_keyed_by_vendors_and_uses_wire_names() {
786 let rows = statuses_with(&Config::default(), &bare());
787 let text = serde_json::to_string(&serde_json::json!({"vendors": rows})).unwrap();
788 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
789 let vendors = parsed["vendors"].as_array().unwrap();
790 assert_eq!(vendors.len(), VendorId::all().len());
791 assert_eq!(vendors[0]["id"], "anthropic");
792 assert_eq!(vendors[0]["kind"], "oauth");
793 assert_eq!(vendors[0]["enabled"], true);
797 assert_eq!(vendors[0]["configured"], false);
798 assert_eq!(vendors[0]["short_name"], "cld");
799 let agy = vendors
800 .iter()
801 .find(|v| v["id"] == "antigravity")
802 .expect("antigravity is in the report");
803 assert_eq!(agy["kind"], "local");
804 assert_eq!(agy["needs_credential"], false);
805 }
806}