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}
56
57pub struct MultiProvider {
59 client: reqwest::Client,
60 backends: Vec<ResolvedBackend>,
61 routes: HashMap<String, Route>,
62}
63
64impl MultiProvider {
65 #[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 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 #[must_use]
126 pub fn backends(&self) -> &[ResolvedBackend] {
127 &self.backends
128 }
129
130 #[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 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 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 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}