1use super::types::ProviderConfig;
9use agent_core::auth::broker;
10use serde::Deserialize;
11
12#[derive(Debug)]
13pub struct ProviderSpec {
14 pub key: &'static str,
15 pub name: &'static str,
16 pub base_url: &'static str,
17 pub env_vars: &'static [&'static str],
18 pub default_model: &'static str,
19 pub models: &'static [(&'static str, &'static str, &'static str)], }
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ProviderModelInfo {
24 pub id: String,
25 pub name: Option<String>,
26}
27
28#[derive(Debug, Deserialize)]
29struct ProviderModelsResponse {
30 data: Vec<ProviderModelsItem>,
31}
32
33#[derive(Debug, Deserialize)]
34struct ProviderModelsItem {
35 id: String,
36 #[serde(default)]
37 name: Option<String>,
38}
39
40pub fn parse_provider_models_response(
41 body: &str,
42) -> Result<Vec<ProviderModelInfo>, serde_json::Error> {
43 let response: ProviderModelsResponse = serde_json::from_str(body)?;
44 Ok(response
45 .data
46 .into_iter()
47 .filter(|item| !item.id.trim().is_empty())
48 .map(|item| ProviderModelInfo {
49 id: item.id,
50 name: item.name.filter(|name| !name.trim().is_empty()),
51 })
52 .collect())
53}
54
55pub fn providers() -> &'static [ProviderSpec] {
56 static PROVIDERS: std::sync::LazyLock<Vec<ProviderSpec>> = std::sync::LazyLock::new(|| {
57 vec![
58 ProviderSpec {
59 key: "groq",
60 name: "Groq",
61 base_url: "https://api.groq.com/openai/v1",
62 env_vars: &["GROQ_API_KEY"],
63 default_model: "llama-3.3-70b-versatile",
64 models: &[
65 ("llama-3.3-70b-versatile", "Llama 3.3 70B", "S"),
66 ("llama-3.1-8b-instant", "Llama 3.1 8B", "B"),
67 (
68 "meta-llama/llama-4-scout-17b-16e-instruct",
69 "Llama 4 Scout",
70 "A",
71 ),
72 (
73 "meta-llama/llama-4-maverick-17b-128e-instruct",
74 "Llama 4 Maverick",
75 "S",
76 ),
77 ],
78 },
79 ProviderSpec {
80 key: "cerebras",
81 name: "Cerebras",
82 base_url: "https://api.cerebras.ai/v1",
83 env_vars: &["CEREBRAS_API_KEY"],
84 default_model: "llama3.1-8b",
85 models: &[
86 ("qwen-3-235b-a22b-instruct-2507", "Qwen3 235B", "S+"),
87 ("llama3.1-8b", "Llama 3.1 8B", "B"),
88 ],
89 },
90 ProviderSpec {
91 key: "nvidia",
92 name: "NVIDIA NIM",
93 base_url: "https://integrate.api.nvidia.com/v1",
94 env_vars: &["NVIDIA_API_KEY"],
95 default_model: "meta/llama-3.3-70b-instruct",
96 models: &[
97 (
98 "qwen/qwen3-coder-480b-a35b-instruct",
99 "Qwen3 Coder 480B",
100 "S+",
101 ),
102 (
103 "mistralai/mistral-large-3-675b-instruct-2512",
104 "Mistral Large 675B",
105 "A+",
106 ),
107 ("meta/llama-3.3-70b-instruct", "Llama 3.3 70B", "A"),
108 (
109 "meta/llama-4-maverick-17b-128e-instruct",
110 "Llama 4 Maverick",
111 "S",
112 ),
113 ("meta/llama-4-scout-17b-16e-instruct", "Llama 4 Scout", "A"),
114 (
115 "nvidia/llama-3.1-nemotron-ultra-253b-v1",
116 "Nemotron Ultra 253B",
117 "A+",
118 ),
119 (
120 "mistralai/devstral-2-123b-instruct-2512",
121 "Devstral 2 123B",
122 "S+",
123 ),
124 ("minimaxai/minimax-m2.5", "MiniMax M2.5", "S+"),
125 ("stepfun-ai/step-3.5-flash", "Step 3.5 Flash", "S+"),
126 ],
127 },
128 ProviderSpec {
129 key: "sambanova",
130 name: "SambaNova",
131 base_url: "https://api.sambanova.ai/v1",
132 env_vars: &["SAMBANOVA_API_KEY"],
133 default_model: "Meta-Llama-3.3-70B-Instruct",
134 models: &[
135 ("QwQ-32B", "QwQ 32B", "A+"),
136 ("Meta-Llama-3.3-70B-Instruct", "Llama 3.3 70B", "S"),
137 ("Meta-Llama-3.1-8B-Instruct", "Llama 3.1 8B", "B"),
138 ("DeepSeek-R1", "DeepSeek R1", "S+"),
139 ("DeepSeek-R1-Distill-Llama-70B", "R1 Distill 70B", "A"),
140 ("Qwen3-32B", "Qwen3 32B", "A"),
141 ],
142 },
143 ProviderSpec {
144 key: "openrouter",
145 name: "OpenRouter",
146 base_url: "https://openrouter.ai/api/v1",
147 env_vars: &["OPENROUTER_API_KEY"],
148 default_model: "meta-llama/llama-3.3-70b-instruct",
149 models: &[
150 ("qwen/qwen3-coder", "Qwen3 Coder", "S+"),
151 ("meta-llama/llama-3.3-70b-instruct", "Llama 3.3 70B", "S"),
152 ("deepseek/deepseek-chat-v3-0324", "DeepSeek V3", "S"),
153 ("google/gemma-3-27b-it", "Gemma 3 27B", "A"),
154 (
155 "mistralai/mistral-small-3.1-24b-instruct",
156 "Mistral Small 3.1",
157 "A",
158 ),
159 ],
160 },
161 ProviderSpec {
162 key: "google",
163 name: "Google AI Studio",
164 base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
165 env_vars: &["GOOGLE_API_KEY"],
166 default_model: "gemini-2.5-flash",
167 models: &[
168 ("gemini-2.5-flash", "Gemini 2.5 Flash", "A+"),
169 ("gemini-2.0-flash", "Gemini 2.0 Flash", "B+"),
170 ("gemma-3-27b-it", "Gemma 3 27B", "A"),
171 ],
172 },
173 ProviderSpec {
174 key: "deepinfra",
175 name: "DeepInfra",
176 base_url: "https://api.deepinfra.com/v1/openai",
177 env_vars: &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
178 default_model: "meta-llama/Llama-3.3-70B-Instruct",
179 models: &[
180 ("meta-llama/Llama-3.3-70B-Instruct", "Llama 3.3 70B", "S"),
181 ("Qwen/Qwen2.5-Coder-32B-Instruct", "Qwen2.5 Coder 32B", "A"),
182 ("deepseek-ai/DeepSeek-V3-0324", "DeepSeek V3", "S"),
183 ],
184 },
185 ProviderSpec {
186 key: "huggingface",
187 name: "HuggingFace",
188 base_url: "https://router.huggingface.co/v1",
189 env_vars: &["HUGGINGFACE_API_KEY", "HF_TOKEN"],
190 default_model: "meta-llama/Llama-3.3-70B-Instruct",
191 models: &[
192 ("meta-llama/Llama-3.3-70B-Instruct", "Llama 3.3 70B", "S"),
193 ("Qwen/Qwen2.5-72B-Instruct", "Qwen2.5 72B", "A"),
194 ],
195 },
196 ProviderSpec {
197 key: "fireworks",
198 name: "Fireworks AI",
199 base_url: "https://api.fireworks.ai/inference/v1",
200 env_vars: &["FIREWORKS_API_KEY"],
201 default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
202 models: &[
203 (
204 "accounts/fireworks/models/llama-v3p3-70b-instruct",
205 "Llama 3.3 70B",
206 "S",
207 ),
208 (
209 "accounts/fireworks/models/qwen2p5-coder-32b-instruct",
210 "Qwen2.5 Coder 32B",
211 "A",
212 ),
213 ],
214 },
215 ProviderSpec {
216 key: "hyperbolic",
217 name: "Hyperbolic",
218 base_url: "https://api.hyperbolic.xyz/v1",
219 env_vars: &["HYPERBOLIC_API_KEY"],
220 default_model: "meta-llama/Llama-3.3-70B-Instruct",
221 models: &[
222 ("meta-llama/Llama-3.3-70B-Instruct", "Llama 3.3 70B", "S"),
223 ("Qwen/Qwen2.5-Coder-32B-Instruct", "Qwen2.5 Coder 32B", "A"),
224 ("deepseek-ai/DeepSeek-V3-0324", "DeepSeek V3", "S"),
225 ],
226 },
227 ProviderSpec {
228 key: "scaleway",
229 name: "Scaleway",
230 base_url: "https://api.scaleway.ai/v1",
231 env_vars: &["SCALEWAY_API_KEY"],
232 default_model: "llama-3.3-70b-instruct",
233 models: &[
234 ("llama-3.3-70b-instruct", "Llama 3.3 70B", "S"),
235 ("qwen3-235b-a22b", "Qwen3 235B", "S+"),
236 ],
237 },
238 ProviderSpec {
239 key: "siliconflow",
240 name: "SiliconFlow",
241 base_url: "https://api.siliconflow.cn/v1",
242 env_vars: &["SILICONFLOW_API_KEY"],
243 default_model: "Qwen/Qwen3-8B",
244 models: &[
245 ("Qwen/Qwen3-8B", "Qwen3 8B", "A-"),
246 ("deepseek-ai/DeepSeek-R1", "DeepSeek R1", "S+"),
247 ],
248 },
249 ProviderSpec {
250 key: "together",
251 name: "Together AI",
252 base_url: "https://api.together.xyz/v1",
253 env_vars: &["TOGETHER_API_KEY"],
254 default_model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
255 models: &[
256 (
257 "meta-llama/Llama-3.3-70B-Instruct-Turbo",
258 "Llama 3.3 70B",
259 "S",
260 ),
261 ("Qwen/Qwen2.5-Coder-32B-Instruct", "Qwen2.5 Coder 32B", "A"),
262 ("deepseek-ai/DeepSeek-V3", "DeepSeek V3", "S"),
263 ],
264 },
265 ProviderSpec {
266 key: "chutes",
267 name: "Chutes AI",
268 base_url: "https://llm.chutes.ai/v1",
269 env_vars: &["CHUTES_API_KEY"],
270 default_model: "deepseek-ai/DeepSeek-V3-0324",
271 models: &[("deepseek-ai/DeepSeek-V3-0324", "DeepSeek V3", "S")],
272 },
273 ProviderSpec {
274 key: "codestral",
275 name: "Codestral (Mistral)",
276 base_url: "https://api.mistral.ai/v1",
277 env_vars: &["CODESTRAL_API_KEY"],
278 default_model: "codestral-latest",
279 models: &[("codestral-latest", "Codestral", "B+")],
280 },
281 ProviderSpec {
282 key: "perplexity",
283 name: "Perplexity",
284 base_url: "https://api.perplexity.ai",
285 env_vars: &["PERPLEXITY_API_KEY", "PPLX_API_KEY"],
286 default_model: "llama-3.1-sonar-large-128k-online",
287 models: &[("llama-3.1-sonar-large-128k-online", "Sonar Large", "A+")],
288 },
289 ProviderSpec {
290 key: "ovhcloud",
291 name: "OVHcloud",
292 base_url: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
293 env_vars: &["OVH_AI_ENDPOINTS_ACCESS_TOKEN"],
294 default_model: "Meta-Llama-3.3-70B-Instruct",
295 models: &[
296 ("Meta-Llama-3.3-70B-Instruct", "Llama 3.3 70B", "S"),
297 ("Qwen/QwQ-32B", "QwQ 32B", "A+"),
298 ],
299 },
300 ProviderSpec {
301 key: "inferx",
302 name: "InferX",
303 base_url: "https://model.inferx.net/endpoints/v1",
304 env_vars: &["INFERX_API_KEY"],
305 default_model: "deepseek-v4-flash",
306 models: &[("deepseek-v4-flash", "DeepSeek V4 Flash", "A")],
307 },
308 ProviderSpec {
309 key: "kimi",
310 name: "Kimi (Moonshot AI)",
311 base_url: "https://api.moonshot.ai/v1",
312 env_vars: &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
313 default_model: "kimi-k3.1-pro",
316 models: &[
317 ("kimi-k3.1-pro", "Kimi K3.1 Pro", "S+"),
318 ("kimi-k3.1-pro-highspeed", "Kimi K3.1 Pro Highspeed", "S+"),
319 ("kimi-k3", "Kimi K3", "S+"),
320 ("kimi-k2.7-code", "Kimi K2.7 Code", "S+"),
321 ("kimi-k2.7-code-highspeed", "Kimi K2.7 Code Highspeed", "S"),
322 ("kimi-k2.6", "Kimi K2.6", "S"),
323 ],
324 },
325 ]
326 });
327 &PROVIDERS
328}
329
330pub fn resolve_provider(key: &str) -> Option<(ProviderConfig, &'static str)> {
333 let specs = providers();
334 let spec = specs.iter().find(|s| s.key == key)?;
335 if !broker::static_key_configured(spec.key) {
336 return None;
337 }
338 Some((
339 ProviderConfig {
340 base_url: spec.base_url.to_string(),
341 model: spec.default_model.to_string(),
342 provider: spec.key.to_string(),
343 },
344 spec.default_model,
345 ))
346}
347
348pub fn resolve_provider_model(key: &str, model: &str) -> Option<ProviderConfig> {
350 if key == "local" {
352 return Some(resolve_local(model));
353 }
354 let specs = providers();
355 let spec = specs.iter().find(|s| s.key == key)?;
356 if !broker::static_key_configured(spec.key) {
357 return None;
358 }
359 Some(ProviderConfig {
360 base_url: spec.base_url.to_string(),
361 model: model.to_string(),
362 provider: spec.key.to_string(),
363 })
364}
365
366pub fn resolve_shorthand(s: &str) -> Option<ProviderConfig> {
368 let (provider_key, model) = s.split_once('/')?;
369 resolve_provider_model(provider_key, model)
370}
371
372pub fn resolve_codex_shorthand(s: &str) -> Option<ProviderConfig> {
374 let (provider_key, model) = s.split_once('/')?;
375 if provider_key != "openai-codex" {
376 return None;
377 }
378 Some(ProviderConfig {
379 base_url: "https://chatgpt.com/backend-api".to_string(),
380 model: model.to_string(),
383 provider: "openai-codex".to_string(),
384 })
385}
386
387fn resolve_local(model: &str) -> ProviderConfig {
393 ProviderConfig {
394 base_url: broker::local_endpoint_url(),
395 model: model.to_string(),
396 provider: "local".to_string(),
397 }
398}
399
400pub async fn fetch_provider_models(provider_key: &str) -> Result<Vec<ProviderModelInfo>, String> {
401 let spec = providers()
402 .iter()
403 .find(|spec| spec.key == provider_key)
404 .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
405 let response = broker::global_broker()
407 .proxy(broker::ProxyRequest {
408 provider: spec.key.to_string(),
409 method: broker::ProxyMethod::Get,
410 path: "/models".to_string(),
411 body: None,
412 stream: false,
413 body_bytes: None,
414 })
415 .await
416 .map_err(|e| e.to_string())?;
417 if !(200..300).contains(&response.status) {
418 return Err(format!("model list failed: HTTP {}", response.status));
419 }
420 parse_provider_models_response(&response.body)
421 .map_err(|e| format!("failed to parse model list: {e}"))
422}
423
424pub fn list_providers() -> Vec<(&'static str, &'static str, bool, usize)> {
426 providers()
427 .iter()
428 .map(|s| {
429 let has_key = broker::static_key_configured(s.key);
430 (s.key, s.name, has_key, s.models.len())
431 })
432 .collect()
433}
434
435pub fn list_models(key: &str) -> Option<Vec<(&'static str, &'static str, &'static str)>> {
437 let specs = providers();
438 let spec = specs.iter().find(|s| s.key == key)?;
439 Some(spec.models.to_vec())
440}
441
442pub fn configured_providers() -> Vec<(&'static str, &'static str, &'static str)> {
444 providers()
445 .iter()
446 .filter(|s| broker::static_key_configured(s.key))
447 .map(|s| (s.key, s.name, s.default_model))
448 .collect()
449}
450
451#[cfg(test)]
452mod model_list_tests {
453 use super::*;
454
455 #[test]
459 fn engine_registry_matches_broker_static_provider_table() {
460 for spec in providers() {
461 let core = agent_core::auth::static_provider(spec.key).unwrap_or_else(|| {
462 panic!(
463 "engine provider '{}' missing from broker static table",
464 spec.key
465 )
466 });
467 assert_eq!(
468 spec.base_url, core.base_url,
469 "base_url drift for {}",
470 spec.key
471 );
472 assert_eq!(
473 spec.env_vars, core.env_vars,
474 "env_vars drift for {}",
475 spec.key
476 );
477 assert_eq!(spec.name, core.name, "name drift for {}", spec.key);
478 }
479 assert_eq!(
480 providers().len(),
481 agent_core::auth::static_providers::STATIC_PROVIDERS.len(),
482 "broker table and engine catalog must cover the same providers"
483 );
484 }
485
486 #[test]
490 fn resolve_local_carries_endpoint_but_no_credential() {
491 let cfg = resolve_provider_model("local", "llama3").expect("local always resolves");
492 assert_eq!(cfg.provider, "local");
493 assert_eq!(cfg.model, "llama3");
494 assert!(!cfg.base_url.is_empty());
495 let debug = format!("{cfg:?}");
496 assert!(
497 !debug.to_lowercase().contains("api_key"),
498 "no key field may exist: {debug}"
499 );
500 }
501
502 #[test]
503 fn inferx_catalog_exposes_its_default_model_and_pinned_endpoint() {
504 let spec = providers()
505 .iter()
506 .find(|spec| spec.key == "inferx")
507 .expect("InferX must be registered");
508 assert_eq!(spec.name, "InferX");
509 assert_eq!(spec.base_url, "https://model.inferx.net/endpoints/v1");
510 assert_eq!(spec.env_vars, ["INFERX_API_KEY"]);
511 assert_eq!(spec.default_model, "deepseek-v4-flash");
512 assert_eq!(
513 spec.models,
514 [("deepseek-v4-flash", "DeepSeek V4 Flash", "A")]
515 );
516 }
517
518 #[test]
519 fn inferx_routes_any_published_model_through_its_pinned_endpoint() {
520 let route = crate::runtime::openai::resolve_route("inferx/deepseek-v4-flash")
521 .expect("InferX's registered default must route");
522 assert_eq!(route.provider, "inferx");
523 assert_eq!(route.model, "deepseek-v4-flash");
524 assert_eq!(route.endpoint, "https://model.inferx.net/endpoints/v1");
525
526 let dynamic = crate::runtime::openai::resolve_route("inferx/future-model")
527 .expect("OpenAI-compatible providers accept published model IDs");
528 assert_eq!(dynamic.model, "future-model");
529 assert_eq!(dynamic.endpoint, route.endpoint);
530 }
531
532 #[test]
533 fn kimi_catalog_lists_k3_1_pro_and_highspeed_with_k3_1_pro_as_default() {
534 let spec = providers()
535 .iter()
536 .find(|spec| spec.key == "kimi")
537 .expect("Kimi must be registered");
538 assert_eq!(spec.base_url, "https://api.moonshot.ai/v1");
539 assert_eq!(spec.default_model, "kimi-k3.1-pro");
542 assert_eq!(spec.models[0].0, spec.default_model);
543 for (id, label) in [
544 ("kimi-k3.1-pro", "Kimi K3.1 Pro"),
545 ("kimi-k3.1-pro-highspeed", "Kimi K3.1 Pro Highspeed"),
546 ] {
547 let row = spec
548 .models
549 .iter()
550 .find(|(model, _, _)| *model == id)
551 .unwrap_or_else(|| panic!("{id} must be in the Kimi catalog"));
552 assert_eq!(row.1, label, "{id}");
553 }
554 }
555
556 #[test]
560 fn every_kimi_catalog_row_has_a_reasoning_capability() {
561 let spec = providers()
562 .iter()
563 .find(|spec| spec.key == "kimi")
564 .expect("Kimi must be registered");
565 for (id, _, _) in spec.models {
566 assert!(
567 crate::runtime::openai::catalog::kimi::kimi_static_capability(id).is_some(),
568 "{id} has no Kimi reasoning capability row"
569 );
570 }
571 }
572
573 #[test]
574 fn parses_openrouter_models_response() {
575 let json = r#"{
576 "data": [
577 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
578 { "id": "openai/gpt-oss-120b" }
579 ]
580 }"#;
581
582 let models = parse_provider_models_response(json).expect("parse models");
583 assert_eq!(models.len(), 2);
584 assert_eq!(models[0].id, "qwen/qwen3-coder");
585 assert_eq!(models[0].name.as_deref(), Some("Qwen: Qwen3 Coder"));
586 assert_eq!(models[1].id, "openai/gpt-oss-120b");
587 assert_eq!(models[1].name, None);
588 }
589}