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-5";
70const FALLBACK_OPENAI_MODEL: &str = "gpt-5-mini";
72const FALLBACK_OLLAMA_MODEL: &str = "llama2";
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
82pub enum AiBackend {
83 #[value(name = "default")]
86 Default,
87 #[value(name = "claude-cli")]
90 ClaudeCli,
91 #[value(name = "openai")]
94 OpenAi,
95 #[value(name = "ollama")]
98 Ollama,
99 #[value(name = "bedrock")]
102 Bedrock,
103}
104
105impl AiBackend {
106 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 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
133fn non_empty_var(env: &impl EnvSource, key: &str) -> Option<String> {
139 env.var(key).filter(|v| !v.is_empty())
140}
141
142pub 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
172pub 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
217pub 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
225pub 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 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 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 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}