1use anyhow::{anyhow, Result};
35
36use crate::claude::model_config::ModelRegistry;
37use crate::utils::env::EnvSource;
38
39pub const AI_BACKEND_ENV: &str = "OMNI_DEV_AI_BACKEND";
42pub const MODEL_ENV: &str = "OMNI_DEV_MODEL";
45pub const BETA_HEADER_ENV: &str = "OMNI_DEV_BETA_HEADER";
48pub const CLAUDE_MODEL_ENV: &str = "CLAUDE_MODEL";
50pub const CLAUDE_CODE_MODEL_ENV: &str = "CLAUDE_CODE_MODEL";
52pub const ANTHROPIC_MODEL_ENV: &str = "ANTHROPIC_MODEL";
54pub const OPENAI_MODEL_ENV: &str = "OPENAI_MODEL";
56pub const OLLAMA_MODEL_ENV: &str = "OLLAMA_MODEL";
58pub const USE_OPENAI_ENV: &str = "USE_OPENAI";
60pub const USE_OLLAMA_ENV: &str = "USE_OLLAMA";
62pub const USE_BEDROCK_ENV: &str = "CLAUDE_CODE_USE_BEDROCK";
64
65const FALLBACK_CLAUDE_MODEL: &str = "claude-sonnet-4-6";
67const FALLBACK_OPENAI_MODEL: &str = "gpt-5-mini";
69const FALLBACK_OLLAMA_MODEL: &str = "llama2";
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
79pub enum AiBackend {
80 #[value(name = "default")]
83 Default,
84 #[value(name = "claude-cli")]
87 ClaudeCli,
88 #[value(name = "openai")]
91 OpenAi,
92 #[value(name = "ollama")]
95 Ollama,
96 #[value(name = "bedrock")]
99 Bedrock,
100}
101
102impl AiBackend {
103 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 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
130fn non_empty_var(env: &impl EnvSource, key: &str) -> Option<String> {
136 env.var(key).filter(|v| !v.is_empty())
137}
138
139pub 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
169pub 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
214pub 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
222pub 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 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 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 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}