1use crate::Api;
9use crate::catalog::BuiltinProviderEntry;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AuthMethod {
18 Bearer,
20 XApiKey,
22 ApiKey,
24 None,
26}
27
28#[derive(Debug, Clone)]
34pub struct BuiltinProvider {
35 pub name: &'static str,
37 pub display_name: &'static str,
39 pub aliases: &'static [&'static str],
41 pub api: Api,
43 pub env_key: &'static str,
45 pub extra_env_keys: &'static [&'static str],
47 pub base_url: &'static str,
49 pub default_enabled: bool,
51 pub auth_method: AuthMethod,
53 pub extra_headers: &'static [(&'static str, &'static str)],
55 pub category: &'static str,
58 pub description: &'static str,
60}
61
62fn parse_api(s: &str) -> Api {
72 Api::from_kebab_str(s).unwrap_or(Api::OpenAiCompletions)
77}
78
79impl From<&BuiltinProviderEntry> for BuiltinProvider {
80 fn from(entry: &BuiltinProviderEntry) -> Self {
81 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
144static 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
171pub 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 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
201pub 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
208pub fn get_provider_env_key(name: &str) -> Option<&'static str> {
210 get_builtin_provider(name).map(|p| p.env_key)
211}
212
213pub 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
224pub fn get_provider_api(name: &str) -> Option<Api> {
226 get_builtin_provider(name).map(|p| p.api)
227}
228
229pub fn get_provider_base_url(name: &str) -> Option<&'static str> {
231 get_builtin_provider(name).map(|p| p.base_url)
232}
233
234pub fn get_api_mappings() -> &'static [(&'static str, Api)] {
236 API_TO_PROVIDER
237}
238
239pub fn get_all_provider_names() -> Vec<&'static str> {
241 get_builtin_providers().iter().map(|p| p.name).collect()
242}
243
244pub 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
255pub fn resolve_provider_name(name: &str) -> Option<&'static str> {
257 get_builtin_provider(name).map(|p| p.name)
258}
259
260pub fn is_builtin_provider(name: &str) -> bool {
262 get_builtin_provider(name).is_some()
263}
264
265pub 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
287fn build_builtin_transport(builtin: &'static BuiltinProvider) -> Option<Box<dyn super::Provider>> {
293 match builtin.api {
294 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 !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 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 Api::AzureOpenAiResponses => Some(Box::new(super::azure::AzureProvider::new())),
321
322 Api::BedrockConverseStream => Some(Box::new(super::bedrock::BedrockProvider::new())),
324
325 Api::OpenAiResponses => Some(Box::new(
327 super::openai_responses::OpenAiResponsesProvider::new(),
328 )),
329 Api::OpenAiCodexResponses => Some(Box::new(
335 super::openai_responses::OpenAiResponsesProvider::new(),
336 )),
337
338 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 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 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 #[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
395pub 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
413fn 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 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 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 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 build_builtin_transport(builtin)
470 }
471 }
472
473 Api::GoogleGenerativeAi => build_builtin_transport(builtin),
475 Api::GoogleGeminiCli => build_builtin_transport(builtin),
476 Api::GoogleVertex => build_builtin_transport(builtin),
477 Api::AzureOpenAiResponses => build_builtin_transport(builtin),
479
480 Api::BedrockConverseStream => build_builtin_transport(builtin),
482
483 Api::OpenAiResponses => build_builtin_transport(builtin),
484 Api::OpenAiCodexResponses => build_builtin_transport(builtin),
485
486 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 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 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 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#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn test_create_builtin_provider_anthropic() {
548 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 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 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 unsafe {
598 std::env::set_var("OXICODE_CATALOG_OVERRIDE", &path);
599 }
600 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 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 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 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 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 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 }
754
755 #[test]
758 fn test_openclaw_ported_providers_present() {
759 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 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 assert_eq!(resolve_provider_name("nonexistent-provider"), None);
788 }
789
790 #[test]
791 fn test_openclaw_provider_base_urls() {
792 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 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 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 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 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 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 assert_eq!(get_provider_base_url("openai"), Some(""));
893 assert_eq!(get_provider_base_url("anthropic"), Some(""));
894 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 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 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 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 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 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}