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