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}
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, // model endpoint required
286        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, // derived from RESOURCE_NAME or BASE_URL
330        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/// A backend resolved from the environment and ready to serve.
377#[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    /// Models offered by this backend.
384    pub models: Vec<BackendModel>,
385    /// Subscription session for the `claude-oauth` preset; `None` otherwise.
386    pub oauth: Option<crate::oauth::OAuthSessionHandle>,
387    /// Enrich the curated list with a live `GET {base}/models` probe.
388    pub discover: bool,
389    /// Extra default headers (e.g. `OpenRouter` app attribution).
390    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            // Local servers: only enable when explicitly opted in so a fresh
429            // install without Ollama running doesn't advertise dead models.
430            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            // Local servers always probe `GET {base}/models`: the user
444            // controls what is pulled, so everything served is relevant.
445            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        // `<PREFIX>_DISCOVER=1` merges the endpoint's live model list into
459        // the curated catalog (off by default: cloud catalogs are noisy).
460        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        // Azure: derive `https://{resource}.openai.azure.com/openai/v1` when
469        // only a resource name was given.
470        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        // Baseten / Azure with no endpoint: key alone is not enough.
481        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    /// Resolve the Claude subscription preset: explicit opt-in plus
515    /// credentials from the environment, our login file, or `Claude Code`.
516    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
609/// Scan the environment for every configured backend, in preset order.
610pub fn resolve_backends() -> Vec<ResolvedBackend> {
611    let mut backends: Vec<ResolvedBackend> = PRESETS.iter().filter_map(Preset::resolve).collect();
612
613    // Fully custom OpenAI-compatible endpoint (any proxy/gateway).
614    // Without `INFINITY_OPENAI_MODELS` the model list comes from a live
615    // `GET {base}/models` probe instead.
616    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        // A prefix that is never set must yield no overrides.
662        assert!(model_overrides("INFINITY_PROVIDER_MODELS_DEV_UNSET_TEST_PREFIX").is_none());
663    }
664
665    /// Opt-in gating and static-token activation run in one test because they
666    /// mutate the same process-global env vars.
667    #[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}