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";
64pub const STRUCTURED_OUTPUT_DISABLE_ENV: &str = "OMNI_DEV_STRUCTURED_OUTPUT_DISABLE";
68
69const FALLBACK_CLAUDE_MODEL: &str = "claude-sonnet-5";
74const FALLBACK_OPENAI_MODEL: &str = "gpt-5-mini";
76const FALLBACK_OLLAMA_MODEL: &str = "llama2";
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
86pub enum AiBackend {
87 #[value(name = "default")]
90 Default,
91 #[value(name = "claude-cli")]
94 ClaudeCli,
95 #[value(name = "openai")]
98 OpenAi,
99 #[value(name = "ollama")]
102 Ollama,
103 #[value(name = "bedrock")]
106 Bedrock,
107}
108
109impl AiBackend {
110 pub fn env_value(self) -> &'static str {
112 match self {
113 Self::Default => "default",
114 Self::ClaudeCli => "claude-cli",
115 Self::OpenAi => "openai",
116 Self::Ollama => "ollama",
117 Self::Bedrock => "bedrock",
118 }
119 }
120
121 pub fn from_env_value(value: &str) -> Option<Self> {
126 match value {
127 "default" => Some(Self::Default),
128 "claude-cli" | "claude_cli" => Some(Self::ClaudeCli),
129 "openai" => Some(Self::OpenAi),
130 "ollama" => Some(Self::Ollama),
131 "bedrock" => Some(Self::Bedrock),
132 _ => None,
133 }
134 }
135}
136
137fn non_empty_var(env: &impl EnvSource, key: &str) -> Option<String> {
143 env.var(key).filter(|v| !v.is_empty())
144}
145
146pub fn resolve_backend(env: &impl EnvSource) -> Result<AiBackend> {
155 if let Some(raw) = non_empty_var(env, AI_BACKEND_ENV) {
156 return AiBackend::from_env_value(&raw).ok_or_else(|| {
157 anyhow!(
158 "Unknown {AI_BACKEND_ENV} value '{raw}'. \
159 Valid values: default, claude-cli, openai, ollama, bedrock"
160 )
161 });
162 }
163
164 let flag_true = |key| env.var(key).is_some_and(|v| v == "true");
165 if flag_true(USE_OLLAMA_ENV) {
166 Ok(AiBackend::Ollama)
167 } else if flag_true(USE_OPENAI_ENV) {
168 Ok(AiBackend::OpenAi)
169 } else if flag_true(USE_BEDROCK_ENV) {
170 Ok(AiBackend::Bedrock)
171 } else {
172 Ok(AiBackend::Default)
173 }
174}
175
176#[must_use]
194pub fn resolve_structured_output_disabled(env: &impl EnvSource) -> bool {
195 env.var(STRUCTURED_OUTPUT_DISABLE_ENV).is_some_and(|v| {
196 let v = v.trim().to_ascii_lowercase();
197 v == "1" || v == "true" || v == "yes"
198 })
199}
200
201pub fn resolve_model(
211 backend: AiBackend,
212 explicit: Option<&str>,
213 env: &impl EnvSource,
214 registry: &ModelRegistry,
215) -> String {
216 if let Some(model) = explicit.filter(|m| !m.is_empty()) {
217 return model.to_string();
218 }
219 if let Some(model) = non_empty_var(env, MODEL_ENV) {
220 return model;
221 }
222
223 match backend {
224 AiBackend::Default | AiBackend::ClaudeCli | AiBackend::Bedrock => {
225 [CLAUDE_MODEL_ENV, CLAUDE_CODE_MODEL_ENV, ANTHROPIC_MODEL_ENV]
226 .iter()
227 .find_map(|key| non_empty_var(env, key))
228 .unwrap_or_else(|| {
229 registry
230 .get_default_model("claude")
231 .unwrap_or(FALLBACK_CLAUDE_MODEL)
232 .to_string()
233 })
234 }
235 AiBackend::OpenAi => non_empty_var(env, OPENAI_MODEL_ENV).unwrap_or_else(|| {
236 registry
237 .get_default_model("openai")
238 .unwrap_or(FALLBACK_OPENAI_MODEL)
239 .to_string()
240 }),
241 AiBackend::Ollama => non_empty_var(env, OLLAMA_MODEL_ENV)
242 .unwrap_or_else(|| FALLBACK_OLLAMA_MODEL.to_string()),
243 }
244}
245
246pub fn parse_beta_header(s: &str) -> Result<(String, String)> {
248 let (k, v) = s
249 .split_once(':')
250 .ok_or_else(|| anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
251 Ok((k.to_string(), v.to_string()))
252}
253
254pub fn resolve_beta_header(
261 explicit: Option<(String, String)>,
262 env: &impl EnvSource,
263) -> Result<Option<(String, String)>> {
264 if explicit.is_some() {
265 return Ok(explicit);
266 }
267 match non_empty_var(env, BETA_HEADER_ENV) {
268 Some(raw) => parse_beta_header(&raw).map(Some),
269 None => Ok(None),
270 }
271}
272
273#[cfg(test)]
274#[allow(clippy::unwrap_used, clippy::expect_used)]
275mod tests {
276 use super::*;
277 use crate::claude::model_config::get_model_registry;
278 use crate::test_support::env::MapEnv;
279
280 #[test]
281 fn env_value_round_trips_for_all_backends() {
282 for backend in [
283 AiBackend::Default,
284 AiBackend::ClaudeCli,
285 AiBackend::OpenAi,
286 AiBackend::Ollama,
287 AiBackend::Bedrock,
288 ] {
289 assert_eq!(
290 AiBackend::from_env_value(backend.env_value()),
291 Some(backend)
292 );
293 }
294 }
295
296 #[test]
297 fn from_env_value_accepts_legacy_underscore_alias() {
298 assert_eq!(
299 AiBackend::from_env_value("claude_cli"),
300 Some(AiBackend::ClaudeCli)
301 );
302 }
303
304 #[test]
305 fn from_env_value_rejects_unknown() {
306 assert_eq!(AiBackend::from_env_value("gemini"), None);
307 assert_eq!(AiBackend::from_env_value(""), None);
308 }
309
310 #[test]
311 fn resolve_backend_defaults_to_direct_api() {
312 assert_eq!(resolve_backend(&MapEnv::new()).unwrap(), AiBackend::Default);
313 }
314
315 #[test]
316 fn resolve_backend_legacy_flags_in_order() {
317 let env = MapEnv::new()
319 .with(USE_OLLAMA_ENV, "true")
320 .with(USE_OPENAI_ENV, "true")
321 .with(USE_BEDROCK_ENV, "true");
322 assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Ollama);
323
324 let env = MapEnv::new()
325 .with(USE_OPENAI_ENV, "true")
326 .with(USE_BEDROCK_ENV, "true");
327 assert_eq!(resolve_backend(&env).unwrap(), AiBackend::OpenAi);
328
329 let env = MapEnv::new().with(USE_BEDROCK_ENV, "true");
330 assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Bedrock);
331 }
332
333 #[test]
334 fn resolve_backend_legacy_flags_require_literal_true() {
335 let env = MapEnv::new().with(USE_OLLAMA_ENV, "1");
336 assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Default);
337 }
338
339 #[test]
340 fn resolve_backend_env_var_overrides_legacy_flags() {
341 for (value, expected) in [
342 ("default", AiBackend::Default),
343 ("claude-cli", AiBackend::ClaudeCli),
344 ("openai", AiBackend::OpenAi),
345 ("bedrock", AiBackend::Bedrock),
346 ] {
347 let env = MapEnv::new()
348 .with(AI_BACKEND_ENV, value)
349 .with(USE_OLLAMA_ENV, "true");
350 assert_eq!(resolve_backend(&env).unwrap(), expected, "value {value}");
351 }
352 }
353
354 #[test]
357 fn resolve_structured_output_disabled_reads_truthy_values() {
358 for value in ["1", "true", "TRUE", "yes", " true "] {
359 let env = MapEnv::new().with(STRUCTURED_OUTPUT_DISABLE_ENV, value);
360 assert!(
361 resolve_structured_output_disabled(&env),
362 "value {value:?} should disable structured output"
363 );
364 }
365
366 for value in ["", "0", "false", "no", "maybe"] {
367 let env = MapEnv::new().with(STRUCTURED_OUTPUT_DISABLE_ENV, value);
368 assert!(
369 !resolve_structured_output_disabled(&env),
370 "value {value:?} should leave structured output enabled"
371 );
372 }
373
374 assert!(!resolve_structured_output_disabled(&MapEnv::new()));
375 }
376
377 #[test]
378 fn resolve_backend_unknown_value_is_hard_error() {
379 let env = MapEnv::new().with(AI_BACKEND_ENV, "junk");
380 let err = resolve_backend(&env).unwrap_err().to_string();
381 assert!(err.contains("junk"), "unexpected error: {err}");
382 assert!(err.contains("claude-cli"), "unexpected error: {err}");
383 }
384
385 #[test]
386 fn resolve_backend_empty_value_reads_as_unset() {
387 let env = MapEnv::new()
388 .with(AI_BACKEND_ENV, "")
389 .with(USE_OLLAMA_ENV, "true");
390 assert_eq!(resolve_backend(&env).unwrap(), AiBackend::Ollama);
391 }
392
393 #[test]
394 fn resolve_model_explicit_wins_everywhere() {
395 let env = MapEnv::new()
396 .with(MODEL_ENV, "from-omni-dev-model")
397 .with(CLAUDE_MODEL_ENV, "from-claude-model");
398 let model = resolve_model(
399 AiBackend::Default,
400 Some("explicit"),
401 &env,
402 get_model_registry(),
403 );
404 assert_eq!(model, "explicit");
405 }
406
407 #[test]
408 fn resolve_model_omni_dev_model_beats_family_vars() {
409 for backend in [
410 AiBackend::Default,
411 AiBackend::ClaudeCli,
412 AiBackend::OpenAi,
413 AiBackend::Ollama,
414 AiBackend::Bedrock,
415 ] {
416 let env = MapEnv::new()
417 .with(MODEL_ENV, "global-model")
418 .with(CLAUDE_MODEL_ENV, "claude-var")
419 .with(OPENAI_MODEL_ENV, "openai-var")
420 .with(OLLAMA_MODEL_ENV, "ollama-var");
421 assert_eq!(
422 resolve_model(backend, None, &env, get_model_registry()),
423 "global-model",
424 "backend {backend:?}"
425 );
426 }
427 }
428
429 #[test]
430 fn resolve_model_claude_family_chain_order() {
431 let registry = get_model_registry();
432 for backend in [AiBackend::Default, AiBackend::ClaudeCli, AiBackend::Bedrock] {
433 let env = MapEnv::new()
434 .with(CLAUDE_MODEL_ENV, "a")
435 .with(CLAUDE_CODE_MODEL_ENV, "b")
436 .with(ANTHROPIC_MODEL_ENV, "c");
437 assert_eq!(resolve_model(backend, None, &env, registry), "a");
438
439 let env = MapEnv::new()
440 .with(CLAUDE_CODE_MODEL_ENV, "b")
441 .with(ANTHROPIC_MODEL_ENV, "c");
442 assert_eq!(resolve_model(backend, None, &env, registry), "b");
443
444 let env = MapEnv::new().with(ANTHROPIC_MODEL_ENV, "c");
445 assert_eq!(resolve_model(backend, None, &env, registry), "c");
446
447 assert_eq!(
448 resolve_model(backend, None, &MapEnv::new(), registry),
449 "claude-sonnet-5"
450 );
451 }
452 }
453
454 #[test]
455 fn resolve_model_claude_vars_do_not_leak_into_openai_or_ollama() {
456 let env = MapEnv::new()
457 .with(CLAUDE_MODEL_ENV, "claude-opus-4-6")
458 .with(ANTHROPIC_MODEL_ENV, "claude-opus-4-6");
459 let registry = get_model_registry();
460 assert_eq!(
461 resolve_model(AiBackend::OpenAi, None, &env, registry),
462 "gpt-5-mini"
463 );
464 assert_eq!(
465 resolve_model(AiBackend::Ollama, None, &env, registry),
466 "llama2"
467 );
468 }
469
470 #[test]
471 fn resolve_model_provider_vars() {
472 let registry = get_model_registry();
473 let env = MapEnv::new().with(OPENAI_MODEL_ENV, "gpt-4.1");
474 assert_eq!(
475 resolve_model(AiBackend::OpenAi, None, &env, registry),
476 "gpt-4.1"
477 );
478
479 let env = MapEnv::new().with(OLLAMA_MODEL_ENV, "qwen3");
480 assert_eq!(
481 resolve_model(AiBackend::Ollama, None, &env, registry),
482 "qwen3"
483 );
484 }
485
486 #[test]
487 fn resolve_model_skips_empty_values() {
488 let env = MapEnv::new()
490 .with(CLAUDE_MODEL_ENV, "")
491 .with(CLAUDE_CODE_MODEL_ENV, "b");
492 assert_eq!(
493 resolve_model(AiBackend::Default, None, &env, get_model_registry()),
494 "b"
495 );
496 }
497
498 #[test]
499 fn resolve_beta_header_explicit_wins() {
500 let env = MapEnv::new().with(BETA_HEADER_ENV, "env-key:env-value");
501 let explicit = Some(("k".to_string(), "v".to_string()));
502 let resolved = resolve_beta_header(explicit.clone(), &env).unwrap();
503 assert_eq!(resolved, explicit);
504 }
505
506 #[test]
507 fn resolve_beta_header_from_env() {
508 let env = MapEnv::new().with(BETA_HEADER_ENV, "anthropic-beta:output-128k-2025-02-19");
509 let resolved = resolve_beta_header(None, &env).unwrap();
510 assert_eq!(
511 resolved,
512 Some((
513 "anthropic-beta".to_string(),
514 "output-128k-2025-02-19".to_string()
515 ))
516 );
517 }
518
519 #[test]
520 fn resolve_beta_header_unset_is_none() {
521 assert_eq!(resolve_beta_header(None, &MapEnv::new()).unwrap(), None);
522 }
523
524 #[test]
525 fn resolve_beta_header_malformed_env_is_hard_error() {
526 let env = MapEnv::new().with(BETA_HEADER_ENV, "no-colon-here");
527 let err = resolve_beta_header(None, &env).unwrap_err().to_string();
528 assert!(err.contains("no-colon-here"), "unexpected error: {err}");
529 }
530
531 #[test]
532 fn parse_beta_header_valid() {
533 let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
534 assert_eq!(key, "anthropic-beta");
535 assert_eq!(value, "output-128k-2025-02-19");
536 }
537
538 #[test]
539 fn parse_beta_header_multiple_colons() {
540 let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
542 assert_eq!(key, "key");
543 assert_eq!(value, "value:with:colons");
544 }
545
546 #[test]
547 fn parse_beta_header_missing_colon() {
548 let result = parse_beta_header("no-colon-here");
549 assert!(result.is_err());
550 let err_msg = result.unwrap_err().to_string();
551 assert!(err_msg.contains("no-colon-here"));
552 }
553
554 #[test]
555 fn parse_beta_header_empty_value() {
556 let (key, value) = parse_beta_header("key:").unwrap();
557 assert_eq!(key, "key");
558 assert_eq!(value, "");
559 }
560
561 #[test]
562 fn parse_beta_header_empty_key() {
563 let (key, value) = parse_beta_header(":value").unwrap();
564 assert_eq!(key, "");
565 assert_eq!(value, "value");
566 }
567}