Skip to main content

agent_engine/runtime/openai/
registry.rs

1//! Provider registry — catalog of known OpenAI-compatible endpoints.
2//!
3//! Connection/credential metadata (base URLs, key discovery) is broker-owned
4//! (`agent_core::auth::static_providers` / `agent_core::auth::broker`). This
5//! module keeps the engine-side model catalog and produces credential-free
6//! routing data: no function here returns or accepts an API key.
7
8use 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)], // (id, label, tier)
20}
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                // K3.1 Pro: Moonshot closes the K2 series to new users from
314                // 2026-10-31. It is a flagship model, unlocked after a top-up.
315                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
330/// Look up a provider by key. Returns routing data only when the broker
331/// reports a credential is available; the key itself never leaves the broker.
332pub 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
348/// Resolve a provider + specific model to credential-free routing data.
349pub fn resolve_provider_model(key: &str, model: &str) -> Option<ProviderConfig> {
350    // Special case: local provider — dynamic (non-secret) URL configuration.
351    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
366/// Resolve `"provider/model"` shorthand.
367pub fn resolve_shorthand(s: &str) -> Option<ProviderConfig> {
368    let (provider_key, model) = s.split_once('/')?;
369    resolve_provider_model(provider_key, model)
370}
371
372/// Resolve `"openai-codex/model"` shorthand if Codex OAuth is configured.
373pub 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        // Credentials are resolved immediately before the request through the
381        // credential broker. Routing metadata never carries secrets.
382        model: model.to_string(),
383        provider: "openai-codex".to_string(),
384    })
385}
386
387/// Resolve a local model endpoint (Ollama, LM Studio, vLLM, llama.cpp, etc.)
388///
389/// URL resolution (non-secret): `provider.local.url` config → `LOCAL_ENDPOINT`
390/// env → `http://localhost:11434/v1`. Any optional key stays broker-owned and
391/// is applied at request time by the broker proxy.
392fn 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    // The broker applies the credential; this path never sees the key.
406    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
424/// List all providers with (non-secret) key status.
425pub 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
435/// List models for a provider.
436pub 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
442/// Find all providers with a broker-available credential.
443pub 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    /// Cross-registry invariant: every engine provider joins onto a broker
456    /// static-provider spec with identical connection metadata, so the URL a
457    /// route uses is exactly the URL the broker pins for the credential.
458    #[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    /// Routing data is credential-free by construction: `ProviderConfig` has
487    /// no field that could hold a key, and local resolution keeps only the
488    /// non-secret endpoint URL.
489    #[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        // K3.1 Pro is the default and leads the picker (the K2 series closes
540        // to new Moonshot users from 2026-10-31).
541        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    /// Every Kimi catalog row has a documented reasoning capability, so a
557    /// model the picker offers never falls into the unknown-id (adaptive
558    /// only) path by omission.
559    #[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}