1pub 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 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
184pub 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 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 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 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 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 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 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 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 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 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}