Skip to main content

omni_dev/utils/
preflight.rs

1//! Preflight validation checks for early failure detection.
2//!
3//! This module provides functions to validate required services and credentials
4//! before starting expensive operations. Commands should call these checks early
5//! to fail fast with clear error messages.
6
7use anyhow::{bail, Context, Result};
8
9use crate::claude::model_config::get_model_registry;
10
11/// Result of AI credential validation.
12#[derive(Debug)]
13pub struct AiCredentialInfo {
14    /// The AI provider that will be used.
15    pub provider: AiProvider,
16    /// The model that will be used.
17    pub model: String,
18}
19
20/// AI provider types.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AiProvider {
23    /// Anthropic Claude API.
24    Claude,
25    /// AWS Bedrock with Claude.
26    Bedrock,
27    /// OpenAI API.
28    OpenAi,
29    /// Local Ollama.
30    Ollama,
31    /// `claude -p` subprocess (Claude Code CLI).
32    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
47/// Validates that AI credentials are available before processing.
48///
49/// This performs a lightweight check of environment variables without
50/// creating a full AI client. Use this at the start of commands that
51/// require AI to fail fast if credentials are missing.
52pub fn check_ai_credentials(model_override: Option<&str>) -> Result<AiCredentialInfo> {
53    check_ai_credentials_with(&crate::utils::settings::SettingsEnv::load(), model_override)
54}
55
56/// Rejects a model the registry does not know, before any network call.
57///
58/// Scope is deliberately narrow (issue #1333). Only the Anthropic HTTP
59/// backends are checked, because they are the only ones whose catalog is
60/// authoritative:
61///
62/// - `Ollama` has no registry entries at all, so every model would be rejected.
63/// - `OpenAi` accepts unknown-but-well-shaped identifiers by design; the
64///   registry lists only a subset of the provider's catalog.
65/// - `ClaudeCli` resolves its own aliases (`haiku`, `sonnet`, `opus`, …) that
66///   the registry does not hold, inside the `claude` binary.
67///
68/// A Claude model newer than this build's catalog can be added to
69/// `~/.omni-dev/models.yaml`, which layers over the embedded one.
70fn 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
91/// [`check_ai_credentials`] over an injected
92/// [`EnvSource`](crate::utils::env::EnvSource).
93///
94/// The production wrapper passes `&SettingsEnv::load()` (process env with a
95/// settings.json fallback); tests pass a pure `MapEnv`, so this env-parsing
96/// boundary is exercised without mutating the process environment or taking a
97/// lock (issue #1030).
98pub(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        // Credentials for the `claude -p` subprocess backend live inside the
110        // `claude` binary's own auth state, so we just verify the binary runs.
111        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            // Same guidance whichever way the probe failed; on the `Err` path we
119            // preserve the spawn error as the chain source so the real errno
120            // survives (a missing binary's ENOENT, or the shim tests' transient
121            // ETXTBSY) rather than being flattened into this message.
122            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        // Ollama needs no credentials, just a model.
140        AiBackend::Ollama => Ok(AiCredentialInfo {
141            provider: AiProvider::Ollama,
142            model,
143        }),
144
145        AiBackend::OpenAi => {
146            // Verify API key exists
147            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            // Verify Bedrock configuration
165            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            // Verify API key exists
187            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
210/// Validates that GitHub CLI is available and authenticated.
211///
212/// This checks:
213/// 1. `gh` CLI is installed and in PATH
214/// 2. User is authenticated (can access the current repo)
215///
216/// Use this at the start of commands that require GitHub API access.
217///
218/// `repo_root` anchors the repository-access probe to the injected repository
219/// rather than the process current working directory.
220pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
221    let gh_bin = crate::pr_status::resolve_gh_binary();
222    // Check if gh CLI is available. This probe is a PATH availability check
223    // (CWD-independent), so it is not anchored to `repo_root`. `--version` is a
224    // local probe (no API call), counted as such via the metrics choke point.
225    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            // Test if gh can access the injected repo
230            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
263/// Validates that `repo_root` is a valid git repository.
264///
265/// A lightweight check that opens the repository without loading commit data.
266pub 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
273/// Validates that the working directory at `repo_root` is clean — no
274/// uncommitted changes (staged, unstaged, or untracked non-ignored files).
275///
276/// Use this before operations that require a clean working directory, like
277/// amending commits.
278pub 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
284/// Shared clean-worktree check over an already-opened repository.
285fn 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
302/// Performs combined preflight check for AI commands.
303///
304/// Validates:
305/// - Git repository access
306/// - AI credentials
307///
308/// Returns information about the AI provider that will be used.
309///
310/// `repo_root` anchors the git-repository check to the injected repository
311/// rather than the process current working directory.
312pub 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
320/// Performs combined preflight check for PR creation.
321///
322/// Validates:
323/// - Git repository access
324/// - AI credentials
325/// - GitHub CLI availability and authentication
326///
327/// Returns information about the AI provider that will be used.
328///
329/// `repo_root` anchors the git-repository and GitHub CLI checks to the injected
330/// repository rather than the process current working directory.
331pub 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        // Claude API path with a dummy key, no model override. A pure MapEnv
390        // means absent vars (USE_OPENAI, …) simply read as None — no need to
391        // clear anything, and no process-global env is touched.
392        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    /// Issue #1333: preflight reported "credentials verified" for a model that
458    /// cannot work, deferring the failure to a 404 the caller then swallowed.
459    #[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        // The `--model` flag reaches preflight as OMNI_DEV_MODEL, not as the
478        // override parameter (every call site passes None).
479        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    /// Ollama has no registry entries at all, so validation must not apply —
500    /// every model would otherwise be rejected.
501    #[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    /// Unknown-but-well-shaped OpenAI identifiers are a supported path; the
512    /// registry lists only a subset of that provider's catalog.
513    #[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        // shim_lock bounds concurrent shim subprocesses; retry_on_etxtbsy closes
538        // the exec-script/ETXTBSY race the lock can't (#642, #1348).
539        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        // shim_lock bounds concurrent shim subprocesses; retry_on_etxtbsy closes
558        // the exec-script/ETXTBSY race the lock can't (#642, #1348).
559        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        // A nonexistent binary path never spawns, so no shim_lock is needed.
578        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        // The binary exists and runs, but `--version` exits non-zero — the
594        // `Ok(_)` arm treats that as unavailable, same as a missing binary.
595        // shim_lock bounds concurrent shim subprocesses; retry_on_etxtbsy closes
596        // the exec-script/ETXTBSY race the lock can't (#642, #1348).
597        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        // OMNI_DEV_AI_BACKEND=openai wins even though USE_OLLAMA is set.
617        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        // `--ai-backend default` propagates as OMNI_DEV_AI_BACKEND=default and
630        // must override the USE_* soup.
631        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        // The headline #1118 bug: CLAUDE_MODEL / CLAUDE_CODE_MODEL were
668        // silently ignored by the direct-API and Bedrock paths.
669        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        // The factory/preflight accept both `claude-cli` and `claude_cli`.
707        // Verify the second spelling routes the same way (missing-binary
708        // path exercises the selector cheaply).
709        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}