1use anyhow::{bail, Context, Result};
8
9use crate::claude::model_config::get_model_registry;
10use crate::utils::secret_env::{secret_var, secret_var_any};
11
12#[derive(Debug)]
14pub struct AiCredentialInfo {
15 pub provider: AiProvider,
17 pub model: String,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum AiProvider {
24 Claude,
26 Bedrock,
28 OpenAi,
30 Ollama,
32 ClaudeCli,
34}
35
36impl std::fmt::Display for AiProvider {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::Claude => write!(f, "Claude API"),
40 Self::Bedrock => write!(f, "AWS Bedrock"),
41 Self::OpenAi => write!(f, "OpenAI API"),
42 Self::Ollama => write!(f, "Ollama"),
43 Self::ClaudeCli => write!(f, "Claude Code CLI"),
44 }
45 }
46}
47
48pub fn check_ai_credentials(model_override: Option<&str>) -> Result<AiCredentialInfo> {
54 check_ai_credentials_with(&crate::utils::settings::SettingsEnv::load(), model_override)
55}
56
57fn validate_model(backend: crate::claude::backend::AiBackend, model: &str) -> Result<()> {
72 use crate::claude::backend::AiBackend;
73
74 if !matches!(backend, AiBackend::Default | AiBackend::Bedrock) {
75 return Ok(());
76 }
77
78 let registry = get_model_registry();
79 if registry.is_known_model(model) {
80 return Ok(());
81 }
82
83 bail!(
84 "Unknown model '{model}'.\n\
85 Known models: {}.\n\
86 Add an entry to ~/.omni-dev/models.yaml to use a model this build does not know about, \
87 or run `omni-dev config models show` to see the full catalog.",
88 registry.known_identifiers("claude").join(", ")
89 );
90}
91
92pub(crate) fn check_ai_credentials_with(
100 env: &impl crate::utils::env::EnvSource,
101 model_override: Option<&str>,
102) -> Result<AiCredentialInfo> {
103 use crate::claude::backend::{self, AiBackend};
104
105 let ai_backend = backend::resolve_backend(env)?;
106 let model = backend::resolve_model(ai_backend, model_override, env, get_model_registry());
107 validate_model(ai_backend, &model)?;
108
109 match ai_backend {
110 AiBackend::ClaudeCli => {
113 let binary = env
114 .var("OMNI_DEV_CLAUDE_CLI_BIN")
115 .unwrap_or_else(|| "claude".to_string());
116 let probe = std::process::Command::new(&binary)
117 .arg("--version")
118 .output();
119 let unavailable = || {
124 format!(
125 "Claude Code CLI not available at '{binary}'.\n\
126 Install it from https://github.com/anthropics/claude-code \
127 or set OMNI_DEV_CLAUDE_CLI_BIN to its path."
128 )
129 };
130 match probe {
131 Ok(out) if out.status.success() => Ok(AiCredentialInfo {
132 provider: AiProvider::ClaudeCli,
133 model,
134 }),
135 Ok(_) => Err(anyhow::anyhow!(unavailable())),
136 Err(e) => Err(anyhow::Error::new(e).context(unavailable())),
137 }
138 }
139
140 AiBackend::Ollama => Ok(AiCredentialInfo {
142 provider: AiProvider::Ollama,
143 model,
144 }),
145
146 AiBackend::OpenAi => {
147 secret_var_any(env, &["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])?.ok_or_else(|| {
149 anyhow::anyhow!(
150 "OpenAI API key not found.\n\
151 Set one of these environment variables (or its _FILE companion):\n\
152 - OPENAI_API_KEY\n\
153 - OPENAI_AUTH_TOKEN"
154 )
155 })?;
156
157 Ok(AiCredentialInfo {
158 provider: AiProvider::OpenAi,
159 model,
160 })
161 }
162
163 AiBackend::Bedrock => {
164 secret_var(env, "ANTHROPIC_AUTH_TOKEN")?.ok_or_else(|| {
166 anyhow::anyhow!(
167 "AWS Bedrock authentication not configured.\n\
168 Set ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_AUTH_TOKEN_FILE)."
169 )
170 })?;
171
172 env.var("ANTHROPIC_BEDROCK_BASE_URL").ok_or_else(|| {
173 anyhow::anyhow!(
174 "AWS Bedrock base URL not configured.\n\
175 Set ANTHROPIC_BEDROCK_BASE_URL environment variable."
176 )
177 })?;
178
179 Ok(AiCredentialInfo {
180 provider: AiProvider::Bedrock,
181 model,
182 })
183 }
184
185 AiBackend::Default => {
186 secret_var_any(
188 env,
189 &[
190 "CLAUDE_API_KEY",
191 "ANTHROPIC_API_KEY",
192 "ANTHROPIC_AUTH_TOKEN",
193 ],
194 )?
195 .ok_or_else(|| {
196 anyhow::anyhow!(
197 "Claude API key not found.\n\
198 Set one of these environment variables (or its _FILE companion):\n\
199 - CLAUDE_API_KEY\n\
200 - ANTHROPIC_API_KEY\n\
201 - ANTHROPIC_AUTH_TOKEN"
202 )
203 })?;
204
205 Ok(AiCredentialInfo {
206 provider: AiProvider::Claude,
207 model,
208 })
209 }
210 }
211}
212
213pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
224 let gh_bin = crate::pr_status::resolve_gh_binary();
225 let gh_check = crate::github_metrics::run_gh(&gh_bin, ["--version"], "--version", None);
229
230 match gh_check {
231 Ok(output) if output.status.success() => {
232 let repo_check = crate::github_metrics::run_gh(
234 &gh_bin,
235 ["repo", "view", "--json", "name"],
236 "repo view",
237 Some(repo_root),
238 );
239
240 match repo_check {
241 Ok(repo_output) if repo_output.status.success() => Ok(()),
242 Ok(repo_output) => {
243 let error_details = String::from_utf8_lossy(&repo_output.stderr);
244 if error_details.contains("authentication") || error_details.contains("login") {
245 bail!(
246 "GitHub CLI authentication failed.\n\
247 Please run 'gh auth login' or set GITHUB_TOKEN environment variable."
248 )
249 }
250 bail!(
251 "GitHub CLI cannot access this repository.\n\
252 Error: {}",
253 error_details.trim()
254 )
255 }
256 Err(e) => bail!("Failed to test GitHub CLI access: {e}"),
257 }
258 }
259 _ => bail!(
260 "GitHub CLI (gh) is not installed or not in PATH.\n\
261 Please install it from https://cli.github.com/"
262 ),
263 }
264}
265
266pub fn check_git_repository_at(repo_root: &std::path::Path) -> Result<()> {
270 crate::git::GitRepository::open_at(repo_root).context(
271 "Not in a git repository. Please run this command from within a git repository.",
272 )?;
273 Ok(())
274}
275
276pub fn check_working_directory_clean_at(repo_root: &std::path::Path) -> Result<()> {
282 let repo =
283 crate::git::GitRepository::open_at(repo_root).context("Failed to open git repository")?;
284 check_working_directory_clean_for(&repo)
285}
286
287fn check_working_directory_clean_for(repo: &crate::git::GitRepository) -> Result<()> {
289 let status = repo
290 .get_working_directory_status()
291 .context("Failed to get working directory status")?;
292
293 if !status.clean {
294 let mut message = String::from("Working directory has uncommitted changes:\n");
295 for change in &status.untracked_changes {
296 message.push_str(&format!(" {} {}\n", change.status, change.file));
297 }
298 message.push_str("\nPlease commit or stash your changes before proceeding.");
299 bail!(message);
300 }
301
302 Ok(())
303}
304
305pub fn check_ai_command_prerequisites(
316 model_override: Option<&str>,
317 repo_root: &std::path::Path,
318) -> Result<AiCredentialInfo> {
319 check_git_repository_at(repo_root)?;
320 check_ai_credentials(model_override)
321}
322
323pub fn check_pr_command_prerequisites(
335 model_override: Option<&str>,
336 repo_root: &std::path::Path,
337) -> Result<AiCredentialInfo> {
338 check_git_repository_at(repo_root)?;
339 let ai_info = check_ai_credentials(model_override)?;
340 check_github_cli(repo_root)?;
341 Ok(ai_info)
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used, clippy::expect_used)]
346mod tests {
347 use super::*;
348 use crate::test_support::env::MapEnv;
349
350 #[test]
351 fn ai_provider_display() {
352 assert_eq!(format!("{}", AiProvider::Claude), "Claude API");
353 assert_eq!(format!("{}", AiProvider::Bedrock), "AWS Bedrock");
354 assert_eq!(format!("{}", AiProvider::OpenAi), "OpenAI API");
355 assert_eq!(format!("{}", AiProvider::Ollama), "Ollama");
356 assert_eq!(format!("{}", AiProvider::ClaudeCli), "Claude Code CLI");
357 }
358
359 #[test]
360 fn ai_provider_equality() {
361 assert_eq!(AiProvider::Claude, AiProvider::Claude);
362 assert_ne!(AiProvider::Claude, AiProvider::OpenAi);
363 assert_ne!(AiProvider::Bedrock, AiProvider::Ollama);
364 }
365
366 #[test]
367 fn ai_provider_clone() {
368 let provider = AiProvider::Bedrock;
369 let cloned = provider;
370 assert_eq!(provider, cloned);
371 }
372
373 #[test]
374 fn ai_provider_debug() {
375 let debug_str = format!("{:?}", AiProvider::Claude);
376 assert_eq!(debug_str, "Claude");
377 }
378
379 #[test]
380 fn ai_credential_info_debug() {
381 let info = AiCredentialInfo {
382 provider: AiProvider::Ollama,
383 model: "llama2".to_string(),
384 };
385 let debug_str = format!("{info:?}");
386 assert!(debug_str.contains("Ollama"));
387 assert!(debug_str.contains("llama2"));
388 }
389
390 #[test]
391 fn claude_default_model_from_registry() {
392 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
396
397 let info = check_ai_credentials_with(&env, None).unwrap();
398 assert_eq!(info.provider, AiProvider::Claude);
399 assert_eq!(info.model, "claude-sonnet-5");
400 }
401
402 #[test]
403 fn openai_default_model_from_registry() {
404 let env = MapEnv::new()
405 .with("USE_OPENAI", "true")
406 .with("OPENAI_API_KEY", "sk-test-dummy");
407
408 let info = check_ai_credentials_with(&env, None).unwrap();
409 assert_eq!(info.provider, AiProvider::OpenAi);
410 assert_eq!(info.model, "gpt-5-mini");
411 }
412
413 #[test]
414 fn claude_key_can_come_from_a_file() {
415 let (_dir, path) = crate::test_support::env::secret_file("sk-from-file\n");
416 let env = MapEnv::new().with("ANTHROPIC_API_KEY_FILE", &path);
417 assert!(check_ai_credentials_with(&env, None).is_ok());
418 }
419
420 #[test]
421 fn a_bad_key_file_fails_preflight_naming_the_variable() {
422 let env = MapEnv::new().with("ANTHROPIC_API_KEY_FILE", "relative/key");
423 let err = check_ai_credentials_with(&env, None).unwrap_err();
424 assert!(err.to_string().contains("ANTHROPIC_API_KEY_FILE"), "{err}");
425
426 let env = MapEnv::new()
427 .with("OPENAI_API_KEY", "a")
428 .with("OPENAI_API_KEY_FILE", "/x")
429 .with("USE_OPENAI", "true");
430 let err = check_ai_credentials_with(&env, None).unwrap_err();
431 assert!(err.to_string().contains("both OPENAI_API_KEY"), "{err}");
432 }
433
434 #[test]
435 fn openai_errors_without_api_key() {
436 let env = MapEnv::new().with("USE_OPENAI", "true");
437 let err = check_ai_credentials_with(&env, None).unwrap_err();
438 assert!(err.to_string().contains("OpenAI API key not found"));
439 }
440
441 #[test]
442 fn bedrock_default_model_from_registry() {
443 let env = MapEnv::new()
444 .with("CLAUDE_CODE_USE_BEDROCK", "true")
445 .with("ANTHROPIC_AUTH_TOKEN", "test-token")
446 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
447
448 let info = check_ai_credentials_with(&env, None).unwrap();
449 assert_eq!(info.provider, AiProvider::Bedrock);
450 assert_eq!(info.model, "claude-sonnet-5");
451 }
452
453 #[test]
454 fn bedrock_errors_without_auth_token() {
455 let env = MapEnv::new().with("CLAUDE_CODE_USE_BEDROCK", "true");
456 let err = check_ai_credentials_with(&env, None).unwrap_err();
457 assert!(err
458 .to_string()
459 .contains("AWS Bedrock authentication not configured"));
460 }
461
462 #[test]
463 fn bedrock_errors_without_base_url() {
464 let env = MapEnv::new()
465 .with("CLAUDE_CODE_USE_BEDROCK", "true")
466 .with("ANTHROPIC_AUTH_TOKEN", "test-token");
467 let err = check_ai_credentials_with(&env, None).unwrap_err();
468 assert!(err
469 .to_string()
470 .contains("AWS Bedrock base URL not configured"));
471 }
472
473 #[test]
474 fn model_override_takes_precedence() {
475 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
476
477 let info = check_ai_credentials_with(&env, Some("claude-opus-4-6")).unwrap();
478 assert_eq!(info.model, "claude-opus-4-6");
479 }
480
481 #[test]
484 fn unknown_model_fails_preflight_on_direct_api() {
485 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
486
487 let err = check_ai_credentials_with(&env, Some("claude-sonnet-4-8")).unwrap_err();
488 let msg = err.to_string();
489 assert!(
490 msg.contains("Unknown model 'claude-sonnet-4-8'"),
491 "should name the bad model: {msg}"
492 );
493 assert!(
494 msg.contains("claude-sonnet-4-6"),
495 "should list what the user could have typed: {msg}"
496 );
497 }
498
499 #[test]
500 fn unknown_model_from_env_fails_preflight_on_direct_api() {
501 let env = MapEnv::new()
504 .with("ANTHROPIC_API_KEY", "sk-test-dummy")
505 .with("OMNI_DEV_MODEL", "claude-sonnet-4-8");
506
507 let err = check_ai_credentials_with(&env, None).unwrap_err();
508 assert!(err.to_string().contains("Unknown model"));
509 }
510
511 #[test]
512 fn unknown_model_fails_preflight_on_bedrock() {
513 let env = MapEnv::new()
514 .with("CLAUDE_CODE_USE_BEDROCK", "true")
515 .with("ANTHROPIC_AUTH_TOKEN", "test-token")
516 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://example.invalid")
517 .with("OMNI_DEV_MODEL", "claude-sonnet-4-8");
518
519 let err = check_ai_credentials_with(&env, None).unwrap_err();
520 assert!(err.to_string().contains("Unknown model"));
521 }
522
523 #[test]
526 fn unknown_model_is_allowed_on_ollama() {
527 let env = MapEnv::new()
528 .with("OMNI_DEV_AI_BACKEND", "ollama")
529 .with("OLLAMA_MODEL", "llama3.2:70b");
530
531 let info = check_ai_credentials_with(&env, None).unwrap();
532 assert_eq!(info.model, "llama3.2:70b");
533 }
534
535 #[test]
538 fn unknown_model_is_allowed_on_openai() {
539 let env = MapEnv::new()
540 .with("OMNI_DEV_AI_BACKEND", "openai")
541 .with("OPENAI_API_KEY", "sk-test-dummy")
542 .with("OPENAI_MODEL", "gpt-6-ultra");
543
544 let info = check_ai_credentials_with(&env, None).unwrap();
545 assert_eq!(info.model, "gpt-6-ultra");
546 }
547
548 #[cfg(unix)]
549 fn make_version_shim(tmp: &tempfile::TempDir, exit_code: i32) -> std::path::PathBuf {
550 let shim = tmp.path().join("claude-bin-shim");
551 crate::test_support::shim::write_exec_script(
552 &shim,
553 &format!("#!/bin/sh\necho 'fake-claude 0.0.0'\nexit {exit_code}\n"),
554 );
555 shim
556 }
557
558 #[test]
559 #[cfg(unix)]
560 fn claude_cli_backend_uses_version_probe() {
561 let _guard = crate::test_support::shim::shim_lock();
564 let tmp = tempfile::TempDir::new().unwrap();
565 let shim = make_version_shim(&tmp, 0);
566
567 let env = MapEnv::new()
568 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
569 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap());
570
571 let info =
572 crate::test_support::shim::retry_on_etxtbsy(|| check_ai_credentials_with(&env, None))
573 .unwrap();
574 assert_eq!(info.provider, AiProvider::ClaudeCli);
575 assert_eq!(info.model, "claude-sonnet-5");
576 }
577
578 #[test]
579 #[cfg(unix)]
580 fn claude_cli_backend_uses_model_from_env() {
581 let _guard = crate::test_support::shim::shim_lock();
584 let tmp = tempfile::TempDir::new().unwrap();
585 let shim = make_version_shim(&tmp, 0);
586
587 let env = MapEnv::new()
588 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
589 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap())
590 .with("CLAUDE_MODEL", "haiku");
591
592 let info =
593 crate::test_support::shim::retry_on_etxtbsy(|| check_ai_credentials_with(&env, None))
594 .unwrap();
595 assert_eq!(info.provider, AiProvider::ClaudeCli);
596 assert_eq!(info.model, "haiku");
597 }
598
599 #[test]
600 fn claude_cli_backend_missing_binary_fails_preflight() {
601 let env = MapEnv::new()
603 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
604 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
605
606 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
607 let chain = format!("{err:#}");
608 assert!(
609 chain.contains("Claude Code CLI not available"),
610 "unexpected error: {chain}"
611 );
612 }
613
614 #[test]
615 #[cfg(unix)]
616 fn claude_cli_backend_nonzero_version_probe_fails_preflight() {
617 let _guard = crate::test_support::shim::shim_lock();
622 let tmp = tempfile::TempDir::new().unwrap();
623 let shim = make_version_shim(&tmp, 1);
624
625 let env = MapEnv::new()
626 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
627 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap());
628
629 let err =
630 crate::test_support::shim::retry_on_etxtbsy(|| check_ai_credentials_with(&env, None))
631 .expect_err("a non-zero version probe should fail preflight");
632 assert!(
633 format!("{err:#}").contains("Claude Code CLI not available"),
634 "unexpected error: {err:#}"
635 );
636 }
637
638 #[test]
639 fn backend_env_var_overrides_legacy_use_flags() {
640 let env = MapEnv::new()
642 .with("OMNI_DEV_AI_BACKEND", "openai")
643 .with("USE_OLLAMA", "true")
644 .with("OPENAI_API_KEY", "sk-test-dummy");
645
646 let info = check_ai_credentials_with(&env, None).unwrap();
647 assert_eq!(info.provider, AiProvider::OpenAi);
648 assert_eq!(info.model, "gpt-5-mini");
649 }
650
651 #[test]
652 fn backend_default_value_forces_direct_api() {
653 let env = MapEnv::new()
656 .with("OMNI_DEV_AI_BACKEND", "default")
657 .with("USE_OLLAMA", "true")
658 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
659
660 let info = check_ai_credentials_with(&env, None).unwrap();
661 assert_eq!(info.provider, AiProvider::Claude);
662 }
663
664 #[test]
665 fn backend_env_var_selects_ollama_and_bedrock() {
666 let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "ollama");
667 let info = check_ai_credentials_with(&env, None).unwrap();
668 assert_eq!(info.provider, AiProvider::Ollama);
669 assert_eq!(info.model, "llama2");
670
671 let env = MapEnv::new()
672 .with("OMNI_DEV_AI_BACKEND", "bedrock")
673 .with("ANTHROPIC_AUTH_TOKEN", "tok")
674 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
675 let info = check_ai_credentials_with(&env, None).unwrap();
676 assert_eq!(info.provider, AiProvider::Bedrock);
677 }
678
679 #[test]
680 fn unknown_backend_value_is_hard_error() {
681 let env = MapEnv::new()
682 .with("OMNI_DEV_AI_BACKEND", "junk")
683 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
684
685 let err = check_ai_credentials_with(&env, None).expect_err("unknown backend must error");
686 assert!(format!("{err:#}").contains("junk"));
687 }
688
689 #[test]
690 fn claude_api_honours_claude_model_chain() {
691 let env = MapEnv::new()
694 .with("CLAUDE_MODEL", "claude-opus-4-6")
695 .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
696 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
697
698 let info = check_ai_credentials_with(&env, None).unwrap();
699 assert_eq!(info.provider, AiProvider::Claude);
700 assert_eq!(info.model, "claude-opus-4-6");
701 }
702
703 #[test]
704 fn bedrock_honours_claude_model_chain() {
705 let env = MapEnv::new()
706 .with("CLAUDE_CODE_USE_BEDROCK", "true")
707 .with("CLAUDE_CODE_MODEL", "claude-opus-4-6")
708 .with("ANTHROPIC_AUTH_TOKEN", "tok")
709 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
710
711 let info = check_ai_credentials_with(&env, None).unwrap();
712 assert_eq!(info.provider, AiProvider::Bedrock);
713 assert_eq!(info.model, "claude-opus-4-6");
714 }
715
716 #[test]
717 fn omni_dev_model_beats_provider_var() {
718 let env = MapEnv::new()
719 .with("USE_OPENAI", "true")
720 .with("OMNI_DEV_MODEL", "gpt-4.1")
721 .with("OPENAI_MODEL", "gpt-5-mini")
722 .with("OPENAI_API_KEY", "sk-test-dummy");
723
724 let info = check_ai_credentials_with(&env, None).unwrap();
725 assert_eq!(info.model, "gpt-4.1");
726 }
727
728 #[test]
729 fn claude_cli_backend_accepts_underscore_alias() {
730 let env = MapEnv::new()
734 .with("OMNI_DEV_AI_BACKEND", "claude_cli")
735 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
736
737 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
738 let chain = format!("{err:#}");
739 assert!(chain.contains("Claude Code CLI not available"));
740 }
741}