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 match probe {
119 Ok(out) if out.status.success() => Ok(AiCredentialInfo {
120 provider: AiProvider::ClaudeCli,
121 model,
122 }),
123 _ => bail!(
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 }
130
131 AiBackend::Ollama => Ok(AiCredentialInfo {
133 provider: AiProvider::Ollama,
134 model,
135 }),
136
137 AiBackend::OpenAi => {
138 env.var_any(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])
140 .ok_or_else(|| {
141 anyhow::anyhow!(
142 "OpenAI API key not found.\n\
143 Set one of these environment variables:\n\
144 - OPENAI_API_KEY\n\
145 - OPENAI_AUTH_TOKEN"
146 )
147 })?;
148
149 Ok(AiCredentialInfo {
150 provider: AiProvider::OpenAi,
151 model,
152 })
153 }
154
155 AiBackend::Bedrock => {
156 env.var("ANTHROPIC_AUTH_TOKEN").ok_or_else(|| {
158 anyhow::anyhow!(
159 "AWS Bedrock authentication not configured.\n\
160 Set ANTHROPIC_AUTH_TOKEN environment variable."
161 )
162 })?;
163
164 env.var("ANTHROPIC_BEDROCK_BASE_URL").ok_or_else(|| {
165 anyhow::anyhow!(
166 "AWS Bedrock base URL not configured.\n\
167 Set ANTHROPIC_BEDROCK_BASE_URL environment variable."
168 )
169 })?;
170
171 Ok(AiCredentialInfo {
172 provider: AiProvider::Bedrock,
173 model,
174 })
175 }
176
177 AiBackend::Default => {
178 env.var_any(&[
180 "CLAUDE_API_KEY",
181 "ANTHROPIC_API_KEY",
182 "ANTHROPIC_AUTH_TOKEN",
183 ])
184 .ok_or_else(|| {
185 anyhow::anyhow!(
186 "Claude API key not found.\n\
187 Set one of these environment variables:\n\
188 - CLAUDE_API_KEY\n\
189 - ANTHROPIC_API_KEY\n\
190 - ANTHROPIC_AUTH_TOKEN"
191 )
192 })?;
193
194 Ok(AiCredentialInfo {
195 provider: AiProvider::Claude,
196 model,
197 })
198 }
199 }
200}
201
202pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
213 let gh_check = std::process::Command::new("gh")
216 .args(["--version"])
217 .output();
218
219 match gh_check {
220 Ok(output) if output.status.success() => {
221 let repo_check = std::process::Command::new("gh")
223 .args(["repo", "view", "--json", "name"])
224 .current_dir(repo_root)
225 .output();
226
227 match repo_check {
228 Ok(repo_output) if repo_output.status.success() => Ok(()),
229 Ok(repo_output) => {
230 let error_details = String::from_utf8_lossy(&repo_output.stderr);
231 if error_details.contains("authentication") || error_details.contains("login") {
232 bail!(
233 "GitHub CLI authentication failed.\n\
234 Please run 'gh auth login' or set GITHUB_TOKEN environment variable."
235 )
236 }
237 bail!(
238 "GitHub CLI cannot access this repository.\n\
239 Error: {}",
240 error_details.trim()
241 )
242 }
243 Err(e) => bail!("Failed to test GitHub CLI access: {e}"),
244 }
245 }
246 _ => bail!(
247 "GitHub CLI (gh) is not installed or not in PATH.\n\
248 Please install it from https://cli.github.com/"
249 ),
250 }
251}
252
253pub fn check_git_repository_at(repo_root: &std::path::Path) -> Result<()> {
257 crate::git::GitRepository::open_at(repo_root).context(
258 "Not in a git repository. Please run this command from within a git repository.",
259 )?;
260 Ok(())
261}
262
263pub fn check_working_directory_clean_at(repo_root: &std::path::Path) -> Result<()> {
269 let repo =
270 crate::git::GitRepository::open_at(repo_root).context("Failed to open git repository")?;
271 check_working_directory_clean_for(&repo)
272}
273
274fn check_working_directory_clean_for(repo: &crate::git::GitRepository) -> Result<()> {
276 let status = repo
277 .get_working_directory_status()
278 .context("Failed to get working directory status")?;
279
280 if !status.clean {
281 let mut message = String::from("Working directory has uncommitted changes:\n");
282 for change in &status.untracked_changes {
283 message.push_str(&format!(" {} {}\n", change.status, change.file));
284 }
285 message.push_str("\nPlease commit or stash your changes before proceeding.");
286 bail!(message);
287 }
288
289 Ok(())
290}
291
292pub fn check_ai_command_prerequisites(
303 model_override: Option<&str>,
304 repo_root: &std::path::Path,
305) -> Result<AiCredentialInfo> {
306 check_git_repository_at(repo_root)?;
307 check_ai_credentials(model_override)
308}
309
310pub fn check_pr_command_prerequisites(
322 model_override: Option<&str>,
323 repo_root: &std::path::Path,
324) -> Result<AiCredentialInfo> {
325 check_git_repository_at(repo_root)?;
326 let ai_info = check_ai_credentials(model_override)?;
327 check_github_cli(repo_root)?;
328 Ok(ai_info)
329}
330
331#[cfg(test)]
332#[allow(clippy::unwrap_used, clippy::expect_used)]
333mod tests {
334 use super::*;
335 use crate::test_support::env::MapEnv;
336
337 #[test]
338 fn ai_provider_display() {
339 assert_eq!(format!("{}", AiProvider::Claude), "Claude API");
340 assert_eq!(format!("{}", AiProvider::Bedrock), "AWS Bedrock");
341 assert_eq!(format!("{}", AiProvider::OpenAi), "OpenAI API");
342 assert_eq!(format!("{}", AiProvider::Ollama), "Ollama");
343 assert_eq!(format!("{}", AiProvider::ClaudeCli), "Claude Code CLI");
344 }
345
346 #[test]
347 fn ai_provider_equality() {
348 assert_eq!(AiProvider::Claude, AiProvider::Claude);
349 assert_ne!(AiProvider::Claude, AiProvider::OpenAi);
350 assert_ne!(AiProvider::Bedrock, AiProvider::Ollama);
351 }
352
353 #[test]
354 fn ai_provider_clone() {
355 let provider = AiProvider::Bedrock;
356 let cloned = provider;
357 assert_eq!(provider, cloned);
358 }
359
360 #[test]
361 fn ai_provider_debug() {
362 let debug_str = format!("{:?}", AiProvider::Claude);
363 assert_eq!(debug_str, "Claude");
364 }
365
366 #[test]
367 fn ai_credential_info_debug() {
368 let info = AiCredentialInfo {
369 provider: AiProvider::Ollama,
370 model: "llama2".to_string(),
371 };
372 let debug_str = format!("{info:?}");
373 assert!(debug_str.contains("Ollama"));
374 assert!(debug_str.contains("llama2"));
375 }
376
377 #[test]
378 fn claude_default_model_from_registry() {
379 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
383
384 let info = check_ai_credentials_with(&env, None).unwrap();
385 assert_eq!(info.provider, AiProvider::Claude);
386 assert_eq!(info.model, "claude-sonnet-5");
387 }
388
389 #[test]
390 fn openai_default_model_from_registry() {
391 let env = MapEnv::new()
392 .with("USE_OPENAI", "true")
393 .with("OPENAI_API_KEY", "sk-test-dummy");
394
395 let info = check_ai_credentials_with(&env, None).unwrap();
396 assert_eq!(info.provider, AiProvider::OpenAi);
397 assert_eq!(info.model, "gpt-5-mini");
398 }
399
400 #[test]
401 fn openai_errors_without_api_key() {
402 let env = MapEnv::new().with("USE_OPENAI", "true");
403 let err = check_ai_credentials_with(&env, None).unwrap_err();
404 assert!(err.to_string().contains("OpenAI API key not found"));
405 }
406
407 #[test]
408 fn bedrock_default_model_from_registry() {
409 let env = MapEnv::new()
410 .with("CLAUDE_CODE_USE_BEDROCK", "true")
411 .with("ANTHROPIC_AUTH_TOKEN", "test-token")
412 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
413
414 let info = check_ai_credentials_with(&env, None).unwrap();
415 assert_eq!(info.provider, AiProvider::Bedrock);
416 assert_eq!(info.model, "claude-sonnet-5");
417 }
418
419 #[test]
420 fn bedrock_errors_without_auth_token() {
421 let env = MapEnv::new().with("CLAUDE_CODE_USE_BEDROCK", "true");
422 let err = check_ai_credentials_with(&env, None).unwrap_err();
423 assert!(err
424 .to_string()
425 .contains("AWS Bedrock authentication not configured"));
426 }
427
428 #[test]
429 fn bedrock_errors_without_base_url() {
430 let env = MapEnv::new()
431 .with("CLAUDE_CODE_USE_BEDROCK", "true")
432 .with("ANTHROPIC_AUTH_TOKEN", "test-token");
433 let err = check_ai_credentials_with(&env, None).unwrap_err();
434 assert!(err
435 .to_string()
436 .contains("AWS Bedrock base URL not configured"));
437 }
438
439 #[test]
440 fn model_override_takes_precedence() {
441 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
442
443 let info = check_ai_credentials_with(&env, Some("claude-opus-4-6")).unwrap();
444 assert_eq!(info.model, "claude-opus-4-6");
445 }
446
447 #[test]
450 fn unknown_model_fails_preflight_on_direct_api() {
451 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
452
453 let err = check_ai_credentials_with(&env, Some("claude-sonnet-4-8")).unwrap_err();
454 let msg = err.to_string();
455 assert!(
456 msg.contains("Unknown model 'claude-sonnet-4-8'"),
457 "should name the bad model: {msg}"
458 );
459 assert!(
460 msg.contains("claude-sonnet-4-6"),
461 "should list what the user could have typed: {msg}"
462 );
463 }
464
465 #[test]
466 fn unknown_model_from_env_fails_preflight_on_direct_api() {
467 let env = MapEnv::new()
470 .with("ANTHROPIC_API_KEY", "sk-test-dummy")
471 .with("OMNI_DEV_MODEL", "claude-sonnet-4-8");
472
473 let err = check_ai_credentials_with(&env, None).unwrap_err();
474 assert!(err.to_string().contains("Unknown model"));
475 }
476
477 #[test]
478 fn unknown_model_fails_preflight_on_bedrock() {
479 let env = MapEnv::new()
480 .with("CLAUDE_CODE_USE_BEDROCK", "true")
481 .with("ANTHROPIC_AUTH_TOKEN", "test-token")
482 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://example.invalid")
483 .with("OMNI_DEV_MODEL", "claude-sonnet-4-8");
484
485 let err = check_ai_credentials_with(&env, None).unwrap_err();
486 assert!(err.to_string().contains("Unknown model"));
487 }
488
489 #[test]
492 fn unknown_model_is_allowed_on_ollama() {
493 let env = MapEnv::new()
494 .with("OMNI_DEV_AI_BACKEND", "ollama")
495 .with("OLLAMA_MODEL", "llama3.2:70b");
496
497 let info = check_ai_credentials_with(&env, None).unwrap();
498 assert_eq!(info.model, "llama3.2:70b");
499 }
500
501 #[test]
504 fn unknown_model_is_allowed_on_openai() {
505 let env = MapEnv::new()
506 .with("OMNI_DEV_AI_BACKEND", "openai")
507 .with("OPENAI_API_KEY", "sk-test-dummy")
508 .with("OPENAI_MODEL", "gpt-6-ultra");
509
510 let info = check_ai_credentials_with(&env, None).unwrap();
511 assert_eq!(info.model, "gpt-6-ultra");
512 }
513
514 #[cfg(unix)]
515 fn make_version_shim(tmp: &tempfile::TempDir, exit_code: i32) -> std::path::PathBuf {
516 let shim = tmp.path().join("claude-bin-shim");
517 crate::test_support::shim::write_exec_script(
518 &shim,
519 &format!("#!/bin/sh\necho 'fake-claude 0.0.0'\nexit {exit_code}\n"),
520 );
521 shim
522 }
523
524 #[test]
525 #[cfg(unix)]
526 fn claude_cli_backend_uses_version_probe() {
527 let _guard = crate::test_support::shim::shim_lock();
529 let tmp = tempfile::TempDir::new().unwrap();
530 let shim = make_version_shim(&tmp, 0);
531
532 let env = MapEnv::new()
533 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
534 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap());
535
536 let info = check_ai_credentials_with(&env, None).unwrap();
537 assert_eq!(info.provider, AiProvider::ClaudeCli);
538 assert_eq!(info.model, "claude-sonnet-5");
539 }
540
541 #[test]
542 #[cfg(unix)]
543 fn claude_cli_backend_uses_model_from_env() {
544 let _guard = crate::test_support::shim::shim_lock();
546 let tmp = tempfile::TempDir::new().unwrap();
547 let shim = make_version_shim(&tmp, 0);
548
549 let env = MapEnv::new()
550 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
551 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap())
552 .with("CLAUDE_MODEL", "haiku");
553
554 let info = check_ai_credentials_with(&env, None).unwrap();
555 assert_eq!(info.provider, AiProvider::ClaudeCli);
556 assert_eq!(info.model, "haiku");
557 }
558
559 #[test]
560 fn claude_cli_backend_missing_binary_fails_preflight() {
561 let env = MapEnv::new()
563 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
564 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
565
566 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
567 let chain = format!("{err:#}");
568 assert!(
569 chain.contains("Claude Code CLI not available"),
570 "unexpected error: {chain}"
571 );
572 }
573
574 #[test]
575 fn backend_env_var_overrides_legacy_use_flags() {
576 let env = MapEnv::new()
578 .with("OMNI_DEV_AI_BACKEND", "openai")
579 .with("USE_OLLAMA", "true")
580 .with("OPENAI_API_KEY", "sk-test-dummy");
581
582 let info = check_ai_credentials_with(&env, None).unwrap();
583 assert_eq!(info.provider, AiProvider::OpenAi);
584 assert_eq!(info.model, "gpt-5-mini");
585 }
586
587 #[test]
588 fn backend_default_value_forces_direct_api() {
589 let env = MapEnv::new()
592 .with("OMNI_DEV_AI_BACKEND", "default")
593 .with("USE_OLLAMA", "true")
594 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
595
596 let info = check_ai_credentials_with(&env, None).unwrap();
597 assert_eq!(info.provider, AiProvider::Claude);
598 }
599
600 #[test]
601 fn backend_env_var_selects_ollama_and_bedrock() {
602 let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "ollama");
603 let info = check_ai_credentials_with(&env, None).unwrap();
604 assert_eq!(info.provider, AiProvider::Ollama);
605 assert_eq!(info.model, "llama2");
606
607 let env = MapEnv::new()
608 .with("OMNI_DEV_AI_BACKEND", "bedrock")
609 .with("ANTHROPIC_AUTH_TOKEN", "tok")
610 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
611 let info = check_ai_credentials_with(&env, None).unwrap();
612 assert_eq!(info.provider, AiProvider::Bedrock);
613 }
614
615 #[test]
616 fn unknown_backend_value_is_hard_error() {
617 let env = MapEnv::new()
618 .with("OMNI_DEV_AI_BACKEND", "junk")
619 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
620
621 let err = check_ai_credentials_with(&env, None).expect_err("unknown backend must error");
622 assert!(format!("{err:#}").contains("junk"));
623 }
624
625 #[test]
626 fn claude_api_honours_claude_model_chain() {
627 let env = MapEnv::new()
630 .with("CLAUDE_MODEL", "claude-opus-4-6")
631 .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
632 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
633
634 let info = check_ai_credentials_with(&env, None).unwrap();
635 assert_eq!(info.provider, AiProvider::Claude);
636 assert_eq!(info.model, "claude-opus-4-6");
637 }
638
639 #[test]
640 fn bedrock_honours_claude_model_chain() {
641 let env = MapEnv::new()
642 .with("CLAUDE_CODE_USE_BEDROCK", "true")
643 .with("CLAUDE_CODE_MODEL", "claude-opus-4-6")
644 .with("ANTHROPIC_AUTH_TOKEN", "tok")
645 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
646
647 let info = check_ai_credentials_with(&env, None).unwrap();
648 assert_eq!(info.provider, AiProvider::Bedrock);
649 assert_eq!(info.model, "claude-opus-4-6");
650 }
651
652 #[test]
653 fn omni_dev_model_beats_provider_var() {
654 let env = MapEnv::new()
655 .with("USE_OPENAI", "true")
656 .with("OMNI_DEV_MODEL", "gpt-4.1")
657 .with("OPENAI_MODEL", "gpt-5-mini")
658 .with("OPENAI_API_KEY", "sk-test-dummy");
659
660 let info = check_ai_credentials_with(&env, None).unwrap();
661 assert_eq!(info.model, "gpt-4.1");
662 }
663
664 #[test]
665 fn claude_cli_backend_accepts_underscore_alias() {
666 let env = MapEnv::new()
670 .with("OMNI_DEV_AI_BACKEND", "claude_cli")
671 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
672
673 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
674 let chain = format!("{err:#}");
675 assert!(chain.contains("Claude Code CLI not available"));
676 }
677}