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;
10use crate::utils::secret_env::{secret_var, secret_var_any};
11
12/// Result of AI credential validation.
13#[derive(Debug)]
14pub struct AiCredentialInfo {
15    /// The AI provider that will be used.
16    pub provider: AiProvider,
17    /// The model that will be used.
18    pub model: String,
19}
20
21/// AI provider types.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum AiProvider {
24    /// Anthropic Claude API.
25    Claude,
26    /// AWS Bedrock with Claude.
27    Bedrock,
28    /// OpenAI API.
29    OpenAi,
30    /// Local Ollama.
31    Ollama,
32    /// `claude -p` subprocess (Claude Code CLI).
33    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
48/// Validates that AI credentials are available before processing.
49///
50/// This performs a lightweight check of environment variables without
51/// creating a full AI client. Use this at the start of commands that
52/// require AI to fail fast if credentials are missing.
53pub fn check_ai_credentials(model_override: Option<&str>) -> Result<AiCredentialInfo> {
54    check_ai_credentials_with(&crate::utils::settings::SettingsEnv::load(), model_override)
55}
56
57/// Rejects a model the registry does not know, before any network call.
58///
59/// Scope is deliberately narrow (issue #1333). Only the Anthropic HTTP
60/// backends are checked, because they are the only ones whose catalog is
61/// authoritative:
62///
63/// - `Ollama` has no registry entries at all, so every model would be rejected.
64/// - `OpenAi` accepts unknown-but-well-shaped identifiers by design; the
65///   registry lists only a subset of the provider's catalog.
66/// - `ClaudeCli` resolves its own aliases (`haiku`, `sonnet`, `opus`, …) that
67///   the registry does not hold, inside the `claude` binary.
68///
69/// A Claude model newer than this build's catalog can be added to
70/// `~/.omni-dev/models.yaml`, which layers over the embedded one.
71fn 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
92/// [`check_ai_credentials`] over an injected
93/// [`EnvSource`](crate::utils::env::EnvSource).
94///
95/// The production wrapper passes `&SettingsEnv::load()` (process env with a
96/// settings.json fallback); tests pass a pure `MapEnv`, so this env-parsing
97/// boundary is exercised without mutating the process environment or taking a
98/// lock (issue #1030).
99pub(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        // Credentials for the `claude -p` subprocess backend live inside the
111        // `claude` binary's own auth state, so we just verify the binary runs.
112        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            // Same guidance whichever way the probe failed; on the `Err` path we
120            // preserve the spawn error as the chain source so the real errno
121            // survives (a missing binary's ENOENT, or the shim tests' transient
122            // ETXTBSY) rather than being flattened into this message.
123            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        // Ollama needs no credentials, just a model.
141        AiBackend::Ollama => Ok(AiCredentialInfo {
142            provider: AiProvider::Ollama,
143            model,
144        }),
145
146        AiBackend::OpenAi => {
147            // Verify API key exists
148            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            // Verify Bedrock configuration
165            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            // Verify API key exists
187            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
213/// Validates that GitHub CLI is available and authenticated.
214///
215/// This checks:
216/// 1. `gh` CLI is installed and in PATH
217/// 2. User is authenticated (can access the current repo)
218///
219/// Use this at the start of commands that require GitHub API access.
220///
221/// `repo_root` anchors the repository-access probe to the injected repository
222/// rather than the process current working directory.
223pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
224    let gh_bin = crate::pr_status::resolve_gh_binary();
225    // Check if gh CLI is available. This probe is a PATH availability check
226    // (CWD-independent), so it is not anchored to `repo_root`. `--version` is a
227    // local probe (no API call), counted as such via the metrics choke point.
228    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            // Test if gh can access the injected repo
233            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
266/// Validates that `repo_root` is a valid git repository.
267///
268/// A lightweight check that opens the repository without loading commit data.
269pub 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
276/// Validates that the working directory at `repo_root` is clean — no
277/// uncommitted changes (staged, unstaged, or untracked non-ignored files).
278///
279/// Use this before operations that require a clean working directory, like
280/// amending commits.
281pub 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
287/// Shared clean-worktree check over an already-opened repository.
288fn 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
305/// Performs combined preflight check for AI commands.
306///
307/// Validates:
308/// - Git repository access
309/// - AI credentials
310///
311/// Returns information about the AI provider that will be used.
312///
313/// `repo_root` anchors the git-repository check to the injected repository
314/// rather than the process current working directory.
315pub 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
323/// Performs combined preflight check for PR creation.
324///
325/// Validates:
326/// - Git repository access
327/// - AI credentials
328/// - GitHub CLI availability and authentication
329///
330/// Returns information about the AI provider that will be used.
331///
332/// `repo_root` anchors the git-repository and GitHub CLI checks to the injected
333/// repository rather than the process current working directory.
334pub 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        // Claude API path with a dummy key, no model override. A pure MapEnv
393        // means absent vars (USE_OPENAI, …) simply read as None — no need to
394        // clear anything, and no process-global env is touched.
395        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    /// Issue #1333: preflight reported "credentials verified" for a model that
482    /// cannot work, deferring the failure to a 404 the caller then swallowed.
483    #[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        // The `--model` flag reaches preflight as OMNI_DEV_MODEL, not as the
502        // override parameter (every call site passes None).
503        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    /// Ollama has no registry entries at all, so validation must not apply —
524    /// every model would otherwise be rejected.
525    #[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    /// Unknown-but-well-shaped OpenAI identifiers are a supported path; the
536    /// registry lists only a subset of that provider's catalog.
537    #[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        // shim_lock bounds concurrent shim subprocesses; retry_on_etxtbsy closes
562        // the exec-script/ETXTBSY race the lock can't (#642, #1348).
563        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        // shim_lock bounds concurrent shim subprocesses; retry_on_etxtbsy closes
582        // the exec-script/ETXTBSY race the lock can't (#642, #1348).
583        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        // A nonexistent binary path never spawns, so no shim_lock is needed.
602        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        // The binary exists and runs, but `--version` exits non-zero — the
618        // `Ok(_)` arm treats that as unavailable, same as a missing binary.
619        // shim_lock bounds concurrent shim subprocesses; retry_on_etxtbsy closes
620        // the exec-script/ETXTBSY race the lock can't (#642, #1348).
621        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        // OMNI_DEV_AI_BACKEND=openai wins even though USE_OLLAMA is set.
641        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        // `--ai-backend default` propagates as OMNI_DEV_AI_BACKEND=default and
654        // must override the USE_* soup.
655        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        // The headline #1118 bug: CLAUDE_MODEL / CLAUDE_CODE_MODEL were
692        // silently ignored by the direct-API and Bedrock paths.
693        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        // The factory/preflight accept both `claude-cli` and `claude_cli`.
731        // Verify the second spelling routes the same way (missing-binary
732        // path exercises the selector cheaply).
733        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}