Skip to main content

oxicode_ai/providers/
register_builtins.rs

1//! Built-in provider registration.
2//!
3//! Defines all built-in providers with comprehensive metadata: names, aliases,
4//! API key environment variables, base URLs, auth methods, and provider-specific
5//! headers. The provider factory is data-driven from this metadata rather than
6//! using hardcoded match arms.
7
8use crate::Api;
9use crate::catalog::BuiltinProviderEntry;
10
11// ---------------------------------------------------------------------------
12// Auth method
13// ---------------------------------------------------------------------------
14
15/// How a provider passes its API key in HTTP headers.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AuthMethod {
18    /// `Authorization: Bearer <key>` — most OpenAI-compatible providers.
19    Bearer,
20    /// `x-api-key: <key>` — Anthropic and Anthropic-compatible providers.
21    XApiKey,
22    /// `api-key: <key>` — Azure OpenAI.
23    ApiKey,
24    /// No API key header (uses other auth like OAuth, SigV4).
25    None,
26}
27
28// ---------------------------------------------------------------------------
29// Provider metadata
30// ---------------------------------------------------------------------------
31
32/// Metadata for a built-in provider.
33#[derive(Debug, Clone)]
34pub struct BuiltinProvider {
35    /// Primary provider name (e.g. "openai")
36    pub name: &'static str,
37    /// Display name (e.g. "OpenAI")
38    pub display_name: &'static str,
39    /// Alternative names that resolve to this provider
40    pub aliases: &'static [&'static str],
41    /// API type used by this provider
42    pub api: Api,
43    /// Environment variable(s) that may hold the API key (in priority order)
44    pub env_key: &'static str,
45    /// Additional environment variables to check
46    pub extra_env_keys: &'static [&'static str],
47    /// Default base URL for the API
48    pub base_url: &'static str,
49    /// Whether this provider is enabled by default
50    pub default_enabled: bool,
51    /// How to pass the API key
52    pub auth_method: AuthMethod,
53    /// Extra HTTP headers required by this provider
54    pub extra_headers: &'static [(&'static str, &'static str)],
55    /// Provider category for UI grouping ("primary", "chinese", "enterprise", "open",
56    /// "coding", "cloud", "specialized")
57    pub category: &'static str,
58    /// Short human-readable description shown in the provider selection UI
59    pub description: &'static str,
60}
61
62// ---------------------------------------------------------------------------
63// API enum parsing
64// ---------------------------------------------------------------------------
65
66/// Parse the API string from a TOML entry into the `Api` enum.
67///
68/// Falls back to `Api::OpenAiCompletions` for unknown values — this matches
69/// the historical behavior where most "AI gateway" providers expose an
70/// OpenAI-compatible endpoint.
71fn parse_api(s: &str) -> Api {
72    // Delegate to the single authoritative parser on `Api` (covers all 14
73    // KnownApi dialects). Unknown strings default to OpenAI-compatible — the
74    // catalog only emits known dialects, so this is a defensive fallback for
75    // ad-hoc gateways/aggregators.
76    Api::from_kebab_str(s).unwrap_or(Api::OpenAiCompletions)
77}
78
79impl From<&BuiltinProviderEntry> for BuiltinProvider {
80    fn from(entry: &BuiltinProviderEntry) -> Self {
81        // SAFETY: These String→&'static str conversions are safe because the
82        // resulting `BuiltinProvider` instances are stored in a `OnceLock<Vec<_>>`
83        // that lives for the lifetime of the program. We leak the strings to
84        // obtain `'static` references; this is a one-time cost at startup and
85        // bounded by the models.dev snapshot (~70+ providers).
86        let name: &'static str = Box::leak(entry.id.clone().into_boxed_str());
87        let display_name: &'static str = Box::leak(entry.display_name.clone().into_boxed_str());
88        let aliases: &'static [&'static str] = Box::leak(
89            entry
90                .aliases
91                .iter()
92                .map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str)
93                .collect::<Vec<_>>()
94                .into_boxed_slice(),
95        );
96        let env_key: &'static str = Box::leak(entry.env_key.clone().into_boxed_str());
97        let extra_env_keys: &'static [&'static str] = Box::leak(
98            entry
99                .extra_env_keys
100                .iter()
101                .map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str)
102                .collect::<Vec<_>>()
103                .into_boxed_slice(),
104        );
105        let base_url: &'static str = Box::leak(entry.base_url.clone().into_boxed_str());
106        let category: &'static str = Box::leak(entry.category.clone().into_boxed_str());
107        let description: &'static str = Box::leak(entry.description.clone().into_boxed_str());
108        let extra_headers: &'static [(&'static str, &'static str)] = Box::leak(
109            entry
110                .extra_headers
111                .iter()
112                .map(|(k, v)| {
113                    (
114                        Box::leak(k.clone().into_boxed_str()) as &'static str,
115                        Box::leak(v.clone().into_boxed_str()) as &'static str,
116                    )
117                })
118                .collect::<Vec<_>>()
119                .into_boxed_slice(),
120        );
121
122        BuiltinProvider {
123            name,
124            display_name,
125            aliases,
126            api: parse_api(&entry.api),
127            env_key,
128            extra_env_keys,
129            base_url,
130            default_enabled: entry.default_enabled,
131            auth_method: match entry.auth_method {
132                crate::catalog::AuthMethod::Bearer => AuthMethod::Bearer,
133                crate::catalog::AuthMethod::XApiKey => AuthMethod::XApiKey,
134                crate::catalog::AuthMethod::ApiKey => AuthMethod::ApiKey,
135                crate::catalog::AuthMethod::None => AuthMethod::None,
136            },
137            extra_headers,
138            category,
139            description,
140        }
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Built-in provider definitions (deprecated, now in data/catalog/providers.toml)
146// ---------------------------------------------------------------------------
147
148// ---------------------------------------------------------------------------
149// API-to-provider mappings
150// ---------------------------------------------------------------------------
151
152/// Mapping from API identifier to the primary provider name.
153static API_TO_PROVIDER: &[(&str, Api)] = &[
154    ("anthropic-messages", Api::AnthropicMessages),
155    ("openai-completions", Api::OpenAiCompletions),
156    ("openai-responses", Api::OpenAiResponses),
157    ("openai-codex-responses", Api::OpenAiCodexResponses),
158    ("azure-openai-responses", Api::AzureOpenAiResponses),
159    ("google-generative-ai", Api::GoogleGenerativeAi),
160    ("google-gemini-cli", Api::GoogleGeminiCli),
161    ("google-vertex", Api::GoogleVertex),
162    ("bedrock-converse-stream", Api::BedrockConverseStream),
163    ("cursor-agent", Api::CursorAgent),
164    ("cursor", Api::CursorAgent),
165    ("devin-agent", Api::DevinAgent),
166    ("devin", Api::DevinAgent),
167    ("gitlab-duo", Api::GitLabDuo),
168    ("gitlab-duo-agent", Api::GitLabDuoAgent),
169];
170
171// ---------------------------------------------------------------------------
172// Registry access functions
173// ---------------------------------------------------------------------------
174
175/// Get all built-in providers, built lazily from the catalog TOML.
176///
177/// This replaces the historical `static BUILTIN_PROVIDERS` array. The first
178/// call parses `data/catalog/providers.toml` and converts each entry to a
179/// `BuiltinProvider`; subsequent calls return the cached `&'static` slice.
180///
181/// **Layer 2 (user overrides) is applied here**: before conversion, the
182/// `OverrideFile` from `crate::catalog::load_overrides()` (if any) is
183/// merged with the built-in providers. Override entries with the same id
184/// replace built-in ones; new ids are appended.
185pub fn get_builtin_providers() -> &'static [BuiltinProvider] {
186    static CACHE: std::sync::OnceLock<Vec<BuiltinProvider>> = std::sync::OnceLock::new();
187    CACHE
188        .get_or_init(|| {
189            // Materialize providers from the embedded models.dev snapshot.
190            // This replaces the old TOML-based path (CatalogRoot::providers.toml)
191            // gives us all ~145 providers from models.dev.
192            let mut builtins = crate::catalog::materialize::materialize_providers();
193            if let Some(overrides) = crate::catalog::load_overrides() {
194                crate::catalog::apply_provider_overrides(&mut builtins, &overrides.provider);
195            }
196            builtins.iter().map(BuiltinProvider::from).collect()
197        })
198        .as_slice()
199}
200
201/// Look up a built-in provider by name or alias.
202pub fn get_builtin_provider(name: &str) -> Option<&'static BuiltinProvider> {
203    get_builtin_providers()
204        .iter()
205        .find(|p| p.name == name || p.aliases.contains(&name))
206}
207
208/// Get the environment variable name for a provider.
209pub fn get_provider_env_key(name: &str) -> Option<&'static str> {
210    get_builtin_provider(name).map(|p| p.env_key)
211}
212
213/// Get all environment variable names for a provider (primary + extras).
214pub fn get_provider_env_keys(name: &str) -> Vec<&'static str> {
215    if let Some(p) = get_builtin_provider(name) {
216        let mut keys = vec![p.env_key];
217        keys.extend_from_slice(p.extra_env_keys);
218        keys
219    } else {
220        vec![]
221    }
222}
223
224/// Get the API type for a provider by name or alias.
225pub fn get_provider_api(name: &str) -> Option<Api> {
226    get_builtin_provider(name).map(|p| p.api)
227}
228
229/// Get the default base URL for a provider.
230pub fn get_provider_base_url(name: &str) -> Option<&'static str> {
231    get_builtin_provider(name).map(|p| p.base_url)
232}
233
234/// Get all API-to-provider mappings.
235pub fn get_api_mappings() -> &'static [(&'static str, Api)] {
236    API_TO_PROVIDER
237}
238
239/// Get all provider names (primary names only).
240pub fn get_all_provider_names() -> Vec<&'static str> {
241    get_builtin_providers().iter().map(|p| p.name).collect()
242}
243
244/// Get all provider names including aliases.
245pub fn get_all_provider_aliases() -> Vec<&'static str> {
246    let mut names: Vec<&'static str> = get_builtin_providers()
247        .iter()
248        .flat_map(|p| std::iter::once(p.name).chain(p.aliases.iter().copied()))
249        .collect();
250    names.sort();
251    names.dedup();
252    names
253}
254
255/// Resolve a provider name/alias to its canonical name.
256pub fn resolve_provider_name(name: &str) -> Option<&'static str> {
257    get_builtin_provider(name).map(|p| p.name)
258}
259
260/// Check if a provider name or alias is a known built-in.
261pub fn is_builtin_provider(name: &str) -> bool {
262    get_builtin_provider(name).is_some()
263}
264
265// ---------------------------------------------------------------------------
266// Data-driven provider factory
267// ---------------------------------------------------------------------------
268
269/// Create a built-in provider by name.
270///
271/// This is the **single source of truth** for provider instantiation. It reads
272/// the `BuiltinProvider` metadata and creates the appropriate provider struct
273/// with the correct base URL, API key, and extra headers.
274///
275/// The returned transport carries **no identity** — provider identity (the
276/// canonical catalog id) lives in the registry key / `Model.provider` field,
277/// not on the transport. This completes the omp three-way split (transport /
278/// identity / metadata) — see
279/// `docs/superpowers/specs/2026-07-27-omp-realignment-design.md` (P0.3).
280///
281/// Returns `None` if the name is not a known built-in provider.
282pub fn create_builtin_provider(name: &str) -> Option<Box<dyn super::Provider>> {
283    let builtin = get_builtin_provider(name)?;
284    build_builtin_transport(builtin)
285}
286
287/// Build the transport for a built-in provider.
288///
289/// Transports carry no identity; the canonical catalog id is the registry key
290/// (see [`create_builtin_provider`]). Part of the omp three-way split — see
291/// `docs/superpowers/specs/2026-07-27-omp-realignment-design.md` (P0.3).
292fn build_builtin_transport(builtin: &'static BuiltinProvider) -> Option<Box<dyn super::Provider>> {
293    match builtin.api {
294        // ── Anthropic Messages API ──────────────────────────────────────
295        Api::AnthropicMessages => {
296            let extra_headers: Vec<(String, String)> = builtin
297                .extra_headers
298                .iter()
299                .map(|(k, v)| (k.to_string(), v.to_string()))
300                .collect();
301
302            // If the provider has its own base URL (MiniMax, etc.), use it
303            if !builtin.base_url.is_empty() && builtin.name != "anthropic" {
304                Some(Box::new(super::anthropic::AnthropicProvider::with_config(
305                    builtin.base_url,
306                    None,
307                    extra_headers,
308                )))
309            } else {
310                Some(Box::new(super::anthropic::AnthropicProvider::new()))
311            }
312        }
313
314        // ── Google APIs ─────────────────────────────────────────────────
315        Api::GoogleGenerativeAi => Some(Box::new(super::google::GoogleProvider::new())),
316        Api::GoogleGeminiCli => Some(Box::new(super::gemini_cli::GeminiCliProvider::new())),
317        Api::GoogleVertex => Some(Box::new(super::vertex::VertexProvider::new())),
318
319        // ── Azure ───────────────────────────────────────────────────────
320        Api::AzureOpenAiResponses => Some(Box::new(super::azure::AzureProvider::new())),
321
322        // ── Bedrock ─────────────────────────────────────────────────────
323        Api::BedrockConverseStream => Some(Box::new(super::bedrock::BedrockProvider::new())),
324
325        // ── OpenAI Responses API ────────────────────────────────────────
326        Api::OpenAiResponses => Some(Box::new(
327            super::openai_responses::OpenAiResponsesProvider::new(),
328        )),
329        // ── OpenAI Codex Responses API ─────────────────────────────────
330        // Same wire format as `openai-responses`; reuse
331        // `OpenAiResponsesProvider` (upstream `scripts/catalog/port-openclaw.py:43`
332        // maps `openai-codex → openai-responses`). `openai_responses_shared.rs`
333        // doc comment makes the same statement.
334        Api::OpenAiCodexResponses => Some(Box::new(
335            super::openai_responses::OpenAiResponsesProvider::new(),
336        )),
337
338        // ── OpenAI Chat Completions API ─────────────────────────────────
339        // All OpenAI-compatible providers use OpenAiProvider with custom base
340        // URLs and optional extra headers from their BuiltinProvider metadata.
341        Api::OpenAiCompletions => {
342            let extra_headers: Vec<(String, String)> = builtin
343                .extra_headers
344                .iter()
345                .map(|(k, v)| (k.to_string(), v.to_string()))
346                .collect();
347
348            if builtin.base_url.is_empty() {
349                // OpenAI itself (no custom base URL)
350                if extra_headers.is_empty() {
351                    Some(Box::new(super::openai::OpenAiProvider::new()))
352                } else {
353                    Some(Box::new(super::openai::OpenAiProvider::with_config(
354                        "https://api.openai.com/v1",
355                        None,
356                        extra_headers,
357                    )))
358                }
359            } else if extra_headers.is_empty() {
360                Some(Box::new(super::openai::OpenAiProvider::with_base_url(
361                    builtin.base_url,
362                )))
363            } else {
364                Some(Box::new(super::openai::OpenAiProvider::with_config(
365                    builtin.base_url,
366                    None,
367                    extra_headers,
368                )))
369            }
370        }
371        // ── Ollama chat API (local NDJSON server) ──────────────────────
372        Api::OllamaChat => {
373            let base = if builtin.base_url.is_empty() {
374                "http://localhost:11434"
375            } else {
376                builtin.base_url
377            };
378            Some(Box::new(super::ollama::OllamaProvider::with_base_url(base)))
379        }
380        // ── Remote-AGENT providers (WebSocket/protobuf) ─────────────────
381        // These providers require additional infra (HTTP/2, protobuf,
382        // WebSocket) that oxicode-ai does not currently bundle.
383        #[cfg(feature = "protobuf")]
384        Api::CursorAgent => Some(Box::new(super::cursor::CursorProvider::new())),
385        #[cfg(feature = "protobuf")]
386        Api::DevinAgent => Some(Box::new(super::devin::DevinProvider::new())),
387        Api::GitLabDuo => Some(Box::new(super::gitlab_duo::GitLabDuoProvider::new())),
388        Api::GitLabDuoAgent => Some(Box::new(
389            super::gitlab_duo_agent::GitLabDuoAgentProvider::new(),
390        )),
391        _ => None,
392    }
393}
394
395/// Create a built-in provider with optional credential and base URL overrides.
396///
397/// This is like [`create_builtin_provider`] but allows injecting an API key
398/// and/or base URL at construction time instead of reading from environment
399/// variables. When `api_key` is `Some`, it takes precedence over the
400/// environment. When `base_url` is `Some`, the provider's default endpoint
401/// is overridden.
402///
403/// Returns `None` if the name is not a known built-in provider.
404pub fn create_builtin_provider_with_options(
405    name: &str,
406    api_key: Option<&str>,
407    base_url: Option<&str>,
408) -> Option<Box<dyn super::Provider>> {
409    let builtin = get_builtin_provider(name)?;
410    build_builtin_transport_with_options(builtin, api_key, base_url)
411}
412
413/// Build the transport for a built-in provider with credential/base-URL
414/// overrides. Transports carry no identity (the canonical catalog id is the
415/// registry key). Falls back to [`build_builtin_transport`] when no override
416/// applies.
417fn build_builtin_transport_with_options(
418    builtin: &'static BuiltinProvider,
419    api_key: Option<&str>,
420    base_url: Option<&str>,
421) -> Option<Box<dyn super::Provider>> {
422    // Resolve API key: explicit override > environment variables
423    let resolved_key = api_key.map(String::from).or_else(|| {
424        std::env::var(builtin.env_key).ok().or_else(|| {
425            builtin
426                .extra_env_keys
427                .iter()
428                .find_map(|k| std::env::var(k).ok())
429        })
430    });
431
432    // Resolve base URL: explicit override > built-in default
433    let resolved_base_url = base_url.map(String::from).or_else(|| {
434        if builtin.base_url.is_empty() {
435            None
436        } else {
437            Some(builtin.base_url.to_string())
438        }
439    });
440
441    let extra_headers: Vec<(String, String)> = builtin
442        .extra_headers
443        .iter()
444        .map(|(k, v)| (k.to_string(), v.to_string()))
445        .collect();
446
447    match builtin.api {
448        // ── Anthropic Messages API ──────────────────────────────────────
449        Api::AnthropicMessages => {
450            if let Some(key) = resolved_key {
451                Some(Box::new(super::anthropic::AnthropicProvider::with_config(
452                    resolved_base_url
453                        .as_deref()
454                        .unwrap_or("https://api.anthropic.com"),
455                    Some(key),
456                    extra_headers,
457                )))
458            } else if resolved_base_url.is_some() {
459                Some(Box::new(super::anthropic::AnthropicProvider::with_config(
460                    resolved_base_url
461                        .as_deref()
462                        .unwrap_or("https://api.anthropic.com"),
463                    None,
464                    extra_headers,
465                )))
466            } else {
467                // No key and no base URL — fall back to default construction
468                // (reads from env at stream time)
469                build_builtin_transport(builtin)
470            }
471        }
472
473        // ── Google APIs ─────────────────────────────────────────────────
474        Api::GoogleGenerativeAi => build_builtin_transport(builtin),
475        Api::GoogleGeminiCli => build_builtin_transport(builtin),
476        Api::GoogleVertex => build_builtin_transport(builtin),
477        // ── Azure ───────────────────────────────────────────────────────
478        Api::AzureOpenAiResponses => build_builtin_transport(builtin),
479
480        // ── Bedrock ─────────────────────────────────────────────────────
481        Api::BedrockConverseStream => build_builtin_transport(builtin),
482
483        Api::OpenAiResponses => build_builtin_transport(builtin),
484        Api::OpenAiCodexResponses => build_builtin_transport(builtin),
485
486        // ── OpenAI Chat Completions API ─────────────────────────────────
487        Api::OpenAiCompletions => {
488            let url = resolved_base_url
489                .as_deref()
490                .unwrap_or(if builtin.base_url.is_empty() {
491                    "https://api.openai.com/v1"
492                } else {
493                    builtin.base_url
494                });
495
496            if let Some(key) = resolved_key {
497                if extra_headers.is_empty() {
498                    Some(Box::new(
499                        super::openai::OpenAiProvider::with_base_url_and_key(url, Some(key)),
500                    ))
501                } else {
502                    Some(Box::new(super::openai::OpenAiProvider::with_config(
503                        url,
504                        Some(key),
505                        extra_headers,
506                    )))
507                }
508            } else if url != builtin.base_url || !extra_headers.is_empty() {
509                // Base URL override or extra headers without explicit key
510                Some(Box::new(super::openai::OpenAiProvider::with_config(
511                    url,
512                    None,
513                    extra_headers,
514                )))
515            } else {
516                build_builtin_transport(builtin)
517            }
518        }
519        // ── Ollama chat API ────────────────────────────────────────────
520        Api::OllamaChat => {
521            let url = resolved_base_url
522                .as_deref()
523                .unwrap_or("http://localhost:11434");
524            Some(Box::new(super::ollama::OllamaProvider::with_config(
525                url,
526                resolved_key,
527            )))
528        }
529        // ── Remote-AGENT providers (WebSocket/protobuf) ─────────────────
530        Api::CursorAgent => build_builtin_transport(builtin),
531        Api::DevinAgent => build_builtin_transport(builtin),
532        Api::GitLabDuo => build_builtin_transport(builtin),
533        Api::GitLabDuoAgent => build_builtin_transport(builtin),
534        _ => None,
535    }
536}
537
538// ---------------------------------------------------------------------------
539// Tests
540// ---------------------------------------------------------------------------
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_create_builtin_provider_anthropic() {
548        // Identity is the registry key, not a method on the transport.
549        assert!(create_builtin_provider("anthropic").is_some());
550    }
551
552    #[test]
553    fn test_create_builtin_provider_openai() {
554        assert!(create_builtin_provider("openai").is_some());
555    }
556
557    #[test]
558    fn test_create_builtin_provider_by_alias() {
559        // "amazon-bedrock" is the catalog id; the transport is BedrockProvider.
560        assert!(create_builtin_provider("amazon-bedrock").is_some());
561    }
562
563    #[test]
564    fn test_create_builtin_provider_unknown() {
565        assert!(create_builtin_provider("unknown").is_none());
566    }
567
568    #[test]
569    fn layer2_override_adds_provider() {
570        // Set OXICODE_CATALOG_OVERRIDE to a known override file, then verify
571        // the provider shows up. We use a tempfile in the test target dir.
572        use std::io::Write;
573
574        let dir = std::env::temp_dir().join("oxicode-test-layer2");
575        std::fs::create_dir_all(&dir).unwrap();
576        let path = dir.join("overrides.toml");
577        let mut f = std::fs::File::create(&path).unwrap();
578        writeln!(
579            f,
580            r#"
581[[provider]]
582id = "test-injected-{}"
583display_name = "Test Injected"
584api = "openai-completions"
585env_key = "TEST_INJECTED_KEY"
586auth_method = "bearer"
587category = "primary"
588description = "Test provider from override"
589"#,
590            std::process::id()
591        )
592        .unwrap();
593        drop(f);
594
595        // SAFETY: only this test mutates this env var, and only briefly.
596        // The test is #[test] which runs on a single thread within a test binary.
597        unsafe {
598            std::env::set_var("OXICODE_CATALOG_OVERRIDE", &path);
599        }
600        // Invalidate the cache so the override is picked up.
601        // (The OnceLock has no reset API; this test only checks the load
602        //  machinery via find_override_files, not the full integration.)
603        let files = crate::catalog::find_override_files();
604        unsafe {
605            std::env::remove_var("OXICODE_CATALOG_OVERRIDE");
606        }
607        assert!(
608            !files.is_empty(),
609            "OXICODE_CATALOG_OVERRIDE should be detected"
610        );
611        let (found_path, _content) = &files[0];
612        assert_eq!(found_path, &path);
613    }
614
615    #[test]
616    fn test_create_builtin_provider_deepseek() {
617        // P0.3 regression: "deepseek" must resolve (identity = registry key,
618        // transport is OpenAI-compatible). Previously the transport reported
619        // name() == "openai"; now identity lives in the catalog key.
620        assert!(create_builtin_provider("deepseek").is_some());
621    }
622
623    #[test]
624    fn test_create_builtin_provider_minimax() {
625        assert!(create_builtin_provider("minimax").is_some());
626    }
627
628    #[test]
629    fn test_create_builtin_provider_minimax_cn() {
630        assert!(create_builtin_provider("minimax-cn").is_some());
631    }
632
633    #[test]
634    fn test_create_builtin_provider_togetherai() {
635        assert!(create_builtin_provider("togetherai").is_some());
636    }
637
638    #[test]
639    fn test_create_builtin_provider_openrouter() {
640        assert!(create_builtin_provider("openrouter").is_some());
641    }
642
643    #[test]
644    fn test_create_builtin_provider_cerebras() {
645        assert!(create_builtin_provider("cerebras").is_some());
646    }
647
648    #[test]
649    fn test_get_builtin_provider_openai() {
650        let p = get_builtin_provider("openai").unwrap();
651        assert_eq!(p.name, "openai");
652        assert_eq!(p.display_name, "OpenAI");
653        assert_eq!(p.api, Api::OpenAiCompletions);
654        assert_eq!(p.auth_method, AuthMethod::Bearer);
655    }
656
657    #[test]
658    fn test_get_builtin_provider_anthropic() {
659        let p = get_builtin_provider("anthropic").unwrap();
660        assert_eq!(p.name, "anthropic");
661        assert_eq!(p.auth_method, AuthMethod::XApiKey);
662    }
663
664    #[test]
665    fn test_get_builtin_provider_azure() {
666        let p = get_builtin_provider("azure").unwrap();
667        assert_eq!(p.auth_method, AuthMethod::ApiKey);
668    }
669
670    #[test]
671    fn test_get_builtin_provider_by_alias() {
672        // With models.dev IDs, the canonical id is "amazon-bedrock".
673        let p = get_builtin_provider("amazon-bedrock").unwrap();
674        assert_eq!(p.name, "amazon-bedrock");
675    }
676
677    #[test]
678    fn test_get_builtin_provider_unknown() {
679        assert!(get_builtin_provider("unknown-provider").is_none());
680    }
681
682    #[test]
683    fn test_get_provider_env_key() {
684        assert_eq!(get_provider_env_key("openai"), Some("OPENAI_API_KEY"));
685        assert_eq!(get_provider_env_key("anthropic"), Some("ANTHROPIC_API_KEY"));
686    }
687
688    #[test]
689    fn test_get_provider_env_keys_with_extras() {
690        let keys = get_provider_env_keys("google");
691        assert!(keys.contains(&"GOOGLE_API_KEY"));
692        assert!(keys.contains(&"GEMINI_API_KEY"));
693    }
694
695    #[test]
696    fn test_get_provider_api() {
697        assert_eq!(get_provider_api("anthropic"), Some(Api::AnthropicMessages));
698        assert_eq!(get_provider_api("google-vertex"), Some(Api::GoogleVertex));
699        assert_eq!(get_provider_api("google"), Some(Api::GoogleGenerativeAi));
700    }
701
702    #[test]
703    fn test_resolve_provider_name() {
704        // With models.dev IDs, canonical names are the models.dev IDs.
705        assert_eq!(
706            resolve_provider_name("google-vertex"),
707            Some("google-vertex")
708        );
709        assert_eq!(
710            resolve_provider_name("amazon-bedrock"),
711            Some("amazon-bedrock")
712        );
713        assert_eq!(resolve_provider_name("openai"), Some("openai"));
714    }
715
716    #[test]
717    fn test_is_builtin_provider() {
718        assert!(is_builtin_provider("openai"));
719        assert!(is_builtin_provider("deepseek"));
720        assert!(is_builtin_provider("togetherai"));
721        assert!(!is_builtin_provider("fake-provider"));
722    }
723
724    #[test]
725    fn test_all_providers_have_env_key() {
726        for p in get_builtin_providers() {
727            assert!(!p.env_key.is_empty(), "Provider {} has no env key", p.name);
728        }
729    }
730
731    #[test]
732    fn test_all_providers_have_auth_method() {
733        for p in get_builtin_providers() {
734            // Just verify they all have a valid auth method
735            match p.auth_method {
736                AuthMethod::Bearer
737                | AuthMethod::XApiKey
738                | AuthMethod::ApiKey
739                | AuthMethod::None => {}
740            }
741        }
742    }
743
744    #[test]
745    fn test_get_all_provider_names() {
746        // Provider names are now models.dev IDs.
747        let names = get_all_provider_names();
748        assert!(names.contains(&"openai"));
749        assert!(names.contains(&"anthropic"));
750        assert!(names.contains(&"amazon-bedrock"));
751        assert!(names.contains(&"togetherai"));
752        // models.dev snapshot has 145+ providers
753    }
754
755    // ── Tests for materialized providers (models.dev as source of truth) ─
756
757    #[test]
758    fn test_openclaw_ported_providers_present() {
759        // Verify key models.dev providers are present after materialize.
760        // Local-only providers (ollama/lmstudio/vllm/sglang) are not in
761        // models.dev — they're handled by the runtime discovery layer.
762        let names = get_all_provider_names();
763        for p in [
764            "chutes",
765            "venice",
766            "moonshotai",
767            "novita-ai",
768            "stepfun-ai",
769            "alibaba",
770            "google-vertex-anthropic",
771            "synthetic",
772            "amazon-bedrock",
773            "opencode",
774        ] {
775            assert!(names.contains(&p), "Missing materialized provider: {p}");
776        }
777    }
778
779    #[test]
780    fn test_openclaw_provider_aliases() {
781        // With models.dev IDs, there's no alias layer — the ID is the ID.
782        // Legacy aliases (gmi-cloud, dashscope, etc.) are no longer valid.
783        assert_eq!(resolve_provider_name("chutes"), Some("chutes"));
784        assert_eq!(resolve_provider_name("anthropic"), Some("anthropic"));
785        assert_eq!(resolve_provider_name("openai"), Some("openai"));
786        // Unknown IDs return None (no alias fallback)
787        assert_eq!(resolve_provider_name("nonexistent-provider"), None);
788    }
789
790    #[test]
791    fn test_openclaw_provider_base_urls() {
792        // URLs come from models.dev `api` field. Native SDK providers
793        // (venice, anthropic) have `api: null` → empty base_url.
794        assert_eq!(
795            get_provider_base_url("chutes"),
796            Some("https://llm.chutes.ai/v1")
797        );
798        assert_eq!(get_provider_base_url("venice"), Some(""));
799        assert_eq!(
800            get_provider_base_url("synthetic"),
801            Some("https://api.synthetic.new/openai/v1")
802        );
803        assert_eq!(
804            get_provider_base_url("openrouter"),
805            Some("https://openrouter.ai/api/v1")
806        );
807    }
808
809    #[test]
810    fn test_openclaw_local_providers_use_bearer() {
811        // Local-only providers (ollama/lmstudio/vllm/sglang) are NOT in
812        // models.dev — they're handled by the LOCAL discovery layer
813        // (crate::catalog::runtime) which uses Bearer auth by default.
814        // This test is a placeholder for future LOCAL-layer testing.
815        // Verify that at least some providers use Bearer auth (the
816        // OpenAI-compatible default).
817        let names = get_all_provider_names();
818        let bearer_count = names
819            .iter()
820            .filter(|n| {
821                get_builtin_provider(n)
822                    .map(|p| p.auth_method == AuthMethod::Bearer)
823                    .unwrap_or(false)
824            })
825            .count();
826        assert!(
827            bearer_count > 50,
828            "expected >50 Bearer providers, got {bearer_count}"
829        );
830    }
831
832    #[test]
833    fn test_openclaw_anthropic_compat_providers() {
834        // Providers using Anthropic protocol (identified by @ai-sdk/anthropic
835        // or compatible npm package).
836        // google-vertex-anthropic uses @ai-sdk/google-vertex/anthropic.
837        let bp = get_builtin_provider("google-vertex-anthropic").unwrap();
838        assert_eq!(
839            bp.api,
840            Api::GoogleVertex,
841            "google-vertex-anthropic maps to GoogleVertex"
842        );
843        // minimax uses @ai-sdk/anthropic
844        let bp = get_builtin_provider("minimax").unwrap();
845        assert_eq!(
846            bp.api,
847            Api::AnthropicMessages,
848            "minimax uses AnthropicMessages"
849        );
850    }
851
852    #[test]
853    fn test_create_openclaw_providers() {
854        // Smoke test that materialized providers can be instantiated.
855        // These are models.dev provider IDs (not legacy oxicode IDs).
856        for p in [
857            "chutes",
858            "venice",
859            "moonshotai",
860            "novita-ai",
861            "gmicloud",
862            "deepinfra",
863            "fireworks-ai",
864            "togetherai",
865            "alibaba",
866            "amazon-bedrock",
867            "opencode",
868            "minimax",
869        ] {
870            let bp = create_builtin_provider(p);
871            assert!(bp.is_some(), "create_builtin_provider({p}) failed");
872        }
873    }
874
875    #[test]
876    fn test_get_all_provider_aliases() {
877        // With models.dev as the source of truth, provider IDs are the
878        // canonical models.dev IDs (e.g. "amazon-bedrock", "togetherai").
879        // Legacy oxicode aliases ("bedrock", "together") are no longer present.
880        let aliases = get_all_provider_aliases();
881        assert!(aliases.contains(&"amazon-bedrock"));
882        assert!(aliases.contains(&"togetherai"));
883        assert!(aliases.contains(&"anthropic"));
884        assert!(aliases.contains(&"openai"));
885    }
886
887    #[test]
888    fn test_get_provider_base_url() {
889        // With models.dev as the source of truth, base URLs come from
890        // the `api` field. Native SDK providers (openai, anthropic, google)
891        // have `api: null`, so their base_url is empty (computed at runtime).
892        assert_eq!(get_provider_base_url("openai"), Some(""));
893        assert_eq!(get_provider_base_url("anthropic"), Some(""));
894        // OpenRouter has an explicit api URL
895        assert_eq!(
896            get_provider_base_url("openrouter"),
897            Some("https://openrouter.ai/api/v1")
898        );
899    }
900
901    #[test]
902    fn test_minimax_base_url() {
903        let p = get_builtin_provider("minimax").unwrap();
904        // models.dev: api = "https://api.minimax.io/anthropic/v1"
905        assert_eq!(p.base_url, "https://api.minimax.io/anthropic/v1");
906        assert_eq!(p.api, Api::AnthropicMessages);
907    }
908
909    #[test]
910    fn test_openrouter_extra_headers() {
911        let p = get_builtin_provider("openrouter").unwrap();
912        assert_eq!(
913            p.extra_headers,
914            &[
915                ("HTTP-Referer", "https://oxicode.dev/"),
916                ("X-Title", "oxicode")
917            ]
918        );
919    }
920
921    #[test]
922    fn test_cerebras_extra_headers() {
923        let p = get_builtin_provider("cerebras").unwrap();
924        assert_eq!(
925            p.extra_headers,
926            &[("X-Cerebras-3rd-Party-Integration", "opencode")]
927        );
928    }
929
930    #[test]
931    fn test_create_builtin_provider_with_options_openai() {
932        // With explicit API key and base URL
933        let p = create_builtin_provider_with_options(
934            "openai",
935            Some("sk-test-key"),
936            Some("https://my-proxy.example.com/v1"),
937        );
938        assert!(p.is_some());
939    }
940
941    #[test]
942    fn test_create_builtin_provider_with_options_anthropic() {
943        let p = create_builtin_provider_with_options("anthropic", Some("sk-ant-test-key"), None);
944        assert!(p.is_some());
945    }
946
947    #[test]
948    fn test_create_builtin_provider_with_options_no_override() {
949        // No key or URL — should fall back to default creation
950        let p = create_builtin_provider_with_options("deepseek", None, None);
951        assert!(p.is_some());
952    }
953
954    #[test]
955    fn test_create_builtin_provider_with_options_unknown() {
956        let p = create_builtin_provider_with_options("nonexistent_provider", None, None);
957        assert!(p.is_none());
958    }
959
960    // ────────────────────────────────────────────────────────────────────
961    // Codex Responses + Gemini CLI dispatch (P0.5 — provider/API realignment)
962    //
963    // Proves the previously-silent `_ => None` arms at the bottom of
964    // `build_builtin_transport` and `build_builtin_transport_with_options`
965    // are now reached via explicit arms, and that:
966    // - `Api::OpenAiCodexResponses` dispatches to `OpenAiResponsesProvider`
967    //   (same Responses protocol as plain `openai-responses`).
968    // - `Api::GoogleGeminiCli` dispatches to `GeminiCliProvider`, which
969    //   surfaces `ProviderError::NotImplemented` rather than faking success.
970    // - The Codex Responses SSE parser produces the expected `ProviderEvent`s.
971    // ────────────────────────────────────────────────────────────────────
972
973    /// Build a `BuiltinProvider` from a `BuiltinProviderEntry` whose `api`
974    /// field is whatever the caller wants, then dispatch it through
975    /// `build_builtin_transport`. The entry is leaked (one-time test cost)
976    /// so the `&'static BuiltinProvider` signature is satisfied.
977    fn dispatch_with_api(api: &str) -> Option<Box<dyn crate::Provider>> {
978        let entry = crate::catalog::BuiltinProviderEntry {
979            id: "test-dispatch-target".to_string(),
980            display_name: "Test Dispatch Target".to_string(),
981            aliases: vec![],
982            api: api.to_string(),
983            env_key: "TEST_DISPATCH_KEY".to_string(),
984            extra_env_keys: vec![],
985            base_url: "http://127.0.0.1:1".to_string(),
986            auth_method: crate::catalog::AuthMethod::Bearer,
987            extra_headers: vec![],
988            category: "test".to_string(),
989            description: "Synthetic builtin for dispatch-arm coverage".to_string(),
990            default_enabled: true,
991        };
992        let builtin: &'static BuiltinProvider = Box::leak(Box::new(BuiltinProvider::from(&entry)));
993        build_builtin_transport(builtin)
994    }
995
996    /// Build a `BuiltinProvider` and dispatch through
997    /// `build_builtin_transport_with_options`. Used to prove the
998    /// `_with_options` path also has explicit arms.
999    fn dispatch_with_options_api(api: &str) -> Option<Box<dyn crate::Provider>> {
1000        let entry = crate::catalog::BuiltinProviderEntry {
1001            id: "test-dispatch-options-target".to_string(),
1002            display_name: "Test Dispatch Options Target".to_string(),
1003            aliases: vec![],
1004            api: api.to_string(),
1005            env_key: "TEST_DISPATCH_KEY".to_string(),
1006            extra_env_keys: vec![],
1007            base_url: "http://127.0.0.1:1".to_string(),
1008            auth_method: crate::catalog::AuthMethod::Bearer,
1009            extra_headers: vec![],
1010            category: "test".to_string(),
1011            description: "Synthetic builtin for dispatch-arm coverage".to_string(),
1012            default_enabled: true,
1013        };
1014        let builtin: &'static BuiltinProvider = Box::leak(Box::new(BuiltinProvider::from(&entry)));
1015        build_builtin_transport_with_options(builtin, None, None)
1016    }
1017
1018    #[test]
1019    fn dispatch_openai_codex_responses_returns_some() {
1020        let provider = dispatch_with_api("openai-codex-responses")
1021            .expect("Api::OpenAiCodexResponses must dispatch to a transport");
1022        let _ = provider;
1023    }
1024
1025    #[test]
1026    fn dispatch_openai_codex_responses_with_options_returns_some() {
1027        let provider = dispatch_with_options_api("openai-codex-responses")
1028            .expect("Api::OpenAiCodexResponses must dispatch via _with_options path");
1029        let _ = provider;
1030    }
1031
1032    #[test]
1033    fn dispatch_google_gemini_cli_returns_some() {
1034        let provider = dispatch_with_api("google-gemini-cli")
1035            .expect("Api::GoogleGeminiCli must dispatch to a transport");
1036        let _ = provider;
1037    }
1038
1039    #[test]
1040    fn dispatch_google_gemini_cli_with_options_returns_some() {
1041        let provider = dispatch_with_options_api("google-gemini-cli")
1042            .expect("Api::GoogleGeminiCli must dispatch via _with_options path");
1043        let _ = provider;
1044    }
1045
1046    #[test]
1047    fn gemini_cli_transport_emits_not_implemented_error() {
1048        use crate::{Context, Model, ProviderError, StreamOptions};
1049        let provider: Box<dyn crate::Provider> = dispatch_with_api("google-gemini-cli")
1050            .expect("Api::GoogleGeminiCli must dispatch to a transport");
1051
1052        let model = Model::new(
1053            "gemini-cli-test",
1054            "Gemini CLI Test Model",
1055            Api::GoogleGeminiCli,
1056            "google-gemini-cli",
1057            "http://127.0.0.1:1",
1058        );
1059        let context = Context::new();
1060        let options = StreamOptions::default();
1061
1062        let result = futures::executor::block_on(provider.stream(&model, &context, Some(options)));
1063        match result {
1064            Err(ProviderError::NotImplemented(name)) => {
1065                assert!(
1066                    name.to_lowercase().contains("gemini"),
1067                    "NotImplemented error name should mention gemini, got: {name}"
1068                );
1069            }
1070            Ok(_) => panic!(
1071                "Gemini CLI transport must NOT fake success — stream() must return NotImplemented"
1072            ),
1073            Err(other) => panic!("Gemini CLI stream must return NotImplemented, got: {other:?}"),
1074        }
1075    }
1076}