Skip to main content

omni_dev/claude/
backend.rs

1//! Shared AI backend and model resolution.
2//!
3//! This module is the single source of truth for which AI backend an
4//! invocation uses and which model it sends, consumed by both the client
5//! factory ([`crate::claude::client::create_default_claude_client`]) and the
6//! preflight credential check (`crate::utils::preflight`). Before it existed
7//! the two sites duplicated the dispatch switch and had drifted apart
8//! (issue #1118).
9//!
10//! Resolution reads the environment only through an
11//! [`EnvSource`](crate::utils::env::EnvSource) (STYLE-0028); the production
12//! callers pass `&SettingsEnv::load()`, so every variable below can also be
13//! supplied from `~/.omni-dev/settings.json` `env` bundles or profiles.
14//!
15//! Backend selection precedence:
16//!
17//! 1. [`AI_BACKEND_ENV`] (`OMNI_DEV_AI_BACKEND`), set directly or via the
18//!    global `--ai-backend` flag — wins outright, including the value
19//!    `default`, which forces the direct Anthropic API even when a `USE_*`
20//!    flag is set. An unknown value is a hard error.
21//! 2. Legacy flags, first match wins: [`USE_OLLAMA_ENV`] → [`USE_OPENAI_ENV`]
22//!    → [`USE_BEDROCK_ENV`] (each compared against the literal `true`).
23//! 3. Otherwise the direct Anthropic API ([`AiBackend::Default`]).
24//!
25//! Model resolution stops at the first non-empty value: the explicit value
26//! (CLI-independent callers such as MCP tools) → [`MODEL_ENV`]
27//! (`OMNI_DEV_MODEL`, set by the global `--model` flag) → the backend
28//! family's own variables → the registry default for the provider. The
29//! Claude-family variables ([`CLAUDE_MODEL_ENV`], [`CLAUDE_CODE_MODEL_ENV`],
30//! [`ANTHROPIC_MODEL_ENV`]) apply only to Claude-family backends; OpenAI and
31//! Ollama read only their provider variable, so a Claude model id can never
32//! leak into a non-Claude backend.
33
34use anyhow::{anyhow, Result};
35
36use crate::claude::model_config::ModelRegistry;
37use crate::utils::env::EnvSource;
38
39/// Env var selecting the AI backend (`default`, `claude-cli`, `openai`,
40/// `ollama`, `bedrock`); set by the global `--ai-backend` flag.
41pub const AI_BACKEND_ENV: &str = "OMNI_DEV_AI_BACKEND";
42/// Env var carrying the backend-agnostic model override; set by the global
43/// `--model` flag. Outranks every per-family model variable.
44pub const MODEL_ENV: &str = "OMNI_DEV_MODEL";
45/// Env var carrying a `key:value` beta header; set by the global
46/// `--beta-header` flag.
47pub const BETA_HEADER_ENV: &str = "OMNI_DEV_BETA_HEADER";
48/// Highest-precedence Claude-family model variable.
49pub const CLAUDE_MODEL_ENV: &str = "CLAUDE_MODEL";
50/// Claude-family model variable, read after [`CLAUDE_MODEL_ENV`].
51pub const CLAUDE_CODE_MODEL_ENV: &str = "CLAUDE_CODE_MODEL";
52/// Claude-family model variable, read after [`CLAUDE_CODE_MODEL_ENV`].
53pub const ANTHROPIC_MODEL_ENV: &str = "ANTHROPIC_MODEL";
54/// Model variable for the OpenAI backend.
55pub const OPENAI_MODEL_ENV: &str = "OPENAI_MODEL";
56/// Model variable for the Ollama backend.
57pub const OLLAMA_MODEL_ENV: &str = "OLLAMA_MODEL";
58/// Legacy backend-selection flag for OpenAI (`true` to select).
59pub const USE_OPENAI_ENV: &str = "USE_OPENAI";
60/// Legacy backend-selection flag for Ollama (`true` to select).
61pub const USE_OLLAMA_ENV: &str = "USE_OLLAMA";
62/// Legacy backend-selection flag for AWS Bedrock (`true` to select).
63pub const USE_BEDROCK_ENV: &str = "CLAUDE_CODE_USE_BEDROCK";
64
65/// Hard fallback when the registry has no default for the `claude` provider.
66///
67/// Keep in sync with `providers.claude.default_model` in
68/// `src/templates/models.yaml`; this only applies when that file fails to load.
69const FALLBACK_CLAUDE_MODEL: &str = "claude-sonnet-5";
70/// Hard fallback when the registry has no default for the `openai` provider.
71const FALLBACK_OPENAI_MODEL: &str = "gpt-5-mini";
72/// Default Ollama model; the registry has no `ollama` provider entry.
73const FALLBACK_OLLAMA_MODEL: &str = "llama2";
74
75/// The AI backend used by commands that invoke an AI model.
76///
77/// One enum serves both the `--ai-backend` CLI flag (via
78/// [`clap::ValueEnum`]) and the resolved dispatch in the client factory and
79/// preflight (STYLE-0019). Selection precedence is documented on
80/// [`resolve_backend`].
81#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
82pub enum AiBackend {
83    /// Direct HTTP to the Anthropic API. Selecting this explicitly overrides
84    /// the legacy `USE_*` env vars.
85    #[value(name = "default")]
86    Default,
87    /// Shell out to the `claude -p` CLI (reuses an existing Claude Code auth
88    /// session). Equivalent to setting `OMNI_DEV_AI_BACKEND=claude-cli`.
89    #[value(name = "claude-cli")]
90    ClaudeCli,
91    /// OpenAI API. Equivalent to setting `OMNI_DEV_AI_BACKEND=openai`
92    /// (legacy selector: `USE_OPENAI=true`).
93    #[value(name = "openai")]
94    OpenAi,
95    /// Local Ollama (or another OpenAI-compatible local server). Equivalent
96    /// to setting `OMNI_DEV_AI_BACKEND=ollama` (legacy: `USE_OLLAMA=true`).
97    #[value(name = "ollama")]
98    Ollama,
99    /// AWS Bedrock with Claude models. Equivalent to setting
100    /// `OMNI_DEV_AI_BACKEND=bedrock` (legacy: `CLAUDE_CODE_USE_BEDROCK=true`).
101    #[value(name = "bedrock")]
102    Bedrock,
103}
104
105impl AiBackend {
106    /// Returns the canonical [`AI_BACKEND_ENV`] value for this backend.
107    pub fn env_value(self) -> &'static str {
108        match self {
109            Self::Default => "default",
110            Self::ClaudeCli => "claude-cli",
111            Self::OpenAi => "openai",
112            Self::Ollama => "ollama",
113            Self::Bedrock => "bedrock",
114        }
115    }
116
117    /// Parses an [`AI_BACKEND_ENV`] value.
118    ///
119    /// Accepts the canonical kebab-case values plus the legacy `claude_cli`
120    /// underscore alias. Returns `None` for anything else.
121    pub fn from_env_value(value: &str) -> Option<Self> {
122        match value {
123            "default" => Some(Self::Default),
124            "claude-cli" | "claude_cli" => Some(Self::ClaudeCli),
125            "openai" => Some(Self::OpenAi),
126            "ollama" => Some(Self::Ollama),
127            "bedrock" => Some(Self::Bedrock),
128            _ => None,
129        }
130    }
131}
132
133/// Returns `var`'s value when it is set and non-empty.
134///
135/// The docs promise resolution "stopping at the first non-empty value", and
136/// treating `VAR=` as unset lets users neutralise an exported variable for a
137/// single invocation.
138fn non_empty_var(env: &impl EnvSource, key: &str) -> Option<String> {
139    env.var(key).filter(|v| !v.is_empty())
140}
141
142/// Resolves which AI backend to use from the environment.
143///
144/// [`AI_BACKEND_ENV`] wins outright when set (including `default`, which
145/// forces the direct Anthropic API even when `USE_*` flags are set); an
146/// unknown value is a hard error listing the valid values. When unset, the
147/// legacy flags apply in order: [`USE_OLLAMA_ENV`] → [`USE_OPENAI_ENV`] →
148/// [`USE_BEDROCK_ENV`], each selecting its backend when equal to `true`.
149/// Otherwise the direct Anthropic API is used.
150pub fn resolve_backend(env: &impl EnvSource) -> Result<AiBackend> {
151    if let Some(raw) = non_empty_var(env, AI_BACKEND_ENV) {
152        return AiBackend::from_env_value(&raw).ok_or_else(|| {
153            anyhow!(
154                "Unknown {AI_BACKEND_ENV} value '{raw}'. \
155                 Valid values: default, claude-cli, openai, ollama, bedrock"
156            )
157        });
158    }
159
160    let flag_true = |key| env.var(key).is_some_and(|v| v == "true");
161    if flag_true(USE_OLLAMA_ENV) {
162        Ok(AiBackend::Ollama)
163    } else if flag_true(USE_OPENAI_ENV) {
164        Ok(AiBackend::OpenAi)
165    } else if flag_true(USE_BEDROCK_ENV) {
166        Ok(AiBackend::Bedrock)
167    } else {
168        Ok(AiBackend::Default)
169    }
170}
171
172/// Resolves the model id for `backend`, stopping at the first non-empty
173/// value.
174///
175/// Chain: `explicit` (callers with their own model parameter, e.g. MCP
176/// tools) → [`MODEL_ENV`] → the backend family's variables
177/// (Claude family: [`CLAUDE_MODEL_ENV`] → [`CLAUDE_CODE_MODEL_ENV`] →
178/// [`ANTHROPIC_MODEL_ENV`]; OpenAI: [`OPENAI_MODEL_ENV`]; Ollama:
179/// [`OLLAMA_MODEL_ENV`]) → the registry default for the provider → a
180/// hard-coded fallback.
181pub fn resolve_model(
182    backend: AiBackend,
183    explicit: Option<&str>,
184    env: &impl EnvSource,
185    registry: &ModelRegistry,
186) -> String {
187    if let Some(model) = explicit.filter(|m| !m.is_empty()) {
188        return model.to_string();
189    }
190    if let Some(model) = non_empty_var(env, MODEL_ENV) {
191        return model;
192    }
193
194    match backend {
195        AiBackend::Default | AiBackend::ClaudeCli | AiBackend::Bedrock => {
196            [CLAUDE_MODEL_ENV, CLAUDE_CODE_MODEL_ENV, ANTHROPIC_MODEL_ENV]
197                .iter()
198                .find_map(|key| non_empty_var(env, key))
199                .unwrap_or_else(|| {
200                    registry
201                        .get_default_model("claude")
202                        .unwrap_or(FALLBACK_CLAUDE_MODEL)
203                        .to_string()
204                })
205        }
206        AiBackend::OpenAi => non_empty_var(env, OPENAI_MODEL_ENV).unwrap_or_else(|| {
207            registry
208                .get_default_model("openai")
209                .unwrap_or(FALLBACK_OPENAI_MODEL)
210                .to_string()
211        }),
212        AiBackend::Ollama => non_empty_var(env, OLLAMA_MODEL_ENV)
213            .unwrap_or_else(|| FALLBACK_OLLAMA_MODEL.to_string()),
214    }
215}
216
217/// Parses a `--beta-header key:value` string into a `(key, value)` tuple.
218pub fn parse_beta_header(s: &str) -> Result<(String, String)> {
219    let (k, v) = s
220        .split_once(':')
221        .ok_or_else(|| anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
222    Ok((k.to_string(), v.to_string()))
223}
224
225/// Resolves the beta header to send with AI API requests.
226///
227/// `explicit` (callers with their own parameter) wins; otherwise
228/// [`BETA_HEADER_ENV`] (set by the global `--beta-header` flag) is parsed as
229/// `key:value`. A set-but-malformed value is a hard error; unset (or empty)
230/// resolves to `None`.
231pub fn resolve_beta_header(
232    explicit: Option<(String, String)>,
233    env: &impl EnvSource,
234) -> Result<Option<(String, String)>> {
235    if explicit.is_some() {
236        return Ok(explicit);
237    }
238    match non_empty_var(env, BETA_HEADER_ENV) {
239        Some(raw) => parse_beta_header(&raw).map(Some),
240        None => Ok(None),
241    }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used, clippy::expect_used)]
246mod tests {
247    use super::*;
248    use crate::claude::model_config::get_model_registry;
249    use crate::test_support::env::MapEnv;
250
251    #[test]
252    fn env_value_round_trips_for_all_backends() {
253        for backend in [
254            AiBackend::Default,
255            AiBackend::ClaudeCli,
256            AiBackend::OpenAi,
257            AiBackend::Ollama,
258            AiBackend::Bedrock,
259        ] {
260            assert_eq!(
261                AiBackend::from_env_value(backend.env_value()),
262                Some(backend)
263            );
264        }
265    }
266
267    #[test]
268    fn from_env_value_accepts_legacy_underscore_alias() {
269        assert_eq!(
270            AiBackend::from_env_value("claude_cli"),
271            Some(AiBackend::ClaudeCli)
272        );
273    }
274
275    #[test]
276    fn from_env_value_rejects_unknown() {
277        assert_eq!(AiBackend::from_env_value("gemini"), None);
278        assert_eq!(AiBackend::from_env_value(""), None);
279    }
280
281    #[test]
282    fn resolve_backend_defaults_to_direct_api() {
283        assert_eq!(resolve_backend(&MapEnv::new()).unwrap(), AiBackend::Default);
284    }
285
286    #[test]
287    fn resolve_backend_legacy_flags_in_order() {
288        // Ollama beats OpenAI beats Bedrock (matches the pre-#1118 dispatch).
289        let env = MapEnv::new()
290            .with(USE_OLLAMA_ENV, "true")
291            .with(USE_OPENAI_ENV, "true")
292            .with(USE_BEDROCK_ENV, "true");
293        assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Ollama);
294
295        let env = MapEnv::new()
296            .with(USE_OPENAI_ENV, "true")
297            .with(USE_BEDROCK_ENV, "true");
298        assert_eq!(resolve_backend(&env).unwrap(), AiBackend::OpenAi);
299
300        let env = MapEnv::new().with(USE_BEDROCK_ENV, "true");
301        assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Bedrock);
302    }
303
304    #[test]
305    fn resolve_backend_legacy_flags_require_literal_true() {
306        let env = MapEnv::new().with(USE_OLLAMA_ENV, "1");
307        assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Default);
308    }
309
310    #[test]
311    fn resolve_backend_env_var_overrides_legacy_flags() {
312        for (value, expected) in [
313            ("default", AiBackend::Default),
314            ("claude-cli", AiBackend::ClaudeCli),
315            ("openai", AiBackend::OpenAi),
316            ("bedrock", AiBackend::Bedrock),
317        ] {
318            let env = MapEnv::new()
319                .with(AI_BACKEND_ENV, value)
320                .with(USE_OLLAMA_ENV, "true");
321            assert_eq!(resolve_backend(&env).unwrap(), expected, "value {value}");
322        }
323    }
324
325    #[test]
326    fn resolve_backend_unknown_value_is_hard_error() {
327        let env = MapEnv::new().with(AI_BACKEND_ENV, "junk");
328        let err = resolve_backend(&env).unwrap_err().to_string();
329        assert!(err.contains("junk"), "unexpected error: {err}");
330        assert!(err.contains("claude-cli"), "unexpected error: {err}");
331    }
332
333    #[test]
334    fn resolve_backend_empty_value_reads_as_unset() {
335        let env = MapEnv::new()
336            .with(AI_BACKEND_ENV, "")
337            .with(USE_OLLAMA_ENV, "true");
338        assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Ollama);
339    }
340
341    #[test]
342    fn resolve_model_explicit_wins_everywhere() {
343        let env = MapEnv::new()
344            .with(MODEL_ENV, "from-omni-dev-model")
345            .with(CLAUDE_MODEL_ENV, "from-claude-model");
346        let model = resolve_model(
347            AiBackend::Default,
348            Some("explicit"),
349            &env,
350            get_model_registry(),
351        );
352        assert_eq!(model, "explicit");
353    }
354
355    #[test]
356    fn resolve_model_omni_dev_model_beats_family_vars() {
357        for backend in [
358            AiBackend::Default,
359            AiBackend::ClaudeCli,
360            AiBackend::OpenAi,
361            AiBackend::Ollama,
362            AiBackend::Bedrock,
363        ] {
364            let env = MapEnv::new()
365                .with(MODEL_ENV, "global-model")
366                .with(CLAUDE_MODEL_ENV, "claude-var")
367                .with(OPENAI_MODEL_ENV, "openai-var")
368                .with(OLLAMA_MODEL_ENV, "ollama-var");
369            assert_eq!(
370                resolve_model(backend, None, &env, get_model_registry()),
371                "global-model",
372                "backend {backend:?}"
373            );
374        }
375    }
376
377    #[test]
378    fn resolve_model_claude_family_chain_order() {
379        let registry = get_model_registry();
380        for backend in [AiBackend::Default, AiBackend::ClaudeCli, AiBackend::Bedrock] {
381            let env = MapEnv::new()
382                .with(CLAUDE_MODEL_ENV, "a")
383                .with(CLAUDE_CODE_MODEL_ENV, "b")
384                .with(ANTHROPIC_MODEL_ENV, "c");
385            assert_eq!(resolve_model(backend, None, &env, registry), "a");
386
387            let env = MapEnv::new()
388                .with(CLAUDE_CODE_MODEL_ENV, "b")
389                .with(ANTHROPIC_MODEL_ENV, "c");
390            assert_eq!(resolve_model(backend, None, &env, registry), "b");
391
392            let env = MapEnv::new().with(ANTHROPIC_MODEL_ENV, "c");
393            assert_eq!(resolve_model(backend, None, &env, registry), "c");
394
395            assert_eq!(
396                resolve_model(backend, None, &MapEnv::new(), registry),
397                "claude-sonnet-5"
398            );
399        }
400    }
401
402    #[test]
403    fn resolve_model_claude_vars_do_not_leak_into_openai_or_ollama() {
404        let env = MapEnv::new()
405            .with(CLAUDE_MODEL_ENV, "claude-opus-4-6")
406            .with(ANTHROPIC_MODEL_ENV, "claude-opus-4-6");
407        let registry = get_model_registry();
408        assert_eq!(
409            resolve_model(AiBackend::OpenAi, None, &env, registry),
410            "gpt-5-mini"
411        );
412        assert_eq!(
413            resolve_model(AiBackend::Ollama, None, &env, registry),
414            "llama2"
415        );
416    }
417
418    #[test]
419    fn resolve_model_provider_vars() {
420        let registry = get_model_registry();
421        let env = MapEnv::new().with(OPENAI_MODEL_ENV, "gpt-4.1");
422        assert_eq!(
423            resolve_model(AiBackend::OpenAi, None, &env, registry),
424            "gpt-4.1"
425        );
426
427        let env = MapEnv::new().with(OLLAMA_MODEL_ENV, "qwen3");
428        assert_eq!(
429            resolve_model(AiBackend::Ollama, None, &env, registry),
430            "qwen3"
431        );
432    }
433
434    #[test]
435    fn resolve_model_skips_empty_values() {
436        // The docs promise "stopping at the first non-empty value".
437        let env = MapEnv::new()
438            .with(CLAUDE_MODEL_ENV, "")
439            .with(CLAUDE_CODE_MODEL_ENV, "b");
440        assert_eq!(
441            resolve_model(AiBackend::Default, None, &env, get_model_registry()),
442            "b"
443        );
444    }
445
446    #[test]
447    fn resolve_beta_header_explicit_wins() {
448        let env = MapEnv::new().with(BETA_HEADER_ENV, "env-key:env-value");
449        let explicit = Some(("k".to_string(), "v".to_string()));
450        let resolved = resolve_beta_header(explicit.clone(), &env).unwrap();
451        assert_eq!(resolved, explicit);
452    }
453
454    #[test]
455    fn resolve_beta_header_from_env() {
456        let env = MapEnv::new().with(BETA_HEADER_ENV, "anthropic-beta:output-128k-2025-02-19");
457        let resolved = resolve_beta_header(None, &env).unwrap();
458        assert_eq!(
459            resolved,
460            Some((
461                "anthropic-beta".to_string(),
462                "output-128k-2025-02-19".to_string()
463            ))
464        );
465    }
466
467    #[test]
468    fn resolve_beta_header_unset_is_none() {
469        assert_eq!(resolve_beta_header(None, &MapEnv::new()).unwrap(), None);
470    }
471
472    #[test]
473    fn resolve_beta_header_malformed_env_is_hard_error() {
474        let env = MapEnv::new().with(BETA_HEADER_ENV, "no-colon-here");
475        let err = resolve_beta_header(None, &env).unwrap_err().to_string();
476        assert!(err.contains("no-colon-here"), "unexpected error: {err}");
477    }
478
479    #[test]
480    fn parse_beta_header_valid() {
481        let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
482        assert_eq!(key, "anthropic-beta");
483        assert_eq!(value, "output-128k-2025-02-19");
484    }
485
486    #[test]
487    fn parse_beta_header_multiple_colons() {
488        // Only splits on the first colon
489        let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
490        assert_eq!(key, "key");
491        assert_eq!(value, "value:with:colons");
492    }
493
494    #[test]
495    fn parse_beta_header_missing_colon() {
496        let result = parse_beta_header("no-colon-here");
497        assert!(result.is_err());
498        let err_msg = result.unwrap_err().to_string();
499        assert!(err_msg.contains("no-colon-here"));
500    }
501
502    #[test]
503    fn parse_beta_header_empty_value() {
504        let (key, value) = parse_beta_header("key:").unwrap();
505        assert_eq!(key, "key");
506        assert_eq!(value, "");
507    }
508
509    #[test]
510    fn parse_beta_header_empty_key() {
511        let (key, value) = parse_beta_header(":value").unwrap();
512        assert_eq!(key, "");
513        assert_eq!(value, "value");
514    }
515}