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