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
144#[async_trait]
145impl ModelProvider for MultiProvider {
146    async fn list_models(&self) -> Result<Vec<ModelEntry>, BoxError> {
147        // Advertise in registration order so the global default (the first
148        // model of the first backend) follows preset order.
149        let mut routes: Vec<&Route> = self.routes.values().collect();
150        routes.sort_by_key(|r| r.order);
151        let mut entries: Vec<ModelEntry> = routes.into_iter().map(|r| r.entry.clone()).collect();
152        let mut known: std::collections::HashSet<String> =
153            entries.iter().map(|e| e.model_id.clone()).collect();
154        // Live discovery runs concurrently across backends; each probe is
155        // timeout-guarded and falls back to the curated list on failure.
156        // Discovered ids need no route entries: `invoke_model` passes any
157        // `<backend>/<id>` through to its backend.
158        let probed = futures_util::future::join_all(
159            self.backends.iter().filter(|b| b.discover).map(|b| async {
160                let ids =
161                    crate::openai::discover_models(&self.client, &b.base_url, &b.api_key).await;
162                (b.preset_id, ids)
163            }),
164        )
165        .await;
166        for (preset_id, ids) in probed {
167            for id in ids {
168                let model_id = format!("{preset_id}/{id}");
169                if known.contains(&model_id) {
170                    continue;
171                }
172                // Conservative: unknown models are assumed text-only until
173                // curated (an unexpected image payload fails the whole
174                // turn; a placeholder does not).
175                entries.push(ModelEntry {
176                    model_id: model_id.clone(),
177                    display_name: format!("{id} ({preset_id})"),
178                    context_window: 200_000,
179                    max_output_tokens: Some(32_000),
180                    supports_image_input: false,
181                });
182                known.insert(model_id);
183            }
184        }
185        Ok(entries)
186    }
187
188    async fn invoke_model(
189        &self,
190        model_id: &str,
191        request: CompletionRequest,
192    ) -> Result<ModelStream, CompletionError> {
193        // `<backend>/<id>` passthrough: allow unknown upstream ids on a
194        // backend whose preset id matches the prefix, so callers can address
195        // upstream models not in the advertised list.
196        let route = self.routes.get(model_id).cloned().or_else(|| {
197            model_id.split_once('/').and_then(|(prefix, rest)| {
198                self.backends.iter().enumerate().find_map(|(idx, b)| {
199                    if b.preset_id == prefix && !rest.is_empty() {
200                        Some(Route {
201                            entry: ModelEntry {
202                                model_id: model_id.to_owned(),
203                                display_name: model_id.to_owned(),
204                                context_window: 200_000,
205                                max_output_tokens: Some(32_000),
206                                supports_image_input: false,
207                            },
208                            backend_idx: idx,
209                            upstream_model: rest.to_owned(),
210                            default_max_tokens: Some(32_000),
211                            // Unordered: passthrough routes sort after all
212                            // catalogued routes.
213                            order: usize::MAX,
214                        })
215                    } else {
216                        None
217                    }
218                })
219            })
220        });
221        let Some(route) = route else {
222            return Err(CompletionError::provider(
223                ErrorClass::Fatal,
224                format!("unknown model: {model_id}"),
225            ));
226        };
227        let Some(backend) = self.backends.get(route.backend_idx) else {
228            return Err(CompletionError::provider(
229                ErrorClass::Fatal,
230                format!("unknown backend for model: {model_id}"),
231            ));
232        };
233        match backend.flavor {
234            Flavor::OpenAi => {
235                let target = openai::Backend {
236                    client: &self.client,
237                    base_url: &backend.base_url,
238                    api_key: &backend.api_key,
239                    extra_headers: &backend.extra_headers,
240                    supports_images: route.entry.supports_image_input,
241                };
242                openai::invoke(
243                    &target,
244                    &route.upstream_model,
245                    route.default_max_tokens,
246                    request,
247                )
248                .await
249            }
250            Flavor::Anthropic => {
251                let target = anthropic::Backend {
252                    client: &self.client,
253                    base_url: &backend.base_url,
254                    api_key: &backend.api_key,
255                    extra_headers: &backend.extra_headers,
256                    oauth: backend.oauth.as_ref(),
257                };
258                anthropic::invoke(
259                    &target,
260                    &route.upstream_model,
261                    route.default_max_tokens,
262                    request,
263                    route.entry.supports_image_input,
264                )
265                .await
266            }
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn backend(id: &'static str, models: Vec<(&str, &str)>) -> ResolvedBackend {
276        ResolvedBackend {
277            preset_id: id,
278            flavor: Flavor::OpenAi,
279            base_url: "https://example.test/v1".to_owned(),
280            api_key: "test".to_owned(),
281            models: models
282                .into_iter()
283                .map(|(m, d)| crate::preset::BackendModel {
284                    upstream: m.to_owned(),
285                    display: d.to_owned(),
286                    context_window: 128_000,
287                    max_output_tokens: Some(8_000),
288                    supports_images: true,
289                })
290                .collect(),
291            oauth: None,
292            discover: false,
293            extra_headers: Vec::new(),
294        }
295    }
296
297    #[tokio::test]
298    async fn lists_namespaced_models_in_registration_order() {
299        // Registration order (not alphabetical) decides the listing: the
300        // daemon treats the first model as the global default.
301        let p = MultiProvider::from_backends(vec![
302            backend("openai", vec![("gpt-5.4", "GPT")]),
303            backend("groq", vec![("llama-3.3-70b-versatile", "Llama")]),
304        ]);
305        let models = p.list_models().await.unwrap();
306        let ids: Vec<_> = models.iter().map(|m| m.model_id.as_str()).collect();
307        assert_eq!(ids, vec!["openai/gpt-5.4", "groq/llama-3.3-70b-versatile"]);
308    }
309
310    #[tokio::test]
311    async fn unknown_model_is_fatal() {
312        let p = MultiProvider::from_backends(vec![]);
313        let res = p
314            .invoke_model(
315                "nope/nothing",
316                CompletionRequest {
317                    chat_history: vec![infinity_provider_protocol::Message::user("hi")],
318                    ..Default::default()
319                },
320            )
321            .await;
322        let Err(err) = res else {
323            panic!("unknown model must fail");
324        };
325        assert_eq!(err.class(), ErrorClass::Fatal);
326    }
327
328    #[tokio::test]
329    async fn unlisted_upstream_model_passes_through_on_known_backend() {
330        // No network: the passthrough resolves routing, then fails at
331        // connect — but with a non-fatal transport class, proving it routed
332        // to the backend instead of rejecting the id.
333        let p = MultiProvider::from_backends(vec![backend(
334            "groq",
335            vec![("llama-3.3-70b-versatile", "Llama")],
336        )]);
337        let res = p
338            .invoke_model(
339                "groq/some-future-model",
340                CompletionRequest {
341                    chat_history: vec![infinity_provider_protocol::Message::user("hi")],
342                    ..Default::default()
343                },
344            )
345            .await;
346        let Err(err) = res else {
347            panic!("unreachable example endpoint must fail");
348        };
349        assert_ne!(err.class(), ErrorClass::Fatal);
350    }
351}