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