infinity_provider_models_dev/
lib.rs1pub 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
42pub const USER_AGENT: &str = concat!("infinity-provider-models-dev/", env!("CARGO_PKG_VERSION"));
47
48#[derive(Clone, Debug)]
50struct Route {
51 entry: ModelEntry,
52 backend_idx: usize,
53 upstream_model: String,
54 default_max_tokens: Option<u64>,
55 order: usize,
59}
60
61pub struct MultiProvider {
63 client: reqwest::Client,
64 backends: Vec<ResolvedBackend>,
65 routes: HashMap<String, Route>,
66}
67
68impl MultiProvider {
69 #[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 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 #[must_use]
133 pub fn backends(&self) -> &[ResolvedBackend] {
134 &self.backends
135 }
136
137 #[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 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 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 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 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 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 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 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}