1use std::env;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Flavor {
26 OpenAi,
28 Anthropic,
30}
31
32#[derive(Clone, Debug)]
34pub struct PresetModel {
35 pub id: &'static str,
36 pub display_name: &'static str,
37 pub context_window: usize,
38 pub max_output_tokens: Option<u64>,
39 pub supports_images: bool,
41}
42
43#[derive(Clone, Debug)]
45pub struct BackendModel {
46 pub upstream: String,
48 pub display: String,
50 pub context_window: usize,
52 pub max_output_tokens: Option<u64>,
54 pub supports_images: bool,
56}
57
58#[derive(Clone, Debug)]
60pub struct Preset {
61 pub id: &'static str,
64 pub prefix: &'static str,
66 pub key_envs: &'static [&'static str],
68 pub base_envs: &'static [&'static str],
70 pub default_base_url: Option<&'static str>,
72 pub flavor: Flavor,
73 pub models: &'static [PresetModel],
74 pub no_auth: bool,
76 pub oauth_subscription: bool,
78}
79
80pub const PRESETS: &[Preset] = &[
81 Preset {
82 id: "openai",
83 prefix: "OPENAI",
84 key_envs: &["OPENAI_API_KEY"],
85 base_envs: &["OPENAI_BASE_URL"],
86 default_base_url: Some("https://api.openai.com/v1"),
87 flavor: Flavor::OpenAi,
88 no_auth: false,
89 oauth_subscription: false,
90 models: crate::catalog_generated::OPENAI,
91 },
92 Preset {
93 id: "anthropic",
94 prefix: "ANTHROPIC",
95 key_envs: &[
96 "ANTHROPIC_API_KEY",
97 "ANTHROPIC_AUTH_TOKEN",
98 "ANTHROPIC_OAUTH_TOKEN",
99 ],
100 base_envs: &["ANTHROPIC_BASE_URL"],
101 default_base_url: Some("https://api.anthropic.com/v1"),
102 flavor: Flavor::Anthropic,
103 no_auth: false,
104 oauth_subscription: false,
105 models: crate::catalog_generated::ANTHROPIC,
106 },
107 Preset {
108 id: "claude-oauth",
109 prefix: "CLAUDE_OAUTH",
110 key_envs: &[],
111 base_envs: &["CLAUDE_OAUTH_BASE_URL"],
112 default_base_url: Some("https://api.anthropic.com"),
113 flavor: Flavor::Anthropic,
114 no_auth: false,
115 oauth_subscription: true,
116 models: crate::catalog_generated::CLAUDE_OAUTH,
117 },
118 Preset {
119 id: "deepseek",
120 prefix: "DEEPSEEK",
121 key_envs: &["DEEPSEEK_API_KEY"],
122 base_envs: &["DEEPSEEK_BASE_URL"],
123 default_base_url: Some("https://api.deepseek.com/v1"),
124 flavor: Flavor::OpenAi,
125 no_auth: false,
126 oauth_subscription: false,
127 models: crate::catalog_generated::DEEPSEEK,
128 },
129 Preset {
130 id: "groq",
131 prefix: "GROQ",
132 key_envs: &["GROQ_API_KEY"],
133 base_envs: &["GROQ_BASE_URL"],
134 default_base_url: Some("https://api.groq.com/openai/v1"),
135 flavor: Flavor::OpenAi,
136 no_auth: false,
137 oauth_subscription: false,
138 models: crate::catalog_generated::GROQ,
139 },
140 Preset {
141 id: "cerebras",
142 prefix: "CEREBRAS",
143 key_envs: &["CEREBRAS_API_KEY"],
144 base_envs: &["CEREBRAS_BASE_URL"],
145 default_base_url: Some("https://api.cerebras.ai/v1"),
146 flavor: Flavor::OpenAi,
147 no_auth: false,
148 oauth_subscription: false,
149 models: crate::catalog_generated::CEREBRAS,
150 },
151 Preset {
152 id: "xai",
153 prefix: "XAI",
154 key_envs: &["XAI_API_KEY"],
155 base_envs: &["XAI_BASE_URL"],
156 default_base_url: Some("https://api.x.ai/v1"),
157 flavor: Flavor::OpenAi,
158 no_auth: false,
159 oauth_subscription: false,
160 models: crate::catalog_generated::XAI,
161 },
162 Preset {
163 id: "mistral",
164 prefix: "MISTRAL",
165 key_envs: &["MISTRAL_API_KEY"],
166 base_envs: &["MISTRAL_BASE_URL"],
167 default_base_url: Some("https://api.mistral.ai/v1"),
168 flavor: Flavor::OpenAi,
169 no_auth: false,
170 oauth_subscription: false,
171 models: crate::catalog_generated::MISTRAL,
172 },
173 Preset {
174 id: "gemini",
175 prefix: "GEMINI",
176 key_envs: &[
177 "GEMINI_API_KEY",
178 "GOOGLE_GENERATIVE_AI_API_KEY",
179 "GOOGLE_API_KEY",
180 ],
181 base_envs: &["GEMINI_BASE_URL", "GOOGLE_GENERATIVE_AI_BASE_URL"],
182 default_base_url: Some("https://generativelanguage.googleapis.com/v1beta/openai/"),
183 flavor: Flavor::OpenAi,
184 no_auth: false,
185 oauth_subscription: false,
186 models: crate::catalog_generated::GEMINI,
187 },
188 Preset {
189 id: "together",
190 prefix: "TOGETHER",
191 key_envs: &["TOGETHER_API_KEY"],
192 base_envs: &["TOGETHER_BASE_URL"],
193 default_base_url: Some("https://api.together.xyz/v1"),
194 flavor: Flavor::OpenAi,
195 no_auth: false,
196 oauth_subscription: false,
197 models: crate::catalog_generated::TOGETHER,
198 },
199 Preset {
200 id: "fireworks",
201 prefix: "FIREWORKS",
202 key_envs: &["FIREWORKS_API_KEY"],
203 base_envs: &["FIREWORKS_BASE_URL"],
204 default_base_url: Some("https://api.fireworks.ai/inference/v1"),
205 flavor: Flavor::OpenAi,
206 no_auth: false,
207 oauth_subscription: false,
208 models: crate::catalog_generated::FIREWORKS,
209 },
210 Preset {
211 id: "openrouter",
212 prefix: "OPENROUTER",
213 key_envs: &["OPENROUTER_API_KEY"],
214 base_envs: &["OPENROUTER_BASE_URL"],
215 default_base_url: Some("https://openrouter.ai/api/v1"),
216 flavor: Flavor::OpenAi,
217 no_auth: false,
218 oauth_subscription: false,
219 models: crate::catalog_generated::OPENROUTER,
220 },
221 Preset {
222 id: "nvidia",
223 prefix: "NVIDIA",
224 key_envs: &["NVIDIA_API_KEY"],
225 base_envs: &["NVIDIA_BASE_URL"],
226 default_base_url: Some("https://integrate.api.nvidia.com/v1"),
227 flavor: Flavor::OpenAi,
228 no_auth: false,
229 oauth_subscription: false,
230 models: crate::catalog_generated::NVIDIA,
231 },
232 Preset {
233 id: "moonshot",
234 prefix: "MOONSHOT",
235 key_envs: &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
236 base_envs: &["MOONSHOT_BASE_URL", "KIMI_BASE_URL"],
237 default_base_url: Some("https://api.moonshot.ai/v1"),
238 flavor: Flavor::OpenAi,
239 no_auth: false,
240 oauth_subscription: false,
241 models: crate::catalog_generated::MOONSHOT,
242 },
243 Preset {
244 id: "zai",
245 prefix: "ZAI",
246 key_envs: &["ZAI_API_KEY"],
247 base_envs: &["ZAI_BASE_URL"],
248 default_base_url: Some("https://api.z.ai/api/coding/paas/v4"),
249 flavor: Flavor::OpenAi,
250 no_auth: false,
251 oauth_subscription: false,
252 models: crate::catalog_generated::ZAI,
253 },
254 Preset {
255 id: "minimax",
256 prefix: "MINIMAX",
257 key_envs: &["MINIMAX_API_KEY", "MINIMAX_CN_API_KEY"],
258 base_envs: &["MINIMAX_BASE_URL"],
259 default_base_url: Some("https://api.minimax.io/v1"),
260 flavor: Flavor::OpenAi,
261 no_auth: false,
262 oauth_subscription: false,
263 models: crate::catalog_generated::MINIMAX,
264 },
265 Preset {
266 id: "qwen",
267 prefix: "QWEN",
268 key_envs: &[
269 "QWEN_TOKEN_PLAN_API_KEY",
270 "QWEN_TOKEN_PLAN_CN_API_KEY",
271 "DASHSCOPE_API_KEY",
272 ],
273 base_envs: &["QWEN_BASE_URL", "DASHSCOPE_BASE_URL"],
274 default_base_url: Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
275 flavor: Flavor::OpenAi,
276 no_auth: false,
277 oauth_subscription: false,
278 models: crate::catalog_generated::QWEN,
279 },
280 Preset {
281 id: "baseten",
282 prefix: "BASETEN",
283 key_envs: &["BASETEN_API_KEY"],
284 base_envs: &["BASETEN_BASE_URL"],
285 default_base_url: None, flavor: Flavor::OpenAi,
287 no_auth: false,
288 oauth_subscription: false,
289 models: &[],
290 },
291 Preset {
292 id: "huggingface",
293 prefix: "HF",
294 key_envs: &["HF_TOKEN", "HUGGINGFACE_API_KEY"],
295 base_envs: &["HF_BASE_URL"],
296 default_base_url: Some("https://router.huggingface.co/v1"),
297 flavor: Flavor::OpenAi,
298 no_auth: false,
299 oauth_subscription: false,
300 models: crate::catalog_generated::HUGGINGFACE,
301 },
302 Preset {
303 id: "vercel-gateway",
304 prefix: "AI_GATEWAY",
305 key_envs: &["AI_GATEWAY_API_KEY"],
306 base_envs: &["AI_GATEWAY_BASE_URL"],
307 default_base_url: Some("https://ai-gateway.vercel.sh/v1"),
308 flavor: Flavor::OpenAi,
309 no_auth: false,
310 oauth_subscription: false,
311 models: crate::catalog_generated::VERCEL_GATEWAY,
312 },
313 Preset {
314 id: "opencode-zen",
315 prefix: "OPENCODE",
316 key_envs: &["OPENCODE_API_KEY"],
317 base_envs: &["OPENCODE_BASE_URL"],
318 default_base_url: Some("https://opencode.ai/zen/v1"),
319 flavor: Flavor::OpenAi,
320 no_auth: false,
321 oauth_subscription: false,
322 models: crate::catalog_generated::OPENCODE_ZEN,
323 },
324 Preset {
325 id: "azure",
326 prefix: "AZURE_OPENAI",
327 key_envs: &["AZURE_OPENAI_API_KEY", "AZURE_API_KEY"],
328 base_envs: &["AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT"],
329 default_base_url: None, flavor: Flavor::OpenAi,
331 no_auth: false,
332 oauth_subscription: false,
333 models: crate::catalog_generated::AZURE,
334 },
335 Preset {
336 id: "ollama",
337 prefix: "OLLAMA",
338 key_envs: &[],
339 base_envs: &["OLLAMA_HOST", "OLLAMA_BASE_URL"],
340 default_base_url: Some("http://127.0.0.1:11434/v1"),
341 flavor: Flavor::OpenAi,
342 models: &[PresetModel {
343 id: "qwen3-coder:32b",
344 display_name: "Qwen3 Coder 32B (Ollama)",
345 context_window: 128_000,
346 max_output_tokens: Some(32_000),
347 supports_images: true,
348 }],
349 no_auth: true,
350 oauth_subscription: false,
351 },
352 Preset {
353 id: "lmstudio",
354 prefix: "LMSTUDIO",
355 key_envs: &[],
356 base_envs: &["LMSTUDIO_BASE_URL"],
357 default_base_url: Some("http://127.0.0.1:1234/v1"),
358 flavor: Flavor::OpenAi,
359 models: &[],
360 no_auth: true,
361 oauth_subscription: false,
362 },
363 Preset {
364 id: "vllm",
365 prefix: "VLLM",
366 key_envs: &[],
367 base_envs: &["VLLM_BASE_URL"],
368 default_base_url: Some("http://127.0.0.1:8000/v1"),
369 flavor: Flavor::OpenAi,
370 models: &[],
371 no_auth: true,
372 oauth_subscription: false,
373 },
374];
375
376#[derive(Clone, Debug)]
378pub struct ResolvedBackend {
379 pub preset_id: &'static str,
380 pub flavor: Flavor,
381 pub base_url: String,
382 pub api_key: String,
383 pub models: Vec<BackendModel>,
385 pub oauth: Option<crate::oauth::OAuthSessionHandle>,
387 pub discover: bool,
389 pub extra_headers: Vec<(String, String)>,
391}
392
393fn non_empty(var: &str) -> Option<String> {
394 env::var(var)
395 .ok()
396 .map(|v| v.trim().to_owned())
397 .filter(|v| !v.is_empty())
398}
399
400fn first_set(vars: &[&str]) -> Option<String> {
401 vars.iter().find_map(|v| non_empty(v))
402}
403
404fn model_overrides(prefix: &str) -> Option<Vec<String>> {
405 if let Some(list) = non_empty(&format!("{prefix}_MODELS")) {
406 let ids: Vec<String> = list
407 .split(',')
408 .map(|s| s.trim().to_owned())
409 .filter(|s| !s.is_empty())
410 .collect();
411 if !ids.is_empty() {
412 return Some(ids);
413 }
414 }
415 non_empty(&format!("{prefix}_MODEL")).map(|m| vec![m])
416}
417
418fn display_for(id: &str) -> String {
419 id.to_owned()
420}
421
422impl Preset {
423 fn resolve(&self) -> Option<ResolvedBackend> {
424 if self.oauth_subscription {
425 return self.resolve_oauth();
426 }
427 if self.no_auth {
428 let enabled = env::var("INFINITY_LOCAL").ok().as_deref() == Some("1")
431 || self.base_envs.iter().any(|v| non_empty(v).is_some())
432 || non_empty(&format!("{}_MODELS", self.prefix)).is_some()
433 || non_empty(&format!("{}_MODEL", self.prefix)).is_some();
434 if !enabled {
435 return None;
436 }
437 let base_url =
438 first_set(self.base_envs).or_else(|| self.default_base_url.map(str::to_owned))?;
439 let models = resolve_models(self, &base_url);
440 if models.is_empty() {
441 return None;
442 }
443 return Some(ResolvedBackend {
446 preset_id: self.id,
447 flavor: self.flavor,
448 base_url: trim_trailing_slash(&base_url).to_owned(),
449 api_key: String::new(),
450 oauth: None,
451 discover: true,
452 models,
453 extra_headers: Vec::new(),
454 });
455 }
456
457 let api_key = first_set(self.key_envs)?;
458 let discover = std::env::var(format!("{}_DISCOVER", self.prefix))
461 .ok()
462 .as_deref()
463 == Some("1");
464 let mut base_url = first_set(self.base_envs)
465 .or_else(|| non_empty(&format!("{}_BASE_URL", self.prefix)))
466 .or_else(|| self.default_base_url.map(str::to_owned));
467
468 if self.id == "azure" && base_url.is_none() {
471 if let Some(resource) =
472 non_empty("AZURE_OPENAI_RESOURCE_NAME").or_else(|| non_empty("AZURE_RESOURCE_NAME"))
473 {
474 base_url = Some(format!(
475 "https://{}.openai.azure.com/openai/v1",
476 resource.trim()
477 ));
478 }
479 }
480 let base_url = base_url?;
482 let models = resolve_models(self, &base_url);
483 if models.is_empty() && !discover {
484 return None;
485 }
486
487 let mut extra_headers = Vec::new();
488 if self.id == "openrouter" {
489 extra_headers.push((
490 "HTTP-Referer".to_owned(),
491 non_empty("OPENROUTER_APP_URL")
492 .unwrap_or_else(|| "https://github.com/hydro-project/infinity".to_owned()),
493 ));
494 extra_headers.push((
495 "X-Title".to_owned(),
496 non_empty("OPENROUTER_APP_NAME").unwrap_or_else(|| "infinity".to_owned()),
497 ));
498 }
499
500 Some(ResolvedBackend {
501 preset_id: self.id,
502 flavor: self.flavor,
503 base_url: trim_trailing_slash(&base_url).to_owned(),
504 api_key,
505 oauth: None,
506 discover,
507 models,
508 extra_headers,
509 })
510 }
511}
512
513impl Preset {
514 fn resolve_oauth(&self) -> Option<ResolvedBackend> {
517 use crate::oauth::{self, OAuthSession, OAuthSessionHandle};
518 use std::sync::{Arc, RwLock};
519
520 let static_token = non_empty(oauth::STATIC_TOKEN_ENV);
521 let opted_in =
522 std::env::var(oauth::OPT_IN_ENV).ok().as_deref() == Some("1") || static_token.is_some();
523 if !opted_in {
524 return None;
525 }
526 let session = if let Some(token) = static_token {
527 OAuthSession {
528 access_token: token,
529 refresh_token: String::new(),
530 expires_at_ms: i64::MAX,
531 persist_path: None,
532 }
533 } else if let Some(loaded) = oauth::load_credentials() {
534 if loaded.access_token.trim().is_empty() {
535 return None;
536 }
537 OAuthSession {
538 access_token: loaded.access_token,
539 refresh_token: loaded.refresh_token,
540 expires_at_ms: loaded.expires_at_ms,
541 persist_path: loaded.persist_path,
542 }
543 } else {
544 tracing::warn!(
545 "claude-oauth: opted in but no credentials found; run \
546 `infinity-provider-models-dev login`, `claude auth login`, \
547 or set ANTHROPIC_OAUTH_TOKEN"
548 );
549 return None;
550 };
551 if !session.refresh_token.trim().is_empty()
552 && oauth::needs_refresh(session.expires_at_ms, oauth::now_ms())
553 {
554 tracing::info!("claude-oauth: stored bearer expired; will refresh on first use");
555 }
556 let base_url =
557 first_set(self.base_envs).or_else(|| self.default_base_url.map(str::to_owned))?;
558 let base_url = trim_trailing_slash(&base_url).to_owned();
559 let models = resolve_models(self, &base_url);
560 if models.is_empty() {
561 return None;
562 }
563 let handle: OAuthSessionHandle = Arc::new(RwLock::new(session));
564 Some(ResolvedBackend {
565 preset_id: self.id,
566 flavor: self.flavor,
567 base_url,
568 api_key: String::new(),
569 oauth: Some(handle),
570 discover: false,
571 models,
572 extra_headers: Vec::new(),
573 })
574 }
575}
576
577fn preset_to_backend(m: &PresetModel) -> BackendModel {
578 BackendModel {
579 upstream: m.id.to_owned(),
580 display: m.display_name.to_owned(),
581 context_window: m.context_window,
582 max_output_tokens: m.max_output_tokens,
583 supports_images: m.supports_images,
584 }
585}
586
587fn resolve_models(preset: &Preset, _base_url: &str) -> Vec<BackendModel> {
588 if let Some(ids) = model_overrides(preset.prefix) {
589 return ids
590 .into_iter()
591 .map(|id| {
592 let builtin = preset.models.iter().find(|m| m.id == id);
593 builtin.map_or_else(
594 || BackendModel {
595 upstream: id.clone(),
596 display: display_for(&id),
597 context_window: 200_000,
598 max_output_tokens: Some(32_000),
599 supports_images: true,
600 },
601 preset_to_backend,
602 )
603 })
604 .collect();
605 }
606 preset.models.iter().map(preset_to_backend).collect()
607}
608
609pub fn resolve_backends() -> Vec<ResolvedBackend> {
611 let mut backends: Vec<ResolvedBackend> = PRESETS.iter().filter_map(Preset::resolve).collect();
612
613 if let Some(base_url) = non_empty("INFINITY_OPENAI_BASE_URL") {
617 let api_key = non_empty("INFINITY_OPENAI_API_KEY").unwrap_or_default();
618 let ids = non_empty("INFINITY_OPENAI_MODELS")
619 .map(|list| {
620 list.split(',')
621 .map(|s| s.trim().to_owned())
622 .filter(|s| !s.is_empty())
623 .collect::<Vec<_>>()
624 })
625 .filter(|v| !v.is_empty())
626 .unwrap_or_default();
627 backends.push(ResolvedBackend {
628 preset_id: "custom",
629 flavor: Flavor::OpenAi,
630 base_url: trim_trailing_slash(&base_url).to_owned(),
631 api_key,
632 oauth: None,
633 discover: true,
634 models: ids
635 .into_iter()
636 .map(|id| BackendModel {
637 upstream: id.clone(),
638 display: display_for(&id),
639 context_window: 200_000,
640 max_output_tokens: Some(32_000),
641 supports_images: true,
642 })
643 .collect(),
644 extra_headers: Vec::new(),
645 });
646 }
647
648 backends
649}
650
651fn trim_trailing_slash(s: &str) -> &str {
652 s.trim_end_matches('/')
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn model_overrides_empty_without_env() {
661 assert!(model_overrides("INFINITY_PROVIDER_MODELS_DEV_UNSET_TEST_PREFIX").is_none());
663 }
664
665 #[test]
668 fn oauth_preset_opt_in_flow() {
669 let saved_token = std::env::var("ANTHROPIC_OAUTH_TOKEN").ok();
670 let saved_opt_in = std::env::var("INFINITY_CLAUDE_OAUTH").ok();
671 let restore = |saved_token: &Option<String>, saved_opt_in: &Option<String>| {
672 if let Some(token) = saved_token {
673 unsafe {
674 std::env::set_var("ANTHROPIC_OAUTH_TOKEN", token);
675 }
676 } else {
677 unsafe {
678 std::env::remove_var("ANTHROPIC_OAUTH_TOKEN");
679 }
680 }
681 if let Some(opt_in) = saved_opt_in {
682 unsafe {
683 std::env::set_var("INFINITY_CLAUDE_OAUTH", opt_in);
684 }
685 } else {
686 unsafe {
687 std::env::remove_var("INFINITY_CLAUDE_OAUTH");
688 }
689 }
690 };
691 let preset = PRESETS
692 .iter()
693 .find(|p| p.id == "claude-oauth")
694 .expect("claude-oauth preset exists");
695
696 unsafe {
697 std::env::remove_var("ANTHROPIC_OAUTH_TOKEN");
698 std::env::remove_var("INFINITY_CLAUDE_OAUTH");
699 }
700 assert!(preset.resolve().is_none());
701
702 unsafe {
703 std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oauth01-test");
704 }
705 let backend = preset.resolve().expect("static token activates preset");
706 assert_eq!(backend.preset_id, "claude-oauth");
707 assert!(backend.oauth.is_some());
708 assert_eq!(backend.models.len(), 3);
709
710 restore(&saved_token, &saved_opt_in);
711 }
712
713 #[test]
714 fn model_overrides_round_trip() {
715 let prefix = "INFINITY_PROVIDER_MODELS_DEV_TEST";
716 let var = format!("{prefix}_MODELS");
717 unsafe {
718 std::env::set_var(&var, "a-model,b-model");
719 }
720 assert_eq!(
721 model_overrides(prefix),
722 Some(vec!["a-model".to_owned(), "b-model".to_owned()])
723 );
724 unsafe {
725 std::env::remove_var(&var);
726 }
727 assert!(model_overrides(prefix).is_none());
728 }
729}