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/// One advertised model: its infinity-facing id plus where to route it.
43#[derive(Clone, Debug)]
44struct Route {
45    entry: ModelEntry,
46    backend_idx: usize,
47    upstream_model: String,
48    default_max_tokens: Option<u64>,
49}
50
51/// [`ModelProvider`] that fans out to every env-configured backend.
52pub struct MultiProvider {
53    client: reqwest::Client,
54    backends: Vec<ResolvedBackend>,
55    routes: HashMap<String, Route>,
56}
57
58impl MultiProvider {
59    /// Build from the current process environment. Logs configured backends
60    /// to stderr (stdout is reserved for the socket path).
61    #[must_use]
62    pub fn from_env() -> Self {
63        Self::from_backends(preset::resolve_backends())
64    }
65
66    #[must_use]
67    pub fn from_backends(backends: Vec<ResolvedBackend>) -> Self {
68        let client = reqwest::Client::new();
69        let mut routes = HashMap::new();
70        for (idx, backend) in backends.iter().enumerate() {
71            for m in &backend.models {
72                let model_id = format!("{}/{}", backend.preset_id, m.upstream);
73                // First backend wins on collision; ids are namespaced so this
74                // only triggers for duplicate custom config.
75                if routes.contains_key(&model_id) {
76                    tracing::warn!("duplicate model id {model_id}, keeping first");
77                    continue;
78                }
79                let entry = ModelEntry {
80                    model_id: model_id.clone(),
81                    display_name: format!("{} ({})", m.display, backend.preset_id),
82                    context_window: m.context_window,
83                    max_output_tokens: m.max_output_tokens,
84                    supports_image_input: m.supports_images,
85                };
86                routes.insert(
87                    model_id,
88                    Route {
89                        entry,
90                        backend_idx: idx,
91                        upstream_model: m.upstream.clone(),
92                        default_max_tokens: m.max_output_tokens,
93                    },
94                );
95            }
96        }
97        for b in &backends {
98            tracing::info!(
99                "backend {}: {} model(s) at {}",
100                b.preset_id,
101                b.models.len(),
102                b.base_url,
103            );
104        }
105        if backends.is_empty() {
106            tracing::warn!(
107                "no backends configured: set one of the pi/opencode API-key env vars \
108                 (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY)"
109            );
110        }
111        Self {
112            client,
113            backends,
114            routes,
115        }
116    }
117
118    /// Backends in resolution order (for debugging / tests).
119    #[must_use]
120    pub fn backends(&self) -> &[ResolvedBackend] {
121        &self.backends
122    }
123
124    /// Share the provider behind `Arc` for `serve_provider`.
125    #[must_use]
126    pub fn into_arc(self) -> Arc<Self> {
127        Arc::new(self)
128    }
129}
130
131#[async_trait]
132impl ModelProvider for MultiProvider {
133    async fn list_models(&self) -> Result<Vec<ModelEntry>, BoxError> {
134        let mut entries: Vec<ModelEntry> = self.routes.values().map(|r| r.entry.clone()).collect();
135        // Live discovery runs concurrently across backends; each probe is
136        // timeout-guarded and falls back to the curated list on failure.
137        // Discovered ids need no route entries: `invoke_model` passes any
138        // `<backend>/<id>` through to its backend.
139        let probed = futures_util::future::join_all(
140            self.backends.iter().filter(|b| b.discover).map(|b| async {
141                let ids =
142                    crate::openai::discover_models(&self.client, &b.base_url, &b.api_key).await;
143                (b.preset_id, ids)
144            }),
145        )
146        .await;
147        for (preset_id, ids) in probed {
148            for id in ids {
149                let model_id = format!("{preset_id}/{id}");
150                if entries.iter().any(|e| e.model_id == model_id) {
151                    continue;
152                }
153                entries.push(ModelEntry {
154                    model_id,
155                    display_name: format!("{id} ({preset_id})"),
156                    context_window: 200_000,
157                    max_output_tokens: Some(32_000),
158                    supports_image_input: true,
159                });
160            }
161        }
162        entries.sort_by(|a, b| a.model_id.cmp(&b.model_id));
163        Ok(entries)
164    }
165
166    async fn invoke_model(
167        &self,
168        model_id: &str,
169        request: CompletionRequest,
170    ) -> Result<ModelStream, CompletionError> {
171        // `bedrock/<id>` passthrough: allow unknown bedrock-style ids on a
172        // backend whose preset id matches the prefix, so callers can address
173        // upstream models not in the advertised list.
174        let route = self.routes.get(model_id).cloned().or_else(|| {
175            model_id.split_once('/').and_then(|(prefix, rest)| {
176                self.backends.iter().enumerate().find_map(|(idx, b)| {
177                    if b.preset_id == prefix && !rest.is_empty() {
178                        Some(Route {
179                            entry: ModelEntry {
180                                model_id: model_id.to_owned(),
181                                display_name: model_id.to_owned(),
182                                context_window: 200_000,
183                                max_output_tokens: Some(32_000),
184                                supports_image_input: true,
185                            },
186                            backend_idx: idx,
187                            upstream_model: rest.to_owned(),
188                            default_max_tokens: Some(32_000),
189                        })
190                    } else {
191                        None
192                    }
193                })
194            })
195        });
196        let Some(route) = route else {
197            return Err(CompletionError::provider(
198                ErrorClass::Fatal,
199                format!("unknown model: {model_id}"),
200            ));
201        };
202        let Some(backend) = self.backends.get(route.backend_idx) else {
203            return Err(CompletionError::provider(
204                ErrorClass::Fatal,
205                format!("unknown backend for model: {model_id}"),
206            ));
207        };
208        match backend.flavor {
209            Flavor::OpenAi => {
210                openai::invoke(
211                    &self.client,
212                    &backend.base_url,
213                    &backend.api_key,
214                    &backend.extra_headers,
215                    &route.upstream_model,
216                    route.default_max_tokens,
217                    request,
218                )
219                .await
220            }
221            Flavor::Anthropic => {
222                let target = anthropic::Backend {
223                    client: &self.client,
224                    base_url: &backend.base_url,
225                    api_key: &backend.api_key,
226                    extra_headers: &backend.extra_headers,
227                    oauth: backend.oauth.as_ref(),
228                };
229                anthropic::invoke(
230                    &target,
231                    &route.upstream_model,
232                    route.default_max_tokens,
233                    request,
234                )
235                .await
236            }
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn backend(id: &'static str, models: Vec<(&str, &str)>) -> ResolvedBackend {
246        ResolvedBackend {
247            preset_id: id,
248            flavor: Flavor::OpenAi,
249            base_url: "https://example.test/v1".to_owned(),
250            api_key: "test".to_owned(),
251            models: models
252                .into_iter()
253                .map(|(m, d)| crate::preset::BackendModel {
254                    upstream: m.to_owned(),
255                    display: d.to_owned(),
256                    context_window: 128_000,
257                    max_output_tokens: Some(8_000),
258                    supports_images: true,
259                })
260                .collect(),
261            oauth: None,
262            discover: false,
263            extra_headers: Vec::new(),
264        }
265    }
266
267    #[tokio::test]
268    async fn lists_namespaced_models_sorted() {
269        let p = MultiProvider::from_backends(vec![
270            backend("groq", vec![("llama-3.3-70b-versatile", "Llama")]),
271            backend("openai", vec![("gpt-5.4", "GPT")]),
272        ]);
273        let models = p.list_models().await.unwrap();
274        let ids: Vec<_> = models.iter().map(|m| m.model_id.as_str()).collect();
275        assert_eq!(ids, vec!["groq/llama-3.3-70b-versatile", "openai/gpt-5.4"]);
276    }
277
278    #[tokio::test]
279    async fn unknown_model_is_fatal() {
280        let p = MultiProvider::from_backends(vec![]);
281        let res = p
282            .invoke_model(
283                "nope/nothing",
284                CompletionRequest {
285                    chat_history: vec![infinity_provider_protocol::Message::user("hi")],
286                    ..Default::default()
287                },
288            )
289            .await;
290        let Err(err) = res else {
291            panic!("unknown model must fail");
292        };
293        assert_eq!(err.class(), ErrorClass::Fatal);
294    }
295
296    #[tokio::test]
297    async fn unlisted_upstream_model_passes_through_on_known_backend() {
298        // No network: the passthrough resolves routing, then fails at
299        // connect — but with a non-fatal transport class, proving it routed
300        // to the backend instead of rejecting the id.
301        let p = MultiProvider::from_backends(vec![backend(
302            "groq",
303            vec![("llama-3.3-70b-versatile", "Llama")],
304        )]);
305        let res = p
306            .invoke_model(
307                "groq/some-future-model",
308                CompletionRequest {
309                    chat_history: vec![infinity_provider_protocol::Message::user("hi")],
310                    ..Default::default()
311                },
312            )
313            .await;
314        let Err(err) = res else {
315            panic!("unreachable example endpoint must fail");
316        };
317        assert_ne!(err.class(), ErrorClass::Fatal);
318    }
319}