1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3use std::sync::LazyLock;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{LiterLlmError, Result};
9
10pub(crate) fn unix_timestamp_secs() -> u64 {
16 SystemTime::now()
17 .duration_since(UNIX_EPOCH)
18 .map(|d| d.as_secs())
19 .unwrap_or(0)
20}
21
22pub(crate) fn validate_sampling_param_range(
35 body: &serde_json::Value,
36 field: &str,
37 provider: &str,
38 min: f64,
39 max: f64,
40) -> Result<()> {
41 let Some(value) = body.get(field).and_then(serde_json::Value::as_f64) else {
42 return Ok(());
43 };
44 if value < min || value > max {
45 return Err(LiterLlmError::BadRequest {
46 message: format!(
47 "{field}={value} is outside {provider}'s supported range [{min}, {max}]; lower \
48 the requested value or omit `{field}` to use the provider default"
49 ),
50 status: 400,
51 });
52 }
53 Ok(())
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
63#[serde(rename_all = "snake_case")]
64pub enum StreamFormat {
65 #[default]
67 Sse,
68 AwsEventStream,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
96pub struct ProviderCapabilities {
97 pub vision: bool,
99 pub reasoning: bool,
101 pub structured_output: bool,
103 pub function_calling: bool,
105 pub audio_in: bool,
107 pub audio_out: bool,
109 pub video_in: bool,
111}
112
113static DEFAULT_CAPABILITIES: ProviderCapabilities = ProviderCapabilities {
115 vision: false,
116 reasoning: false,
117 structured_output: false,
118 function_calling: false,
119 audio_in: false,
120 audio_out: false,
121 video_in: false,
122};
123
124pub fn capabilities(provider_name: &str) -> ProviderCapabilities {
133 let Ok(reg) = REGISTRY.as_ref() else {
134 return DEFAULT_CAPABILITIES;
135 };
136 for entry in ®.providers {
137 if entry.config.name == provider_name {
138 return entry.capabilities;
139 }
140 }
141 DEFAULT_CAPABILITIES
142}
143
144const PROVIDERS_JSON: &str = include_str!("../../schemas/providers.json");
145
146static REGISTRY: LazyLock<std::result::Result<ProviderRegistry, String>> = LazyLock::new(|| {
150 serde_json::from_str::<ProviderRegistryRaw>(PROVIDERS_JSON)
151 .map(ProviderRegistry::from_raw)
152 .map_err(|e| {
153 tracing::error!(error = %e, "embedded schemas/providers.json failed to parse");
157 e.to_string()
158 })
159});
160
161fn registry() -> Result<&'static ProviderRegistry> {
163 REGISTRY.as_ref().map_err(|e| LiterLlmError::ServerError {
164 message: format!("embedded schemas/providers.json is invalid: {e}"),
165 status: 500,
166 })
167}
168
169#[allow(dead_code)]
170#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
171struct RegionalEndpointConfig {
172 region: String,
173 openai_base_url: String,
174 anthropic_base_url: String,
175 docs_root: String,
176}
177
178#[derive(Debug, Deserialize)]
181struct ProviderEntry {
182 #[serde(flatten)]
183 config: ProviderConfig,
184 #[serde(default)]
187 aliases: Vec<String>,
188 #[serde(default)]
192 endpoint_urls: HashMap<String, String>,
193 #[serde(default)]
194 capabilities: ProviderCapabilities,
195 #[allow(dead_code)]
197 #[serde(default)]
198 regional_endpoints: Vec<RegionalEndpointConfig>,
199 #[allow(dead_code)]
205 #[serde(default, rename = "streaming_format")]
206 stream_format: StreamFormat,
207}
208
209#[derive(Debug, Deserialize)]
211struct ProviderRegistryRaw {
212 providers: Vec<ProviderEntry>,
213 #[serde(default, deserialize_with = "deserialize_hashset")]
218 complex_providers: HashSet<String>,
219}
220
221#[derive(Debug)]
227struct ProviderRegistry {
228 providers: Vec<ProviderEntry>,
229 configs: Vec<ProviderConfig>,
230 complex_providers: HashSet<String>,
231}
232
233impl ProviderRegistry {
234 fn from_raw(raw: ProviderRegistryRaw) -> Self {
235 let configs = raw.providers.iter().map(|e| e.config.clone()).collect();
236 Self {
237 configs,
238 providers: raw.providers,
239 complex_providers: raw.complex_providers,
240 }
241 }
242}
243
244fn deserialize_hashset<'de, D>(deserializer: D) -> std::result::Result<HashSet<String>, D::Error>
245where
246 D: serde::Deserializer<'de>,
247{
248 let vec = Vec::<String>::deserialize(deserializer)?;
249 Ok(vec.into_iter().collect())
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ProviderConfig {
258 pub name: String,
260 pub display_name: Option<String>,
262 pub base_url: Option<String>,
264 pub auth: Option<AuthConfig>,
266 pub endpoints: Option<Vec<String>>,
268 pub model_prefixes: Option<Vec<String>>,
270 pub param_mappings: Option<HashMap<String, String>>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
280#[serde(rename_all = "kebab-case")]
281pub enum AuthType {
282 Bearer,
284 #[serde(alias = "header", alias = "x-api-key")]
286 ApiKey,
287 None,
289 #[serde(other)]
291 Unknown,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct AuthConfig {
297 #[serde(rename = "type")]
299 pub auth_type: AuthType,
300 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
303 pub env_var: Option<String>,
304}
305
306pub(crate) trait Provider: Send + Sync {
308 fn validate(&self) -> Result<()> {
317 Ok(())
318 }
319
320 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
329 fn env_var(&self) -> Option<&str> {
330 None
331 }
332
333 fn name(&self) -> &str;
335
336 fn base_url(&self) -> &str;
338
339 fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)>;
348
349 fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
354 &[]
355 }
356
357 fn dynamic_headers(&self, _body: &serde_json::Value) -> Vec<(String, String)> {
365 vec![]
366 }
367
368 fn matches_model(&self, model: &str) -> bool;
370
371 fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
377 if let Some(rest) = model.strip_prefix(self.name())
378 && let Some(stripped) = rest.strip_prefix('/')
379 {
380 return stripped;
381 }
382 model
383 }
384
385 fn chat_completions_path(&self) -> &str {
387 "/chat/completions"
388 }
389
390 fn embeddings_path(&self) -> &str {
392 "/embeddings"
393 }
394
395 fn models_path(&self) -> &str {
397 "/models"
398 }
399
400 fn image_generations_path(&self) -> &str {
402 "/images/generations"
403 }
404
405 fn audio_speech_path(&self) -> &str {
407 "/audio/speech"
408 }
409
410 fn audio_transcriptions_path(&self) -> &str {
412 "/audio/transcriptions"
413 }
414
415 fn moderations_path(&self) -> &str {
417 "/moderations"
418 }
419
420 fn rerank_path(&self) -> &str {
422 "/rerank"
423 }
424
425 fn files_path(&self) -> &str {
427 "/files"
428 }
429
430 fn batches_path(&self) -> &str {
432 "/batches"
433 }
434
435 fn responses_path(&self) -> &str {
437 "/responses"
438 }
439
440 fn search_path(&self) -> &str {
442 "/search"
443 }
444
445 fn ocr_path(&self) -> &str {
447 "/ocr"
448 }
449
450 #[allow(dead_code)]
452 fn supports_streaming(&self) -> bool {
453 true
454 }
455
456 fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
458 let _ = body;
459 Ok(())
460 }
461
462 fn transform_response(&self, _body: &mut serde_json::Value) -> Result<()> {
469 Ok(())
470 }
471
472 fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
478 let Ok(mut base) = url::Url::parse(self.base_url()) else {
479 return format!("{}{}", self.base_url(), endpoint_path);
480 };
481 let path = format!(
482 "{}/{}",
483 base.path().trim_end_matches('/'),
484 endpoint_path.trim_start_matches('/')
485 );
486 base.set_path(&path);
487 base.into()
488 }
489
490 fn parse_stream_event(&self, event_data: &str) -> Result<Option<crate::types::ChatCompletionChunk>> {
502 if let Ok(value) = serde_json::from_str::<serde_json::Value>(event_data)
516 && let Some(error) = value.get("error")
517 {
518 let message = error
519 .get("message")
520 .and_then(serde_json::Value::as_str)
521 .unwrap_or("provider returned an error mid-stream")
522 .to_string();
523 let status = error
524 .get("status_code")
525 .and_then(serde_json::Value::as_u64)
526 .and_then(|s| u16::try_from(s).ok())
527 .unwrap_or(400);
528 return Err(LiterLlmError::BadRequest { message, status });
529 }
530
531 serde_json::from_str::<crate::types::ChatCompletionChunk>(event_data)
532 .map(Some)
533 .map_err(|e| LiterLlmError::Streaming {
534 message: format!("failed to parse SSE data: {e}"),
535 })
536 }
537
538 fn stream_format(&self) -> StreamFormat {
543 StreamFormat::Sse
544 }
545
546 fn build_stream_url(&self, endpoint_path: &str, model: &str) -> String {
552 self.build_url(endpoint_path, model)
553 }
554
555 fn signing_headers(&self, method: &str, url: &str, body: &[u8]) -> Result<Vec<(String, String)>> {
576 let _ = (method, url, body);
577 Ok(vec![])
578 }
579
580 fn signing_headers_with_credential(
588 &self,
589 method: &str,
590 url: &str,
591 body: &[u8],
592 credential: Option<&crate::auth::Credential>,
593 ) -> Result<Vec<(String, String)>> {
594 let _ = credential;
595 self.signing_headers(method, url, body)
596 }
597}
598
599pub(crate) mod anthropic;
600pub(crate) mod azure;
601pub(crate) mod bedrock;
602pub(crate) mod cohere;
603pub mod custom;
604pub(crate) mod github_copilot;
605pub(crate) mod google_ai;
606pub(crate) mod mistral;
607pub mod outbound_policy;
608pub(crate) mod vertex;
609
610#[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
611pub use outbound_policy::configure_outbound_client_builder;
612#[cfg(any(feature = "native-http", feature = "wasm-http"))]
613pub(crate) use outbound_policy::outbound_forbidden_from_reqwest;
614pub use outbound_policy::{
615 OutboundPolicy, current_policy, set_outbound_policy, validate_outbound_url, validate_outbound_url_sync,
616};
617#[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
618pub(crate) use outbound_policy::{authenticated_outbound_client, configure_credential_free_outbound_client_builder};
619
620pub(crate) struct OpenAiProvider;
622
623impl Provider for OpenAiProvider {
624 fn name(&self) -> &str {
625 "openai"
626 }
627
628 fn base_url(&self) -> &str {
629 "https://api.openai.com/v1"
630 }
631
632 fn env_var(&self) -> Option<&str> {
633 Some("OPENAI_API_KEY")
634 }
635
636 fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
637 Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
638 }
639
640 fn matches_model(&self, model: &str) -> bool {
641 model.starts_with("gpt-")
642 || model.starts_with("o1-")
643 || model.starts_with("o3-")
644 || model.starts_with("o4-")
645 || model == "o1"
646 || model == "o3"
647 || model == "o4"
648 || model.starts_with("dall-e-")
649 || model.starts_with("whisper-")
650 || model.starts_with("tts-")
651 || model.starts_with("text-embedding-")
652 || model.starts_with("chatgpt-")
653 || model.starts_with("openai/")
654 }
655
656 fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
657 model.strip_prefix("openai/").unwrap_or(model)
658 }
659
660 fn transform_response(&self, body: &mut serde_json::Value) -> Result<()> {
674 transform_openai_audio_response(body)
675 }
676}
677
678pub(crate) fn transform_openai_audio_response(body: &mut serde_json::Value) -> Result<()> {
682 use serde_json::json;
683
684 let choices = match body.get_mut("choices").and_then(|c| c.as_array_mut()) {
685 Some(c) => c,
686 None => return Ok(()),
687 };
688
689 for choice in choices {
690 let audio = match choice.pointer("/message/audio") {
691 Some(a) if !a.is_null() => a.clone(),
692 _ => continue,
693 };
694
695 let data = audio.get("data").and_then(|v| v.as_str()).unwrap_or("").to_owned();
696 let format = audio.get("format").and_then(|v| v.as_str()).unwrap_or("wav").to_owned();
697 let transcript = audio
698 .get("transcript")
699 .and_then(|v| v.as_str())
700 .unwrap_or("")
701 .to_owned();
702
703 let mut parts: Vec<serde_json::Value> = vec![];
704 if !transcript.is_empty() {
705 parts.push(json!({"type": "text", "text": transcript}));
706 }
707 if !data.is_empty() {
708 parts.push(json!({
709 "type": "output_audio",
710 "audio": {"data": data, "format": format}
711 }));
712 }
713
714 if !parts.is_empty()
715 && let Some(message) = choice.get_mut("message")
716 {
717 message["content"] = json!(parts);
718 }
719 }
720
721 Ok(())
722}
723
724pub(crate) struct OpenAiCompatibleProvider {
726 pub name: String,
727 pub base_url: String,
728 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
730 pub env_var: Option<&'static str>,
731 pub model_prefixes: Vec<String>,
732 pub hint_provider: Option<String>,
734}
735
736impl Provider for OpenAiCompatibleProvider {
737 fn name(&self) -> &str {
738 &self.name
739 }
740
741 fn base_url(&self) -> &str {
742 &self.base_url
743 }
744
745 fn env_var(&self) -> Option<&str> {
746 self.env_var
747 }
748
749 fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
750 Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
751 }
752
753 fn matches_model(&self, model: &str) -> bool {
754 self.model_prefixes
755 .iter()
756 .any(|prefix| model.starts_with(prefix.as_str()))
757 }
758
759 fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
767 if let Some((prefix, rest)) = model.split_once('/')
768 && !rest.is_empty()
769 && (prefix == self.name || self.hint_provider.as_deref() == Some(prefix))
770 {
771 return rest;
772 }
773 model
774 }
775}
776
777pub(crate) fn hint_provider_name(model_hint: &str) -> Option<String> {
781 let name = model_hint.split_once('/').map_or(model_hint, |(prefix, _)| prefix);
782 (!name.is_empty()).then(|| name.to_owned())
783}
784
785pub(crate) struct ConfigDrivenProvider {
804 config: &'static ProviderConfig,
805 aliases: &'static [String],
806 endpoint_urls: Option<&'static HashMap<String, String>>,
807}
808
809impl ConfigDrivenProvider {
810 #[cfg(test)]
811 #[must_use]
812 pub(crate) fn new(config: &'static ProviderConfig) -> Self {
813 Self {
814 config,
815 aliases: &[],
816 endpoint_urls: None,
817 }
818 }
819
820 fn from_entry(entry: &'static ProviderEntry) -> Self {
821 Self {
822 config: &entry.config,
823 aliases: &entry.aliases,
824 endpoint_urls: Some(&entry.endpoint_urls),
825 }
826 }
827}
828
829impl Provider for ConfigDrivenProvider {
830 fn name(&self) -> &str {
831 &self.config.name
832 }
833
834 fn base_url(&self) -> &str {
835 self.config.base_url.as_deref().unwrap_or("")
836 }
837
838 fn env_var(&self) -> Option<&str> {
839 self.config.auth.as_ref().and_then(|a| a.env_var.as_deref())
840 }
841
842 fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
843 if endpoint_path == self.rerank_path()
844 && let Some(url) = self.endpoint_urls.and_then(|urls| urls.get("rerank"))
845 {
846 return url.clone();
847 }
848 format!("{}{}", self.base_url(), endpoint_path)
849 }
850
851 fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
852 match model.split_once('/') {
853 Some((prefix, rest)) if prefix == self.config.name || self.aliases.iter().any(|alias| alias == prefix) => {
854 rest
855 }
856 _ => model,
857 }
858 }
859
860 fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
861 if let Some(mappings) = &self.config.param_mappings
862 && let Some(obj) = body.as_object_mut()
863 {
864 for (from, to) in mappings {
865 if let Some(val) = obj.remove(from.as_str()) {
866 obj.insert(to.clone(), val);
867 }
868 }
869 }
870 Ok(())
871 }
872
873 fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
874 let auth_type = self
875 .config
876 .auth
877 .as_ref()
878 .map(|a| &a.auth_type)
879 .unwrap_or(&AuthType::Bearer);
880
881 match auth_type {
882 AuthType::None => None,
883 AuthType::ApiKey => Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key))),
884 AuthType::Bearer | AuthType::Unknown => {
885 Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
886 }
887 }
888 }
889
890 fn matches_model(&self, model: &str) -> bool {
891 if let Some(prefixes) = &self.config.model_prefixes {
892 prefixes.iter().any(|p| model.starts_with(p.as_str()))
893 } else {
894 false
895 }
896 }
897}
898
899pub(crate) fn detect_provider(model: &str) -> Option<Box<dyn Provider>> {
918 if let Some(provider) = custom::detect_custom_provider(model) {
919 return Some(provider);
920 }
921
922 let openai = OpenAiProvider;
923 if openai.matches_model(model) {
924 return Some(Box::new(openai));
925 }
926
927 let anthropic = anthropic::AnthropicProvider::default();
928 if anthropic.matches_model(model) {
929 return Some(Box::new(anthropic));
930 }
931
932 if model.starts_with("azure/") {
933 return Some(Box::new(azure::AzureProvider::new()));
934 }
935
936 if model.starts_with("gemini/") || model.starts_with("google_ai/") {
937 return Some(Box::new(google_ai::GoogleAiProvider::default()));
938 }
939
940 if model.starts_with("vertex_ai/") {
941 return Some(Box::new(vertex::VertexAiProvider::from_env()));
942 }
943
944 if model.starts_with("bedrock/") {
945 return Some(Box::new(bedrock::BedrockProvider::from_env()));
946 }
947
948 if model.starts_with("command-") || model.starts_with("cohere/") {
949 return Some(Box::new(cohere::CohereProvider));
950 }
951
952 if model.starts_with("mistral-")
953 || model.starts_with("codestral-")
954 || model.starts_with("pixtral-")
955 || model.starts_with("mistral/")
956 {
957 return Some(Box::new(mistral::MistralProvider));
958 }
959
960 if model.starts_with("github_copilot/") {
961 return Some(Box::new(github_copilot::GithubCopilotProvider::from_env()));
962 }
963
964 let reg = match REGISTRY.as_ref() {
965 Ok(r) => r,
966 Err(_) => return None,
967 };
968
969 if let Some((prefix, _)) = model.split_once('/')
970 && let Some(entry) = reg
971 .providers
972 .iter()
973 .find(|e| e.config.name == prefix)
974 .or_else(|| reg.providers.iter().find(|e| e.aliases.iter().any(|a| a == prefix)))
975 && entry.config.base_url.is_some()
976 && !reg.complex_providers.contains(&entry.config.name)
977 {
978 return Some(Box::new(ConfigDrivenProvider::from_entry(entry)));
979 }
980
981 for entry in ®.providers {
982 if reg.complex_providers.contains(&entry.config.name) {
983 continue;
984 }
985 if let Some(prefixes) = &entry.config.model_prefixes {
986 let matches = prefixes
987 .iter()
988 .any(|p| model.starts_with(p.as_str()) && !p.ends_with('/'));
989 if matches && entry.config.base_url.is_some() {
990 return Some(Box::new(ConfigDrivenProvider::from_entry(entry)));
991 }
992 }
993 }
994
995 None
996}
997
998pub fn all_providers() -> Result<&'static [ProviderConfig]> {
1004 Ok(®istry()?.configs)
1005}
1006
1007pub fn complex_provider_names() -> Result<&'static HashSet<String>> {
1014 Ok(®istry()?.complex_providers)
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019 use super::*;
1020 use serde_json::json;
1021
1022 #[test]
1026 fn validate_sampling_param_range_rejects_out_of_range_value() {
1027 let body = json!({"temperature": 1.8});
1028 let err = validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0)
1029 .expect_err("1.8 is outside [0.0, 1.0]");
1030 assert_eq!(err.status_code(), 400);
1031 assert_eq!(
1032 err.to_string(),
1033 "bad request: temperature=1.8 is outside Anthropic's supported range [0, 1]; lower \
1034 the requested value or omit `temperature` to use the provider default"
1035 );
1036 }
1037
1038 #[test]
1039 fn validate_sampling_param_range_accepts_in_range_value() {
1040 let body = json!({"temperature": 0.5});
1041 assert!(validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0).is_ok());
1042 }
1043
1044 #[test]
1045 fn validate_sampling_param_range_ignores_absent_field() {
1046 let body = json!({"model": "claude-3-5-sonnet"});
1047 assert!(validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0).is_ok());
1048 }
1049
1050 #[test]
1061 fn static_key_providers_declare_their_env_var() {
1062 let cases: Vec<(&str, Box<dyn Provider>)> = vec![
1063 ("OPENAI_API_KEY", Box::new(OpenAiProvider)),
1064 (
1065 "ANTHROPIC_API_KEY",
1066 Box::new(super::anthropic::AnthropicProvider::new()),
1067 ),
1068 ("COHERE_API_KEY", Box::new(super::cohere::CohereProvider)),
1069 ("MISTRAL_API_KEY", Box::new(super::mistral::MistralProvider)),
1070 (
1071 "GEMINI_API_KEY",
1072 Box::new(super::google_ai::GoogleAiProvider::default()),
1073 ),
1074 (
1075 "AZURE_OPENAI_API_KEY",
1076 Box::new(super::azure::AzureProvider::with_base_url("https://x.openai.azure.com")),
1077 ),
1078 ];
1079
1080 for (expected, provider) in cases {
1081 assert_eq!(
1082 provider.env_var(),
1083 Some(expected),
1084 "{} must declare {expected}",
1085 provider.name()
1086 );
1087 }
1088 }
1089
1090 #[test]
1091 fn minimax_regional_endpoints_are_registered() {
1092 let registry = registry().expect("registry should load");
1093 let provider = registry
1094 .providers
1095 .iter()
1096 .find(|entry| entry.config.name == "minimax")
1097 .expect("MiniMax provider should be registered");
1098
1099 assert!(provider.capabilities.reasoning);
1100 assert_eq!(
1101 provider.regional_endpoints,
1102 vec![
1103 RegionalEndpointConfig {
1104 region: "global_en".into(),
1105 openai_base_url: "https://api.minimax.io/v1".into(),
1106 anthropic_base_url: "https://api.minimax.io/anthropic".into(),
1107 docs_root: "https://platform.minimax.io/docs".into(),
1108 },
1109 RegionalEndpointConfig {
1110 region: "cn_zh".into(),
1111 openai_base_url: "https://api.minimaxi.com/v1".into(),
1112 anthropic_base_url: "https://api.minimaxi.com/anthropic".into(),
1113 docs_root: "https://platform.minimaxi.com/docs".into(),
1114 },
1115 ]
1116 );
1117 }
1118
1119 #[test]
1124 fn openai_tool_result_image_part_serializes_to_exact_content_array() {
1125 use crate::types::{ContentPart, Message, ToolMessage, UserContent};
1126
1127 let message = Message::Tool(ToolMessage {
1128 content: UserContent::Parts(vec![
1129 ContentPart::text("Here is a screenshot:"),
1130 ContentPart::image_data_url("data:image/png;base64,abc123"),
1131 ]),
1132 tool_call_id: "call_shot".into(),
1133 name: None,
1134 });
1135 let mut body = json!({"model": "gpt-4o", "messages": [message]});
1136
1137 OpenAiProvider
1138 .transform_request(&mut body)
1139 .expect("transform_request should not fail");
1140
1141 assert_eq!(
1142 body["messages"][0],
1143 json!({
1144 "role": "tool",
1145 "content": [
1146 {"type": "text", "text": "Here is a screenshot:"},
1147 {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}
1148 ],
1149 "tool_call_id": "call_shot"
1150 })
1151 );
1152 }
1153
1154 #[test]
1155 fn transform_response_audio_message_hoisted() {
1156 let mut body = json!({
1157 "id": "chatcmpl-audio-123",
1158 "object": "chat.completion",
1159 "created": 1700000000u64,
1160 "model": "gpt-4o-audio-preview",
1161 "choices": [{
1162 "index": 0,
1163 "message": {
1164 "role": "assistant",
1165 "content": null,
1166 "audio": {
1167 "id": "audio-abc",
1168 "data": "aGVsbG8=",
1169 "transcript": "hello",
1170 "format": "wav",
1171 "expires_at": 9999999999u64
1172 }
1173 },
1174 "finish_reason": "stop"
1175 }]
1176 });
1177
1178 transform_openai_audio_response(&mut body).expect("transform must succeed");
1179
1180 let content = body
1181 .pointer("/choices/0/message/content")
1182 .expect("content must be present");
1183 assert!(content.is_array(), "content must be a parts array, got: {content}");
1184 let parts = content.as_array().expect("array");
1185 assert_eq!(parts.len(), 2);
1186 assert_eq!(parts[0]["type"], "text");
1187 assert_eq!(parts[0]["text"], "hello");
1188 assert_eq!(parts[1]["type"], "output_audio");
1189 assert_eq!(parts[1]["audio"]["data"], "aGVsbG8=");
1190 assert_eq!(parts[1]["audio"]["format"], "wav");
1191 }
1192
1193 #[test]
1194 fn transform_response_audio_no_op_when_no_audio_field() {
1195 let mut body = json!({
1196 "choices": [{
1197 "message": {
1198 "role": "assistant",
1199 "content": "plain text"
1200 }
1201 }]
1202 });
1203 let original = body.clone();
1204 transform_openai_audio_response(&mut body).expect("transform must succeed");
1205 assert_eq!(body, original, "body must be unchanged when no audio field is present");
1206 }
1207
1208 #[test]
1209 fn parse_stream_event_tolerates_trailing_metadata_event_without_id() {
1210 let payload = r#"{"choices":[],"x-opencode-type":"inference-cost","cost":"0.00001400","normalizedUsage":{"inputTokens":84,"outputTokens":8}}"#;
1214 let chunk = OpenAiProvider
1215 .parse_stream_event(payload)
1216 .expect("trailing metadata event must not error")
1217 .expect("event should decode to a chunk, not be skipped");
1218 assert!(chunk.id.is_empty());
1219 assert!(chunk.choices.is_empty());
1220 }
1221
1222 #[test]
1225 fn parse_stream_event_surfaces_mid_stream_error_object() {
1226 let payload = r#"{"error":{"message":"tool call validation failed","type":"invalid_request_error","code":"tool_use_failed","status_code":400}}"#;
1231 let err = OpenAiProvider
1232 .parse_stream_event(payload)
1233 .expect_err("a mid-stream error object must not decode as a chunk");
1234 assert_eq!(err.status_code(), 400);
1235 assert!(
1236 err.to_string().contains("tool call validation failed"),
1237 "the provider's own message must reach the caller, got: {err}"
1238 );
1239 }
1240
1241 #[test]
1244 fn parse_stream_event_defaults_status_when_error_object_omits_it() {
1245 let payload = r#"{"error":{"message":"upstream capacity exceeded"}}"#;
1249 let err = OpenAiProvider
1250 .parse_stream_event(payload)
1251 .expect_err("an error object without a status must still be an error");
1252 assert_eq!(err.status_code(), 400);
1253 assert!(err.to_string().contains("upstream capacity exceeded"));
1254 }
1255
1256 #[test]
1259 fn parse_stream_event_tolerates_metadata_event_without_choices() {
1260 let payload =
1265 r#"{"object":"chat.completion.chunk","usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}"#;
1266 let chunk = OpenAiProvider
1267 .parse_stream_event(payload)
1268 .expect("a metadata event without `choices` must not error")
1269 .expect("event should decode to a chunk, not be skipped");
1270 assert!(chunk.choices.is_empty());
1271 }
1272
1273 #[test]
1274 fn parse_stream_event_parses_normal_chunk_with_id() {
1275 let payload = r#"{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}"#;
1276 let chunk = OpenAiProvider
1277 .parse_stream_event(payload)
1278 .expect("valid chunk must parse")
1279 .expect("valid chunk must yield Some");
1280 assert_eq!(chunk.id, "chatcmpl-1");
1281 assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("hi"));
1282 }
1283}