Skip to main content

infinity_provider_models_dev/
preset.rs

1//! Backend preset table: one entry per upstream provider/gateway.
2//!
3//! Each preset maps the API-key env vars documented by
4//! [pi](https://pi.dev/docs/latest/providers) and the provider catalog used
5//! by [opencode](https://opencode.ai/v2/docs/providers/) (via models.dev)
6//! to an `OpenAI`-compatible (or `Anthropic`-native) base URL plus a small
7//! default model list. Presets activate when one of their key env vars is
8//! set; everything else has a default so `export FOO_API_KEY=...` is enough.
9//!
10//! Per-backend overrides (all optional):
11//! - `<PREFIX>_BASE_URL` — override the endpoint (also honors the well-known
12//!   vars listed in `base_envs`, e.g. `OPENAI_BASE_URL`).
13//! - `<PREFIX>_MODELS` — comma-separated model ids to advertise instead of
14//!   the built-ins.
15//! - `<PREFIX>_MODEL` — single model id (same effect, one entry).
16//!
17//! A fully custom `OpenAI`-compatible endpoint works without a preset:
18//! - `INFINITY_OPENAI_BASE_URL` + `INFINITY_OPENAI_API_KEY` (+ optional
19//!   `INFINITY_OPENAI_MODELS`).
20
21use std::env;
22
23/// Wire flavor of a backend.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Flavor {
26    /// `OpenAI` Chat Completions (`/chat/completions`, SSE `data:` frames).
27    OpenAi,
28    /// `Anthropic` Messages (`/v1/messages`, SSE `event:` frames).
29    Anthropic,
30}
31
32/// A built-in model advertised for a backend.
33#[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    /// Whether the model accepts image input.
40    pub supports_images: bool,
41}
42
43/// One model offered by a resolved backend.
44#[derive(Clone, Debug)]
45pub struct BackendModel {
46    /// Upstream model id sent to the endpoint.
47    pub upstream: String,
48    /// Human-readable name shown in pickers.
49    pub display: String,
50    /// Context window size in tokens (used for compaction thresholds).
51    pub context_window: usize,
52    /// Maximum output tokens; `None` falls back to the provider default.
53    pub max_output_tokens: Option<u64>,
54    /// Whether the model accepts image input.
55    pub supports_images: bool,
56}
57
58/// One upstream provider/gateway.
59#[derive(Clone, Debug)]
60pub struct Preset {
61    /// Short stable id, used as a prefix in infinity model ids
62    /// (`<id>/<model>`), e.g. `groq/llama-3.3-70b-versatile`.
63    pub id: &'static str,
64    /// Env prefix for `<PREFIX>_BASE_URL` / `<PREFIX>_MODELS` overrides.
65    pub prefix: &'static str,
66    /// API-key env vars (first one set wins). Empty = no key needed (local).
67    pub key_envs: &'static [&'static str],
68    /// Extra env vars honored for the base URL (besides `<PREFIX>_BASE_URL`).
69    pub base_envs: &'static [&'static str],
70    /// Default base URL when no override is set.
71    pub default_base_url: Option<&'static str>,
72    pub flavor: Flavor,
73    pub models: &'static [PresetModel],
74    /// Whether the endpoint needs no auth (local servers).
75    pub no_auth: bool,
76    /// Whether this preset uses Claude subscription `OAuth` (see `oauth`).
77    pub oauth_subscription: bool,
78    /// Advertise the endpoint's live model list by default (in addition to
79    /// the curated catalog). Local servers always discover; cloud backends
80    /// can also opt in per backend with `<PREFIX>_DISCOVER=1`.
81    pub discover: bool,
82}
83
84pub const PRESETS: &[Preset] = &[
85    Preset {
86        id: "openai",
87        prefix: "OPENAI",
88        key_envs: &["OPENAI_API_KEY"],
89        base_envs: &["OPENAI_BASE_URL"],
90        default_base_url: Some("https://api.openai.com/v1"),
91        flavor: Flavor::OpenAi,
92        no_auth: false,
93        oauth_subscription: false,
94        discover: false,
95        models: crate::catalog_generated::OPENAI,
96    },
97    Preset {
98        id: "anthropic",
99        prefix: "ANTHROPIC",
100        key_envs: &[
101            "ANTHROPIC_API_KEY",
102            "ANTHROPIC_AUTH_TOKEN",
103            "ANTHROPIC_OAUTH_TOKEN",
104        ],
105        base_envs: &["ANTHROPIC_BASE_URL"],
106        default_base_url: Some("https://api.anthropic.com/v1"),
107        flavor: Flavor::Anthropic,
108        no_auth: false,
109        oauth_subscription: false,
110        discover: false,
111        models: crate::catalog_generated::ANTHROPIC,
112    },
113    Preset {
114        id: "claude-oauth",
115        prefix: "CLAUDE_OAUTH",
116        key_envs: &[],
117        base_envs: &["CLAUDE_OAUTH_BASE_URL"],
118        default_base_url: Some("https://api.anthropic.com"),
119        flavor: Flavor::Anthropic,
120        no_auth: false,
121        oauth_subscription: true,
122        discover: false,
123        models: crate::catalog_generated::CLAUDE_OAUTH,
124    },
125    Preset {
126        id: "deepseek",
127        prefix: "DEEPSEEK",
128        key_envs: &["DEEPSEEK_API_KEY"],
129        base_envs: &["DEEPSEEK_BASE_URL"],
130        default_base_url: Some("https://api.deepseek.com/v1"),
131        flavor: Flavor::OpenAi,
132        no_auth: false,
133        oauth_subscription: false,
134        discover: false,
135        models: crate::catalog_generated::DEEPSEEK,
136    },
137    Preset {
138        id: "groq",
139        prefix: "GROQ",
140        key_envs: &["GROQ_API_KEY"],
141        base_envs: &["GROQ_BASE_URL"],
142        default_base_url: Some("https://api.groq.com/openai/v1"),
143        flavor: Flavor::OpenAi,
144        no_auth: false,
145        oauth_subscription: false,
146        discover: false,
147        models: crate::catalog_generated::GROQ,
148    },
149    Preset {
150        id: "cerebras",
151        prefix: "CEREBRAS",
152        key_envs: &["CEREBRAS_API_KEY"],
153        base_envs: &["CEREBRAS_BASE_URL"],
154        default_base_url: Some("https://api.cerebras.ai/v1"),
155        flavor: Flavor::OpenAi,
156        no_auth: false,
157        oauth_subscription: false,
158        discover: false,
159        models: crate::catalog_generated::CEREBRAS,
160    },
161    Preset {
162        id: "xai",
163        prefix: "XAI",
164        key_envs: &["XAI_API_KEY"],
165        base_envs: &["XAI_BASE_URL"],
166        default_base_url: Some("https://api.x.ai/v1"),
167        flavor: Flavor::OpenAi,
168        no_auth: false,
169        oauth_subscription: false,
170        discover: false,
171        models: crate::catalog_generated::XAI,
172    },
173    Preset {
174        id: "mistral",
175        prefix: "MISTRAL",
176        key_envs: &["MISTRAL_API_KEY"],
177        base_envs: &["MISTRAL_BASE_URL"],
178        default_base_url: Some("https://api.mistral.ai/v1"),
179        flavor: Flavor::OpenAi,
180        no_auth: false,
181        oauth_subscription: false,
182        discover: false,
183        models: crate::catalog_generated::MISTRAL,
184    },
185    Preset {
186        id: "gemini",
187        prefix: "GEMINI",
188        key_envs: &[
189            "GEMINI_API_KEY",
190            "GOOGLE_GENERATIVE_AI_API_KEY",
191            "GOOGLE_API_KEY",
192        ],
193        base_envs: &["GEMINI_BASE_URL", "GOOGLE_GENERATIVE_AI_BASE_URL"],
194        default_base_url: Some("https://generativelanguage.googleapis.com/v1beta/openai/"),
195        flavor: Flavor::OpenAi,
196        no_auth: false,
197        oauth_subscription: false,
198        discover: false,
199        models: crate::catalog_generated::GEMINI,
200    },
201    Preset {
202        id: "together",
203        prefix: "TOGETHER",
204        key_envs: &["TOGETHER_API_KEY"],
205        base_envs: &["TOGETHER_BASE_URL"],
206        default_base_url: Some("https://api.together.xyz/v1"),
207        flavor: Flavor::OpenAi,
208        no_auth: false,
209        oauth_subscription: false,
210        discover: false,
211        models: crate::catalog_generated::TOGETHER,
212    },
213    Preset {
214        id: "fireworks",
215        prefix: "FIREWORKS",
216        key_envs: &["FIREWORKS_API_KEY"],
217        base_envs: &["FIREWORKS_BASE_URL"],
218        default_base_url: Some("https://api.fireworks.ai/inference/v1"),
219        flavor: Flavor::OpenAi,
220        no_auth: false,
221        oauth_subscription: false,
222        discover: false,
223        models: crate::catalog_generated::FIREWORKS,
224    },
225    Preset {
226        id: "openrouter",
227        prefix: "OPENROUTER",
228        key_envs: &["OPENROUTER_API_KEY"],
229        base_envs: &["OPENROUTER_BASE_URL"],
230        default_base_url: Some("https://openrouter.ai/api/v1"),
231        flavor: Flavor::OpenAi,
232        no_auth: false,
233        oauth_subscription: false,
234        discover: false,
235        models: crate::catalog_generated::OPENROUTER,
236    },
237    Preset {
238        id: "nvidia",
239        prefix: "NVIDIA",
240        key_envs: &["NVIDIA_API_KEY"],
241        base_envs: &["NVIDIA_BASE_URL"],
242        default_base_url: Some("https://integrate.api.nvidia.com/v1"),
243        flavor: Flavor::OpenAi,
244        no_auth: false,
245        oauth_subscription: false,
246        discover: false,
247        models: crate::catalog_generated::NVIDIA,
248    },
249    Preset {
250        id: "moonshot",
251        prefix: "MOONSHOT",
252        key_envs: &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
253        base_envs: &["MOONSHOT_BASE_URL", "KIMI_BASE_URL"],
254        default_base_url: Some("https://api.moonshot.ai/v1"),
255        flavor: Flavor::OpenAi,
256        no_auth: false,
257        oauth_subscription: false,
258        discover: false,
259        models: crate::catalog_generated::MOONSHOT,
260    },
261    Preset {
262        id: "zai",
263        prefix: "ZAI",
264        key_envs: &["ZAI_API_KEY"],
265        base_envs: &["ZAI_BASE_URL"],
266        default_base_url: Some("https://api.z.ai/api/coding/paas/v4"),
267        flavor: Flavor::OpenAi,
268        no_auth: false,
269        oauth_subscription: false,
270        discover: false,
271        models: crate::catalog_generated::ZAI,
272    },
273    Preset {
274        id: "minimax",
275        prefix: "MINIMAX",
276        key_envs: &["MINIMAX_API_KEY", "MINIMAX_CN_API_KEY"],
277        base_envs: &["MINIMAX_BASE_URL"],
278        default_base_url: Some("https://api.minimax.io/v1"),
279        flavor: Flavor::OpenAi,
280        no_auth: false,
281        oauth_subscription: false,
282        discover: false,
283        models: crate::catalog_generated::MINIMAX,
284    },
285    Preset {
286        id: "qwen",
287        prefix: "QWEN",
288        key_envs: &[
289            "QWEN_TOKEN_PLAN_API_KEY",
290            "QWEN_TOKEN_PLAN_CN_API_KEY",
291            "DASHSCOPE_API_KEY",
292        ],
293        base_envs: &["QWEN_BASE_URL", "DASHSCOPE_BASE_URL"],
294        default_base_url: Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
295        flavor: Flavor::OpenAi,
296        no_auth: false,
297        oauth_subscription: false,
298        discover: false,
299        models: crate::catalog_generated::QWEN,
300    },
301    Preset {
302        id: "baseten",
303        prefix: "BASETEN",
304        key_envs: &["BASETEN_API_KEY"],
305        base_envs: &["BASETEN_BASE_URL"],
306        default_base_url: None, // model endpoint required
307        flavor: Flavor::OpenAi,
308        no_auth: false,
309        oauth_subscription: false,
310        discover: false,
311        models: &[],
312    },
313    Preset {
314        id: "huggingface",
315        prefix: "HF",
316        key_envs: &["HF_TOKEN", "HUGGINGFACE_API_KEY"],
317        base_envs: &["HF_BASE_URL"],
318        default_base_url: Some("https://router.huggingface.co/v1"),
319        flavor: Flavor::OpenAi,
320        no_auth: false,
321        oauth_subscription: false,
322        discover: false,
323        models: crate::catalog_generated::HUGGINGFACE,
324    },
325    Preset {
326        id: "vercel-gateway",
327        prefix: "AI_GATEWAY",
328        key_envs: &["AI_GATEWAY_API_KEY"],
329        base_envs: &["AI_GATEWAY_BASE_URL"],
330        default_base_url: Some("https://ai-gateway.vercel.sh/v1"),
331        flavor: Flavor::OpenAi,
332        no_auth: false,
333        oauth_subscription: false,
334        discover: false,
335        models: crate::catalog_generated::VERCEL_GATEWAY,
336    },
337    Preset {
338        id: "opencode",
339        prefix: "OPENCODE",
340        key_envs: &["OPENCODE_API_KEY"],
341        base_envs: &["OPENCODE_BASE_URL"],
342        default_base_url: Some("https://opencode.ai/inference/openai/v1"),
343        flavor: Flavor::OpenAi,
344        no_auth: false,
345        oauth_subscription: false,
346        discover: true,
347        models: crate::catalog_generated::OPENCODE,
348    },
349    Preset {
350        id: "opencode-go",
351        prefix: "OPENCODE_GO",
352        key_envs: &["OPENCODE_API_KEY"],
353        base_envs: &["OPENCODE_GO_BASE_URL"],
354        default_base_url: Some("https://opencode.ai/inference/go/openai/v1"),
355        flavor: Flavor::OpenAi,
356        no_auth: false,
357        oauth_subscription: false,
358        discover: true,
359        models: crate::catalog_generated::OPENCODE_GO,
360    },
361    Preset {
362        id: "azure",
363        prefix: "AZURE_OPENAI",
364        key_envs: &["AZURE_OPENAI_API_KEY", "AZURE_API_KEY"],
365        base_envs: &["AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT"],
366        default_base_url: None, // derived from RESOURCE_NAME or BASE_URL
367        flavor: Flavor::OpenAi,
368        no_auth: false,
369        oauth_subscription: false,
370        discover: false,
371        models: crate::catalog_generated::AZURE,
372    },
373    Preset {
374        id: "ollama",
375        prefix: "OLLAMA",
376        key_envs: &[],
377        base_envs: &["OLLAMA_HOST", "OLLAMA_BASE_URL"],
378        default_base_url: Some("http://127.0.0.1:11434/v1"),
379        flavor: Flavor::OpenAi,
380        models: &[PresetModel {
381            id: "qwen3-coder:32b",
382            display_name: "Qwen3 Coder 32B (Ollama)",
383            context_window: 128_000,
384            max_output_tokens: Some(32_000),
385            supports_images: true,
386        }],
387        no_auth: true,
388        oauth_subscription: false,
389        discover: true,
390    },
391    Preset {
392        id: "lmstudio",
393        prefix: "LMSTUDIO",
394        key_envs: &[],
395        base_envs: &["LMSTUDIO_BASE_URL"],
396        default_base_url: Some("http://127.0.0.1:1234/v1"),
397        flavor: Flavor::OpenAi,
398        models: &[],
399        no_auth: true,
400        oauth_subscription: false,
401        discover: true,
402    },
403    Preset {
404        id: "vllm",
405        prefix: "VLLM",
406        key_envs: &[],
407        base_envs: &["VLLM_BASE_URL"],
408        default_base_url: Some("http://127.0.0.1:8000/v1"),
409        flavor: Flavor::OpenAi,
410        models: &[],
411        no_auth: true,
412        oauth_subscription: false,
413        discover: true,
414    },
415];
416
417/// A backend resolved from the environment and ready to serve.
418#[derive(Clone, Debug)]
419pub struct ResolvedBackend {
420    pub preset_id: &'static str,
421    pub flavor: Flavor,
422    pub base_url: String,
423    pub api_key: String,
424    /// Models offered by this backend.
425    pub models: Vec<BackendModel>,
426    /// Subscription session for the `claude-oauth` preset; `None` otherwise.
427    pub oauth: Option<crate::oauth::OAuthSessionHandle>,
428    /// Enrich the curated list with a live `GET {base}/models` probe.
429    pub discover: bool,
430    /// Extra default headers (e.g. `OpenRouter` app attribution).
431    pub extra_headers: Vec<(String, String)>,
432}
433
434fn non_empty(var: &str) -> Option<String> {
435    env::var(var)
436        .ok()
437        .map(|v| v.trim().to_owned())
438        .filter(|v| !v.is_empty())
439}
440
441fn first_set(vars: &[&str]) -> Option<String> {
442    vars.iter().find_map(|v| non_empty(v))
443}
444
445fn model_overrides(prefix: &str) -> Option<Vec<String>> {
446    if let Some(list) = non_empty(&format!("{prefix}_MODELS")) {
447        let ids: Vec<String> = list
448            .split(',')
449            .map(|s| s.trim().to_owned())
450            .filter(|s| !s.is_empty())
451            .collect();
452        if !ids.is_empty() {
453            return Some(ids);
454        }
455    }
456    non_empty(&format!("{prefix}_MODEL")).map(|m| vec![m])
457}
458
459fn display_for(id: &str) -> String {
460    id.to_owned()
461}
462
463/// Stable per-process session id for `opencode-go` (`x-opencode-session`).
464/// Overridable with `OPENCODE_SESSION_ID`.
465fn session_id() -> String {
466    if let Some(id) = non_empty("OPENCODE_SESSION_ID") {
467        return id;
468    }
469    let mut random = [0_u8; 16];
470    if getrandom::fill(&mut random).is_err() {
471        return "infinity-provider-models-dev".to_owned();
472    }
473    random
474        .iter()
475        .fold(String::with_capacity(32), |mut hex, byte| {
476            use std::fmt::Write as _;
477            let _ = write!(hex, "{byte:02x}");
478            hex
479        })
480}
481
482impl Preset {
483    fn resolve(&self) -> Option<ResolvedBackend> {
484        if self.oauth_subscription {
485            return self.resolve_oauth();
486        }
487        if self.no_auth {
488            // Local servers: only enable when explicitly opted in so a fresh
489            // install without Ollama running doesn't advertise dead models.
490            let enabled = env::var("INFINITY_LOCAL").ok().as_deref() == Some("1")
491                || self.base_envs.iter().any(|v| non_empty(v).is_some())
492                || non_empty(&format!("{}_MODELS", self.prefix)).is_some()
493                || non_empty(&format!("{}_MODEL", self.prefix)).is_some();
494            if !enabled {
495                return None;
496            }
497            let base_url =
498                first_set(self.base_envs).or_else(|| self.default_base_url.map(str::to_owned))?;
499            let models = resolve_models(self, &base_url);
500            if models.is_empty() {
501                return None;
502            }
503            // Local servers always probe `GET {base}/models`: the user
504            // controls what is pulled, so everything served is relevant.
505            return Some(ResolvedBackend {
506                preset_id: self.id,
507                flavor: self.flavor,
508                base_url: trim_trailing_slash(&base_url).to_owned(),
509                api_key: String::new(),
510                oauth: None,
511                discover: true,
512                models,
513                extra_headers: Vec::new(),
514            });
515        }
516
517        let api_key = first_set(self.key_envs)?;
518        // `<PREFIX>_DISCOVER=1` merges the endpoint's live model list into
519        // the curated catalog (off by default: cloud catalogs are noisy).
520        // `discover` presets (e.g. `opencode`) always discover.
521        let discover = self.discover
522            || std::env::var(format!("{}_DISCOVER", self.prefix))
523                .ok()
524                .as_deref()
525                == Some("1");
526        // Go asks clients for a stable per-conversation session id for
527        // routing and prompt-cache affinity; ours is stable per provider
528        // process and overridable via `OPENCODE_SESSION_ID`.
529        let mut extra_headers = Vec::new();
530        if self.id == "opencode-go" {
531            extra_headers.push(("x-opencode-session".to_owned(), session_id()));
532        }
533        let mut base_url = first_set(self.base_envs)
534            .or_else(|| non_empty(&format!("{}_BASE_URL", self.prefix)))
535            .or_else(|| self.default_base_url.map(str::to_owned));
536
537        // Azure: derive `https://{resource}.openai.azure.com/openai/v1` when
538        // only a resource name was given.
539        if self.id == "azure" && base_url.is_none() {
540            if let Some(resource) =
541                non_empty("AZURE_OPENAI_RESOURCE_NAME").or_else(|| non_empty("AZURE_RESOURCE_NAME"))
542            {
543                base_url = Some(format!(
544                    "https://{}.openai.azure.com/openai/v1",
545                    resource.trim()
546                ));
547            }
548        }
549        // Baseten / Azure with no endpoint: key alone is not enough.
550        let base_url = base_url?;
551        let models = resolve_models(self, &base_url);
552        if models.is_empty() && !discover {
553            return None;
554        }
555
556        if self.id == "openrouter" {
557            extra_headers.push((
558                "HTTP-Referer".to_owned(),
559                non_empty("OPENROUTER_APP_URL")
560                    .unwrap_or_else(|| "https://github.com/hydro-project/infinity".to_owned()),
561            ));
562            extra_headers.push((
563                "X-Title".to_owned(),
564                non_empty("OPENROUTER_APP_NAME").unwrap_or_else(|| "infinity".to_owned()),
565            ));
566        }
567
568        Some(ResolvedBackend {
569            preset_id: self.id,
570            flavor: self.flavor,
571            base_url: trim_trailing_slash(&base_url).to_owned(),
572            api_key,
573            oauth: None,
574            discover,
575            models,
576            extra_headers,
577        })
578    }
579}
580
581impl Preset {
582    /// Resolve the Claude subscription preset: explicit opt-in plus
583    /// credentials from the environment, our login file, or `Claude Code`.
584    fn resolve_oauth(&self) -> Option<ResolvedBackend> {
585        use crate::oauth::{self, OAuthSession, OAuthSessionHandle};
586        use std::sync::{Arc, RwLock};
587
588        let static_token = non_empty(oauth::STATIC_TOKEN_ENV);
589        let opted_in =
590            std::env::var(oauth::OPT_IN_ENV).ok().as_deref() == Some("1") || static_token.is_some();
591        if !opted_in {
592            return None;
593        }
594        let session = if let Some(token) = static_token {
595            OAuthSession {
596                access_token: token,
597                refresh_token: String::new(),
598                expires_at_ms: i64::MAX,
599                persist_path: None,
600            }
601        } else if let Some(loaded) = oauth::load_credentials() {
602            if loaded.access_token.trim().is_empty() {
603                return None;
604            }
605            OAuthSession {
606                access_token: loaded.access_token,
607                refresh_token: loaded.refresh_token,
608                expires_at_ms: loaded.expires_at_ms,
609                persist_path: loaded.persist_path,
610            }
611        } else {
612            tracing::warn!(
613                "claude-oauth: opted in but no credentials found; run \
614                 `infinity-provider-models-dev login`, `claude auth login`, \
615                 or set ANTHROPIC_OAUTH_TOKEN"
616            );
617            return None;
618        };
619        if !session.refresh_token.trim().is_empty()
620            && oauth::needs_refresh(session.expires_at_ms, oauth::now_ms())
621        {
622            tracing::info!("claude-oauth: stored bearer expired; will refresh on first use");
623        }
624        let base_url =
625            first_set(self.base_envs).or_else(|| self.default_base_url.map(str::to_owned))?;
626        let base_url = trim_trailing_slash(&base_url).to_owned();
627        let models = resolve_models(self, &base_url);
628        if models.is_empty() {
629            return None;
630        }
631        let handle: OAuthSessionHandle = Arc::new(RwLock::new(session));
632        Some(ResolvedBackend {
633            preset_id: self.id,
634            flavor: self.flavor,
635            base_url,
636            api_key: String::new(),
637            oauth: Some(handle),
638            discover: false,
639            models,
640            extra_headers: Vec::new(),
641        })
642    }
643}
644
645fn preset_to_backend(m: &PresetModel) -> BackendModel {
646    BackendModel {
647        upstream: m.id.to_owned(),
648        display: m.display_name.to_owned(),
649        context_window: m.context_window,
650        max_output_tokens: m.max_output_tokens,
651        supports_images: m.supports_images,
652    }
653}
654
655fn resolve_models(preset: &Preset, _base_url: &str) -> Vec<BackendModel> {
656    if let Some(ids) = model_overrides(preset.prefix) {
657        return ids
658            .into_iter()
659            .map(|id| {
660                let builtin = preset.models.iter().find(|m| m.id == id);
661                builtin.map_or_else(
662                    || BackendModel {
663                        upstream: id.clone(),
664                        display: display_for(&id),
665                        context_window: 200_000,
666                        max_output_tokens: Some(32_000),
667                        supports_images: true,
668                    },
669                    preset_to_backend,
670                )
671            })
672            .collect();
673    }
674    preset.models.iter().map(preset_to_backend).collect()
675}
676
677/// Scan the environment for every configured backend, in preset order.
678pub fn resolve_backends() -> Vec<ResolvedBackend> {
679    let mut backends: Vec<ResolvedBackend> = PRESETS.iter().filter_map(Preset::resolve).collect();
680
681    // Fully custom OpenAI-compatible endpoint (any proxy/gateway).
682    // Without `INFINITY_OPENAI_MODELS` the model list comes from a live
683    // `GET {base}/models` probe instead.
684    if let Some(base_url) = non_empty("INFINITY_OPENAI_BASE_URL") {
685        let api_key = non_empty("INFINITY_OPENAI_API_KEY").unwrap_or_default();
686        let ids = non_empty("INFINITY_OPENAI_MODELS")
687            .map(|list| {
688                list.split(',')
689                    .map(|s| s.trim().to_owned())
690                    .filter(|s| !s.is_empty())
691                    .collect::<Vec<_>>()
692            })
693            .filter(|v| !v.is_empty())
694            .unwrap_or_default();
695        backends.push(ResolvedBackend {
696            preset_id: "custom",
697            flavor: Flavor::OpenAi,
698            base_url: trim_trailing_slash(&base_url).to_owned(),
699            api_key,
700            oauth: None,
701            discover: true,
702            models: ids
703                .into_iter()
704                .map(|id| BackendModel {
705                    upstream: id.clone(),
706                    display: display_for(&id),
707                    context_window: 200_000,
708                    max_output_tokens: Some(32_000),
709                    supports_images: true,
710                })
711                .collect(),
712            extra_headers: Vec::new(),
713        });
714    }
715
716    backends
717}
718
719fn trim_trailing_slash(s: &str) -> &str {
720    s.trim_end_matches('/')
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn model_overrides_empty_without_env() {
729        // A prefix that is never set must yield no overrides.
730        assert!(model_overrides("INFINITY_PROVIDER_MODELS_DEV_UNSET_TEST_PREFIX").is_none());
731    }
732
733    /// Opt-in gating and static-token activation run in one test because they
734    /// mutate the same process-global env vars.
735    #[test]
736    fn oauth_preset_opt_in_flow() {
737        let saved_token = std::env::var("ANTHROPIC_OAUTH_TOKEN").ok();
738        let saved_opt_in = std::env::var("INFINITY_CLAUDE_OAUTH").ok();
739        let restore = |saved_token: &Option<String>, saved_opt_in: &Option<String>| {
740            if let Some(token) = saved_token {
741                unsafe {
742                    std::env::set_var("ANTHROPIC_OAUTH_TOKEN", token);
743                }
744            } else {
745                unsafe {
746                    std::env::remove_var("ANTHROPIC_OAUTH_TOKEN");
747                }
748            }
749            if let Some(opt_in) = saved_opt_in {
750                unsafe {
751                    std::env::set_var("INFINITY_CLAUDE_OAUTH", opt_in);
752                }
753            } else {
754                unsafe {
755                    std::env::remove_var("INFINITY_CLAUDE_OAUTH");
756                }
757            }
758        };
759        let preset = PRESETS
760            .iter()
761            .find(|p| p.id == "claude-oauth")
762            .expect("claude-oauth preset exists");
763
764        unsafe {
765            std::env::remove_var("ANTHROPIC_OAUTH_TOKEN");
766            std::env::remove_var("INFINITY_CLAUDE_OAUTH");
767        }
768        assert!(preset.resolve().is_none());
769
770        unsafe {
771            std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oauth01-test");
772        }
773        let backend = preset.resolve().expect("static token activates preset");
774        assert_eq!(backend.preset_id, "claude-oauth");
775        assert!(backend.oauth.is_some());
776        assert_eq!(backend.models.len(), 3);
777
778        restore(&saved_token, &saved_opt_in);
779    }
780
781    /// `opencode` / `opencode-go` share one key; Go additionally sends a
782    /// stable session header. Touches only `OPENCODE_*` vars.
783    #[test]
784    fn opencode_presets_share_key_and_go_sends_session() {
785        let saved_key = std::env::var("OPENCODE_API_KEY").ok();
786        let saved_session = std::env::var("OPENCODE_SESSION_ID").ok();
787        let restore = |saved_key: &Option<String>, saved_session: &Option<String>| {
788            if let Some(key) = saved_key {
789                unsafe {
790                    std::env::set_var("OPENCODE_API_KEY", key);
791                }
792            } else {
793                unsafe {
794                    std::env::remove_var("OPENCODE_API_KEY");
795                }
796            }
797            if let Some(session) = saved_session {
798                unsafe {
799                    std::env::set_var("OPENCODE_SESSION_ID", session);
800                }
801            } else {
802                unsafe {
803                    std::env::remove_var("OPENCODE_SESSION_ID");
804                }
805            }
806        };
807        let find = |id: &str| {
808            PRESETS
809                .iter()
810                .find(|p| p.id == id)
811                .unwrap_or_else(|| panic!("{id} preset exists"))
812        };
813
814        unsafe {
815            std::env::remove_var("OPENCODE_API_KEY");
816            std::env::remove_var("OPENCODE_SESSION_ID");
817        }
818        assert!(find("opencode").resolve().is_none());
819        assert!(find("opencode-go").resolve().is_none());
820
821        unsafe {
822            std::env::set_var("OPENCODE_API_KEY", "ok-test");
823        }
824        let base = find("opencode").resolve().expect("opencode activates");
825        assert!(base.discover);
826        assert!(base.models.len() >= 2);
827        assert!(
828            base.extra_headers
829                .iter()
830                .all(|(k, _)| k != "x-opencode-session"),
831            "session header is Go-only"
832        );
833        let go = find("opencode-go")
834            .resolve()
835            .expect("opencode-go activates");
836        assert!(go.discover);
837        let session = go
838            .extra_headers
839            .iter()
840            .find(|(k, _)| k == "x-opencode-session")
841            .map(|(_, v)| v.clone());
842        assert!(session.is_some_and(|s| !s.is_empty()));
843
844        unsafe {
845            std::env::set_var("OPENCODE_SESSION_ID", "pinned-session");
846        }
847        let go = find("opencode-go")
848            .resolve()
849            .expect("opencode-go activates");
850        assert!(
851            go.extra_headers
852                .iter()
853                .any(|(k, v)| k == "x-opencode-session" && v == "pinned-session"),
854            "explicit session id wins"
855        );
856
857        restore(&saved_key, &saved_session);
858    }
859
860    #[test]
861    fn model_overrides_round_trip() {
862        let prefix = "INFINITY_PROVIDER_MODELS_DEV_TEST";
863        let var = format!("{prefix}_MODELS");
864        unsafe {
865            std::env::set_var(&var, "a-model,b-model");
866        }
867        assert_eq!(
868            model_overrides(prefix),
869            Some(vec!["a-model".to_owned(), "b-model".to_owned()])
870        );
871        unsafe {
872            std::env::remove_var(&var);
873        }
874        assert!(model_overrides(prefix).is_none());
875    }
876}