Skip to main content

infinity_provider_models_dev/
lib.rs

1//! Multi-backend model provider for Infinity, configured through the same
2//! API-key env vars that [pi](https://pi.dev/docs/latest/providers) and
3//! [opencode](https://opencode.ai/v2/docs/providers/) understand.
4//!
5//! Each configured upstream backend contributes its models under
6//! `<backend>/<model>` ids (e.g. `groq/llama-3.3-70b-versatile`,
7//! `anthropic/claude-sonnet-4-6`). The provider speaks `OpenAI` Chat
8//! Completions streaming for `OpenAI`-compatible backends and `Anthropic`
9//! Messages streaming for `Anthropic`, and translates both into the infinity
10//! [`ModelProvider`] protocol — the same shape as
11//! `infinity-provider-bedrock`, but for every non-Amazon provider.
12//!
13//! ```bash
14//! export GROQ_API_KEY=...
15//! export OPENROUTER_API_KEY=...
16//! infinity-provider-models-dev   # prints a unix socket path, serves the provider
17//! ```
18//!
19//! `~/.infinity/providers.json`:
20//! ```json
21//! { "models_dev": { "command": ["infinity-provider-models-dev"] } }
22//! ```
23
24pub mod anthropic;
25pub mod catalog_generated;
26pub mod convert;
27pub mod oauth;
28pub mod openai;
29pub mod preset;
30
31use std::collections::HashMap;
32use std::sync::Arc;
33
34use async_trait::async_trait;
35use infinity_provider_protocol::{
36    CompletionError, CompletionRequest, ErrorClass, ModelEntry, ModelProvider, ModelStream,
37};
38use preset::{Flavor, ResolvedBackend};
39
40type BoxError = Box<dyn std::error::Error + Send + Sync>;
41
42/// Crate user agent sent on every upstream request.
43///
44/// Providers (notably `OpenCode Go`) ask clients to identify with their own
45/// agent string rather than a generic HTTP-library default.
46pub const USER_AGENT: &str = concat!("infinity-provider-models-dev/", env!("CARGO_PKG_VERSION"));
47
48/// One advertised model: its infinity-facing id plus where to route it.
49#[derive(Clone, Debug)]
50struct Route {
51    entry: ModelEntry,
52    backend_idx: usize,
53    upstream_model: String,
54    default_max_tokens: Option<u64>,
55    /// Registration order. `list_models` advertises in this order so the
56    /// global default (the first model of the first backend) follows
57    /// preset order instead of an arbitrary map order.
58    order: usize,
59}
60
61/// [`ModelProvider`] that fans out to every env-configured backend.
62pub struct MultiProvider {
63    client: reqwest::Client,
64    backends: Vec<ResolvedBackend>,
65    routes: HashMap<String, Route>,
66}
67
68impl MultiProvider {
69    /// Build from the current process environment. Logs configured backends
70    /// to stderr (stdout is reserved for the socket path).
71    #[must_use]
72    pub fn from_env() -> Self {
73        Self::from_backends(preset::resolve_backends())
74    }
75
76    #[must_use]
77    pub fn from_backends(backends: Vec<ResolvedBackend>) -> Self {
78        let client = reqwest::Client::new();
79        let mut routes = HashMap::new();
80        let mut order = 0_usize;
81        for (idx, backend) in backends.iter().enumerate() {
82            for m in &backend.models {
83                let model_id = format!("{}/{}", backend.preset_id, m.upstream);
84                // First backend wins on collision; ids are namespaced so this
85                // only triggers for duplicate custom config.
86                if routes.contains_key(&model_id) {
87                    tracing::warn!("duplicate model id {model_id}, keeping first");
88                    continue;
89                }
90                let entry = ModelEntry {
91                    model_id: model_id.clone(),
92                    display_name: format!("{} ({})", m.display, backend.preset_id),
93                    context_window: m.context_window,
94                    max_output_tokens: m.max_output_tokens,
95                    supports_image_input: m.supports_images,
96                };
97                order = order.saturating_add(1);
98                routes.insert(
99                    model_id,
100                    Route {
101                        entry,
102                        backend_idx: idx,
103                        upstream_model: m.upstream.clone(),
104                        default_max_tokens: m.max_output_tokens,
105                        order,
106                    },
107                );
108            }
109        }
110        for b in &backends {
111            tracing::info!(
112                "backend {}: {} model(s) at {}",
113                b.preset_id,
114                b.models.len(),
115                b.base_url,
116            );
117        }
118        if backends.is_empty() {
119            tracing::warn!(
120                "no backends configured: set one of the pi/opencode API-key env vars \
121                 (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY)"
122            );
123        }
124        Self {
125            client,
126            backends,
127            routes,
128        }
129    }
130
131    /// Backends in resolution order (for debugging / tests).
132    #[must_use]
133    pub fn backends(&self) -> &[ResolvedBackend] {
134        &self.backends
135    }
136
137    /// Share the provider behind `Arc` for `serve_provider`.
138    #[must_use]
139    pub fn into_arc(self) -> Arc<Self> {
140        Arc::new(self)
141    }
142
143    /// Reorder `entries` so the `INFINITY_DEFAULT_MODEL` id (if set) comes
144    /// first. Existing ids move; unlisted ids on a known backend are
145    /// synthesized as passthrough entries; unknown prefixes warn and keep
146    /// the order.
147    fn apply_default_override(&self, entries: &mut Vec<ModelEntry>, wanted: Option<String>) {
148        let Some(wanted) = wanted else {
149            return;
150        };
151        if let Some(pos) = entries.iter().position(|e| e.model_id == wanted) {
152            if pos != 0 {
153                let entry = entries.remove(pos);
154                entries.insert(0, entry);
155            }
156            tracing::info!("default model pinned by {DEFAULT_MODEL_ENV}: {wanted}");
157            return;
158        }
159        let Some((prefix, rest)) = wanted.split_once('/') else {
160            tracing::warn!("{DEFAULT_MODEL_ENV}={wanted} is not a <backend>/<model> id; ignoring");
161            return;
162        };
163        if rest.is_empty() || !self.backends.iter().any(|b| b.preset_id == prefix) {
164            tracing::warn!("{DEFAULT_MODEL_ENV}={wanted} matches no known backend; ignoring");
165            return;
166        }
167        tracing::warn!(
168            "{DEFAULT_MODEL_ENV}={wanted} is not in the advertised list; \
169             advertising it first as a passthrough (invoke may fail if the id is wrong)"
170        );
171        entries.insert(
172            0,
173            ModelEntry {
174                model_id: wanted.clone(),
175                display_name: format!("{rest} ({prefix})"),
176                context_window: 200_000,
177                max_output_tokens: Some(32_000),
178                supports_image_input: false,
179            },
180        );
181    }
182}
183
184/// Env var pinning the global default: when set to a full `model_id`
185/// (`<backend>/<upstream-id>`), that model is advertised first from
186/// `list_models` so infinity picks it as the default.
187pub const DEFAULT_MODEL_ENV: &str = "INFINITY_DEFAULT_MODEL";
188
189fn default_model_override() -> Option<String> {
190    std::env::var(DEFAULT_MODEL_ENV)
191        .ok()
192        .map(|v| v.trim().to_owned())
193        .filter(|v| !v.is_empty())
194}
195
196#[async_trait]
197impl ModelProvider for MultiProvider {
198    async fn list_models(&self) -> Result<Vec<ModelEntry>, BoxError> {
199        // Advertise in registration order so the global default (the first
200        // model of the first backend) follows preset order.
201        let mut routes: Vec<&Route> = self.routes.values().collect();
202        routes.sort_by_key(|r| r.order);
203        let mut entries: Vec<ModelEntry> = routes.into_iter().map(|r| r.entry.clone()).collect();
204        let mut known: std::collections::HashSet<String> =
205            entries.iter().map(|e| e.model_id.clone()).collect();
206        // Live discovery runs concurrently across backends; each probe is
207        // timeout-guarded and falls back to the curated list on failure.
208        // Discovered ids need no route entries: `invoke_model` passes any
209        // `<backend>/<id>` through to its backend.
210        let probed = futures_util::future::join_all(
211            self.backends.iter().filter(|b| b.discover).map(|b| async {
212                let ids =
213                    crate::openai::discover_models(&self.client, &b.base_url, &b.api_key).await;
214                (b.preset_id, ids)
215            }),
216        )
217        .await;
218        for (preset_id, ids) in probed {
219            for id in ids {
220                let model_id = format!("{preset_id}/{id}");
221                if known.contains(&model_id) {
222                    continue;
223                }
224                // Conservative: unknown models are assumed text-only until
225                // curated (an unexpected image payload fails the whole
226                // turn; a placeholder does not).
227                entries.push(ModelEntry {
228                    model_id: model_id.clone(),
229                    display_name: format!("{id} ({preset_id})"),
230                    context_window: 200_000,
231                    max_output_tokens: Some(32_000),
232                    supports_image_input: false,
233                });
234                known.insert(model_id);
235            }
236        }
237        // Env override runs after discovery so a discovered id can also be
238        // pinned to the top.
239        let wanted = default_model_override();
240        self.apply_default_override(&mut entries, wanted);
241        Ok(entries)
242    }
243
244    async fn invoke_model(
245        &self,
246        model_id: &str,
247        request: CompletionRequest,
248    ) -> Result<ModelStream, CompletionError> {
249        // `<backend>/<id>` passthrough: allow unknown upstream ids on a
250        // backend whose preset id matches the prefix, so callers can address
251        // upstream models not in the advertised list.
252        let route = self.routes.get(model_id).cloned().or_else(|| {
253            model_id.split_once('/').and_then(|(prefix, rest)| {
254                self.backends.iter().enumerate().find_map(|(idx, b)| {
255                    if b.preset_id == prefix && !rest.is_empty() {
256                        Some(Route {
257                            entry: ModelEntry {
258                                model_id: model_id.to_owned(),
259                                display_name: model_id.to_owned(),
260                                context_window: 200_000,
261                                max_output_tokens: Some(32_000),
262                                supports_image_input: false,
263                            },
264                            backend_idx: idx,
265                            upstream_model: rest.to_owned(),
266                            default_max_tokens: Some(32_000),
267                            // Unordered: passthrough routes sort after all
268                            // catalogued routes.
269                            order: usize::MAX,
270                        })
271                    } else {
272                        None
273                    }
274                })
275            })
276        });
277        let Some(route) = route else {
278            return Err(CompletionError::provider(
279                ErrorClass::Fatal,
280                format!("unknown model: {model_id}"),
281            ));
282        };
283        let Some(backend) = self.backends.get(route.backend_idx) else {
284            return Err(CompletionError::provider(
285                ErrorClass::Fatal,
286                format!("unknown backend for model: {model_id}"),
287            ));
288        };
289        match backend.flavor {
290            Flavor::OpenAi => {
291                let target = openai::Backend {
292                    client: &self.client,
293                    base_url: &backend.base_url,
294                    api_key: &backend.api_key,
295                    extra_headers: &backend.extra_headers,
296                    supports_images: route.entry.supports_image_input,
297                };
298                openai::invoke(
299                    &target,
300                    &route.upstream_model,
301                    route.default_max_tokens,
302                    request,
303                )
304                .await
305            }
306            Flavor::Anthropic => {
307                let target = anthropic::Backend {
308                    client: &self.client,
309                    base_url: &backend.base_url,
310                    api_key: &backend.api_key,
311                    extra_headers: &backend.extra_headers,
312                    oauth: backend.oauth.as_ref(),
313                };
314                anthropic::invoke(
315                    &target,
316                    &route.upstream_model,
317                    route.default_max_tokens,
318                    request,
319                    route.entry.supports_image_input,
320                )
321                .await
322            }
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn backend(id: &'static str, models: Vec<(&str, &str)>) -> ResolvedBackend {
332        ResolvedBackend {
333            preset_id: id,
334            flavor: Flavor::OpenAi,
335            base_url: "https://example.test/v1".to_owned(),
336            api_key: "test".to_owned(),
337            models: models
338                .into_iter()
339                .map(|(m, d)| crate::preset::BackendModel {
340                    upstream: m.to_owned(),
341                    display: d.to_owned(),
342                    context_window: 128_000,
343                    max_output_tokens: Some(8_000),
344                    supports_images: true,
345                })
346                .collect(),
347            oauth: None,
348            discover: false,
349            extra_headers: Vec::new(),
350        }
351    }
352
353    fn two_backend_provider() -> MultiProvider {
354        MultiProvider::from_backends(vec![
355            backend("openai", vec![("gpt-5.4", "GPT")]),
356            backend("groq", vec![("llama-3.3-70b-versatile", "Llama")]),
357        ])
358    }
359
360    fn baseline_entries(p: &MultiProvider) -> Vec<ModelEntry> {
361        let mut routes: Vec<&Route> = p.routes.values().collect();
362        routes.sort_by_key(|r| r.order);
363        routes.into_iter().map(|r| r.entry.clone()).collect()
364    }
365
366    fn ids(entries: &[ModelEntry]) -> Vec<&str> {
367        entries.iter().map(|m| m.model_id.as_str()).collect()
368    }
369
370    #[test]
371    fn default_override_moves_existing_model_first() {
372        let p = two_backend_provider();
373        let mut entries = baseline_entries(&p);
374        p.apply_default_override(
375            &mut entries,
376            Some("groq/llama-3.3-70b-versatile".to_owned()),
377        );
378        assert_eq!(
379            ids(&entries),
380            vec!["groq/llama-3.3-70b-versatile", "openai/gpt-5.4"]
381        );
382    }
383
384    #[test]
385    fn default_override_synthesizes_passthrough_for_known_backend() {
386        let p = two_backend_provider();
387        let mut entries = baseline_entries(&p);
388        p.apply_default_override(&mut entries, Some("groq/some-future-model".to_owned()));
389        assert_eq!(
390            ids(&entries),
391            vec![
392                "groq/some-future-model",
393                "openai/gpt-5.4",
394                "groq/llama-3.3-70b-versatile"
395            ]
396        );
397    }
398
399    #[test]
400    fn default_override_unknown_prefix_leaves_order() {
401        let p = two_backend_provider();
402        let mut entries = baseline_entries(&p);
403        p.apply_default_override(&mut entries, Some("nope/nothing".to_owned()));
404        assert_eq!(
405            ids(&entries),
406            vec!["openai/gpt-5.4", "groq/llama-3.3-70b-versatile"]
407        );
408    }
409
410    #[test]
411    fn default_override_none_is_noop() {
412        let p = two_backend_provider();
413        let mut entries = baseline_entries(&p);
414        p.apply_default_override(&mut entries, None);
415        assert_eq!(
416            ids(&entries),
417            vec!["openai/gpt-5.4", "groq/llama-3.3-70b-versatile"]
418        );
419    }
420
421    #[test]
422    fn default_model_env_parsing_trims_and_rejects_empty() {
423        let _guard = default_env_lock();
424        let saved = std::env::var(DEFAULT_MODEL_ENV).ok();
425        unsafe {
426            std::env::set_var(DEFAULT_MODEL_ENV, "  groq/m  ");
427        }
428        assert_eq!(default_model_override(), Some("groq/m".to_owned()));
429        unsafe {
430            std::env::set_var(DEFAULT_MODEL_ENV, "   ");
431        }
432        assert_eq!(default_model_override(), None);
433        restore_default_env(saved);
434    }
435
436    #[tokio::test]
437    #[allow(clippy::await_holding_lock)]
438    async fn lists_namespaced_models_in_registration_order() {
439        // Registration order (not alphabetical) decides the listing: the
440        // daemon treats the first model as the global default. Isolated
441        // from INFINITY_DEFAULT_MODEL which other tests mutate.
442        let _guard = default_env_lock();
443        let saved = std::env::var(DEFAULT_MODEL_ENV).ok();
444        unsafe {
445            std::env::remove_var(DEFAULT_MODEL_ENV);
446        }
447        let p = two_backend_provider();
448        let models = p.list_models().await.unwrap();
449        // `list_models` reads the env after an await point, so restore only
450        // after it returns while still holding the lock.
451        let list_ids: Vec<String> = models.iter().map(|m| m.model_id.clone()).collect();
452        restore_default_env(saved);
453        assert_eq!(
454            list_ids,
455            vec!["openai/gpt-5.4", "groq/llama-3.3-70b-versatile"]
456        );
457    }
458
459    #[tokio::test]
460    #[allow(clippy::await_holding_lock)]
461    async fn list_models_pins_default_from_env_end_to_end() {
462        let _guard = default_env_lock();
463        let saved = std::env::var(DEFAULT_MODEL_ENV).ok();
464        unsafe {
465            std::env::set_var(DEFAULT_MODEL_ENV, "groq/llama-3.3-70b-versatile");
466        }
467        let p = two_backend_provider();
468        let models = p.list_models().await.unwrap();
469        let list_ids: Vec<String> = models.iter().map(|m| m.model_id.clone()).collect();
470        restore_default_env(saved);
471        assert_eq!(
472            list_ids,
473            vec!["groq/llama-3.3-70b-versatile", "openai/gpt-5.4"]
474        );
475    }
476
477    fn default_env_lock() -> std::sync::MutexGuard<'static, ()> {
478        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
479        LOCK.get_or_init(|| std::sync::Mutex::new(()))
480            .lock()
481            .unwrap_or_else(std::sync::PoisonError::into_inner)
482    }
483
484    fn restore_default_env(saved: Option<String>) {
485        if let Some(v) = saved {
486            unsafe {
487                std::env::set_var(DEFAULT_MODEL_ENV, v);
488            }
489        } else {
490            unsafe {
491                std::env::remove_var(DEFAULT_MODEL_ENV);
492            }
493        }
494    }
495
496    #[tokio::test]
497    async fn unknown_model_is_fatal() {
498        let p = MultiProvider::from_backends(vec![]);
499        let res = p
500            .invoke_model(
501                "nope/nothing",
502                CompletionRequest {
503                    chat_history: vec![infinity_provider_protocol::Message::user("hi")],
504                    ..Default::default()
505                },
506            )
507            .await;
508        let Err(err) = res else {
509            panic!("unknown model must fail");
510        };
511        assert_eq!(err.class(), ErrorClass::Fatal);
512    }
513
514    #[tokio::test]
515    async fn unlisted_upstream_model_passes_through_on_known_backend() {
516        // No network: the passthrough resolves routing, then fails at
517        // connect — but with a non-fatal transport class, proving it routed
518        // to the backend instead of rejecting the id.
519        let p = MultiProvider::from_backends(vec![backend(
520            "groq",
521            vec![("llama-3.3-70b-versatile", "Llama")],
522        )]);
523        let res = p
524            .invoke_model(
525                "groq/some-future-model",
526                CompletionRequest {
527                    chat_history: vec![infinity_provider_protocol::Message::user("hi")],
528                    ..Default::default()
529                },
530            )
531            .await;
532        let Err(err) = res else {
533            panic!("unreachable example endpoint must fail");
534        };
535        assert_ne!(err.class(), ErrorClass::Fatal);
536    }
537}