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
42#[derive(Clone, Debug)]
44struct Route {
45 entry: ModelEntry,
46 backend_idx: usize,
47 upstream_model: String,
48 default_max_tokens: Option<u64>,
49}
50
51pub struct MultiProvider {
53 client: reqwest::Client,
54 backends: Vec<ResolvedBackend>,
55 routes: HashMap<String, Route>,
56}
57
58impl MultiProvider {
59 #[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 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 #[must_use]
120 pub fn backends(&self) -> &[ResolvedBackend] {
121 &self.backends
122 }
123
124 #[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 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 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 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}