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            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        // Ollama needs no credentials, just a model.
132        AiBackend::Ollama => Ok(AiCredentialInfo {
133            provider: AiProvider::Ollama,
134            model,
135        }),
136
137        AiBackend::OpenAi => {
138            // Verify API key exists
139            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            // Verify Bedrock configuration
157            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            // Verify API key exists
179            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
202/// Validates that GitHub CLI is available and authenticated.
203///
204/// This checks:
205/// 1. `gh` CLI is installed and in PATH
206/// 2. User is authenticated (can access the current repo)
207///
208/// Use this at the start of commands that require GitHub API access.
209///
210/// `repo_root` anchors the repository-access probe to the injected repository
211/// rather than the process current working directory.
212pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
213    // Check if gh CLI is available. This probe is a PATH availability check
214    // (CWD-independent), so it is not anchored to `repo_root`.
215    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            // Test if gh can access the injected repo
222            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
253/// Validates that `repo_root` is a valid git repository.
254///
255/// A lightweight check that opens the repository without loading commit data.
256pub 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
263/// Validates that the working directory at `repo_root` is clean — no
264/// uncommitted changes (staged, unstaged, or untracked non-ignored files).
265///
266/// Use this before operations that require a clean working directory, like
267/// amending commits.
268pub 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
274/// Shared clean-worktree check over an already-opened repository.
275fn 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
292/// Performs combined preflight check for AI commands.
293///
294/// Validates:
295/// - Git repository access
296/// - AI credentials
297///
298/// Returns information about the AI provider that will be used.
299///
300/// `repo_root` anchors the git-repository check to the injected repository
301/// rather than the process current working directory.
302pub 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
310/// Performs combined preflight check for PR creation.
311///
312/// Validates:
313/// - Git repository access
314/// - AI credentials
315/// - GitHub CLI availability and authentication
316///
317/// Returns information about the AI provider that will be used.
318///
319/// `repo_root` anchors the git-repository and GitHub CLI checks to the injected
320/// repository rather than the process current working directory.
321pub 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        // Claude API path with a dummy key, no model override. A pure MapEnv
380        // means absent vars (USE_OPENAI, …) simply read as None — no need to
381        // clear anything, and no process-global env is touched.
382        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    /// Issue #1333: preflight reported "credentials verified" for a model that
448    /// cannot work, deferring the failure to a 404 the caller then swallowed.
449    #[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        // The `--model` flag reaches preflight as OMNI_DEV_MODEL, not as the
468        // override parameter (every call site passes None).
469        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    /// Ollama has no registry entries at all, so validation must not apply —
490    /// every model would otherwise be rejected.
491    #[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    /// Unknown-but-well-shaped OpenAI identifiers are a supported path; the
502    /// registry lists only a subset of that provider's catalog.
503    #[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        // shim_lock guards the exec-script/ETXTBSY race (#642), not env.
528        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        // shim_lock guards the exec-script/ETXTBSY race (#642), not env.
545        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        // A nonexistent binary path never spawns, so no shim_lock is needed.
562        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        // OMNI_DEV_AI_BACKEND=openai wins even though USE_OLLAMA is set.
577        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        // `--ai-backend default` propagates as OMNI_DEV_AI_BACKEND=default and
590        // must override the USE_* soup.
591        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        // The headline #1118 bug: CLAUDE_MODEL / CLAUDE_CODE_MODEL were
628        // silently ignored by the direct-API and Bedrock paths.
629        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        // The factory/preflight accept both `claude-cli` and `claude_cli`.
667        // Verify the second spelling routes the same way (missing-binary
668        // path exercises the selector cheaply).
669        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}