use crate::Api;
use crate::catalog::BuiltinProviderEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMethod {
Bearer,
XApiKey,
ApiKey,
None,
}
#[derive(Debug, Clone)]
pub struct BuiltinProvider {
pub name: &'static str,
pub display_name: &'static str,
pub aliases: &'static [&'static str],
pub api: Api,
pub env_key: &'static str,
pub extra_env_keys: &'static [&'static str],
pub base_url: &'static str,
pub default_enabled: bool,
pub auth_method: AuthMethod,
pub extra_headers: &'static [(&'static str, &'static str)],
pub category: &'static str,
pub description: &'static str,
}
fn parse_api(s: &str) -> Api {
match s {
"openai-completions" => Api::OpenAiCompletions,
"openai-responses" => Api::OpenAiResponses,
"anthropic-messages" => Api::AnthropicMessages,
"google-generative-ai" => Api::GoogleGenerativeAi,
"google-vertex" => Api::GoogleVertex,
"mistral-conversations" => Api::MistralConversations,
"azure-openai-responses" => Api::AzureOpenAiResponses,
"bedrock-converse-stream" => Api::BedrockConverseStream,
_ => Api::OpenAiCompletions,
}
}
impl From<&BuiltinProviderEntry> for BuiltinProvider {
fn from(entry: &BuiltinProviderEntry) -> Self {
let name: &'static str = Box::leak(entry.id.clone().into_boxed_str());
let display_name: &'static str = Box::leak(entry.display_name.clone().into_boxed_str());
let aliases: &'static [&'static str] = Box::leak(
entry
.aliases
.iter()
.map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str)
.collect::<Vec<_>>()
.into_boxed_slice(),
);
let env_key: &'static str = Box::leak(entry.env_key.clone().into_boxed_str());
let extra_env_keys: &'static [&'static str] = Box::leak(
entry
.extra_env_keys
.iter()
.map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str)
.collect::<Vec<_>>()
.into_boxed_slice(),
);
let base_url: &'static str = Box::leak(entry.base_url.clone().into_boxed_str());
let category: &'static str = Box::leak(entry.category.clone().into_boxed_str());
let description: &'static str = Box::leak(entry.description.clone().into_boxed_str());
let extra_headers: &'static [(&'static str, &'static str)] = Box::leak(
entry
.extra_headers
.iter()
.map(|(k, v)| {
(
Box::leak(k.clone().into_boxed_str()) as &'static str,
Box::leak(v.clone().into_boxed_str()) as &'static str,
)
})
.collect::<Vec<_>>()
.into_boxed_slice(),
);
BuiltinProvider {
name,
display_name,
aliases,
api: parse_api(&entry.api),
env_key,
extra_env_keys,
base_url,
default_enabled: entry.default_enabled,
auth_method: match entry.auth_method {
crate::catalog::AuthMethod::Bearer => AuthMethod::Bearer,
crate::catalog::AuthMethod::XApiKey => AuthMethod::XApiKey,
crate::catalog::AuthMethod::ApiKey => AuthMethod::ApiKey,
crate::catalog::AuthMethod::None => AuthMethod::None,
},
extra_headers,
category,
description,
}
}
}
static API_TO_PROVIDER: &[(&str, Api)] = &[
("anthropic-messages", Api::AnthropicMessages),
("openai-completions", Api::OpenAiCompletions),
("mistral-conversations", Api::MistralConversations),
("openai-responses", Api::OpenAiResponses),
("azure-openai-responses", Api::AzureOpenAiResponses),
("google-generative-ai", Api::GoogleGenerativeAi),
("google-vertex", Api::GoogleVertex),
("bedrock-converse-stream", Api::BedrockConverseStream),
];
pub fn get_builtin_providers() -> &'static [BuiltinProvider] {
static CACHE: std::sync::OnceLock<Vec<BuiltinProvider>> = std::sync::OnceLock::new();
CACHE
.get_or_init(|| {
let mut builtins: Vec<crate::catalog::BuiltinProviderEntry> =
crate::catalog::load_builtin_providers().to_vec();
if let Some(overrides) = crate::catalog::load_overrides() {
crate::catalog::apply_provider_overrides(&mut builtins, &overrides.provider);
}
builtins.iter().map(BuiltinProvider::from).collect()
})
.as_slice()
}
pub fn get_builtin_provider(name: &str) -> Option<&'static BuiltinProvider> {
get_builtin_providers()
.iter()
.find(|p| p.name == name || p.aliases.contains(&name))
}
pub fn get_provider_env_key(name: &str) -> Option<&'static str> {
get_builtin_provider(name).map(|p| p.env_key)
}
pub fn get_provider_env_keys(name: &str) -> Vec<&'static str> {
if let Some(p) = get_builtin_provider(name) {
let mut keys = vec![p.env_key];
keys.extend_from_slice(p.extra_env_keys);
keys
} else {
vec![]
}
}
pub fn get_provider_api(name: &str) -> Option<Api> {
get_builtin_provider(name).map(|p| p.api)
}
pub fn get_provider_base_url(name: &str) -> Option<&'static str> {
get_builtin_provider(name).map(|p| p.base_url)
}
pub fn get_api_mappings() -> &'static [(&'static str, Api)] {
API_TO_PROVIDER
}
pub fn get_all_provider_names() -> Vec<&'static str> {
get_builtin_providers().iter().map(|p| p.name).collect()
}
pub fn get_all_provider_aliases() -> Vec<&'static str> {
let mut names: Vec<&'static str> = get_builtin_providers()
.iter()
.flat_map(|p| std::iter::once(p.name).chain(p.aliases.iter().copied()))
.collect();
names.sort();
names.dedup();
names
}
pub fn resolve_provider_name(name: &str) -> Option<&'static str> {
get_builtin_provider(name).map(|p| p.name)
}
pub fn is_builtin_provider(name: &str) -> bool {
get_builtin_provider(name).is_some()
}
pub fn create_builtin_provider(name: &str) -> Option<Box<dyn super::Provider>> {
let builtin = get_builtin_provider(name)?;
match builtin.api {
Api::AnthropicMessages => {
let extra_headers: Vec<(String, String)> = builtin
.extra_headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
if !builtin.base_url.is_empty() && builtin.name != "anthropic" {
Some(Box::new(super::anthropic::AnthropicProvider::with_config(
builtin.base_url,
None,
extra_headers,
)))
} else {
Some(Box::new(super::anthropic::AnthropicProvider::new()))
}
}
Api::GoogleGenerativeAi => Some(Box::new(super::google::GoogleProvider::new())),
Api::GoogleVertex => Some(Box::new(super::vertex::VertexProvider::new())),
Api::MistralConversations => Some(Box::new(super::mistral::MistralProvider::new())),
Api::AzureOpenAiResponses => Some(Box::new(super::azure::AzureProvider::new())),
Api::BedrockConverseStream => Some(Box::new(super::bedrock::BedrockProvider::new())),
Api::OpenAiResponses => Some(Box::new(
super::openai_responses::OpenAiResponsesProvider::new(),
)),
Api::OpenAiCompletions => {
let extra_headers: Vec<(String, String)> = builtin
.extra_headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
if builtin.base_url.is_empty() {
if extra_headers.is_empty() {
Some(Box::new(super::openai::OpenAiProvider::new()))
} else {
Some(Box::new(super::openai::OpenAiProvider::with_config(
"https://api.openai.com/v1",
None,
extra_headers,
)))
}
} else if extra_headers.is_empty() {
Some(Box::new(super::openai::OpenAiProvider::with_base_url(
builtin.base_url,
)))
} else {
Some(Box::new(super::openai::OpenAiProvider::with_config(
builtin.base_url,
None,
extra_headers,
)))
}
}
}
}
pub fn create_builtin_provider_with_options(
name: &str,
api_key: Option<&str>,
base_url: Option<&str>,
) -> Option<Box<dyn super::Provider>> {
let builtin = get_builtin_provider(name)?;
let resolved_key = api_key.map(String::from).or_else(|| {
std::env::var(builtin.env_key).ok().or_else(|| {
builtin
.extra_env_keys
.iter()
.find_map(|k| std::env::var(k).ok())
})
});
let resolved_base_url = base_url.map(String::from).or_else(|| {
if builtin.base_url.is_empty() {
None
} else {
Some(builtin.base_url.to_string())
}
});
let extra_headers: Vec<(String, String)> = builtin
.extra_headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
match builtin.api {
Api::AnthropicMessages => {
if let Some(key) = resolved_key {
Some(Box::new(super::anthropic::AnthropicProvider::with_config(
resolved_base_url
.as_deref()
.unwrap_or("https://api.anthropic.com"),
Some(key),
extra_headers,
)))
} else if resolved_base_url.is_some() {
Some(Box::new(super::anthropic::AnthropicProvider::with_config(
resolved_base_url
.as_deref()
.unwrap_or("https://api.anthropic.com"),
None,
extra_headers,
)))
} else {
create_builtin_provider(name)
}
}
Api::GoogleGenerativeAi => create_builtin_provider(name),
Api::GoogleVertex => create_builtin_provider(name),
Api::MistralConversations => create_builtin_provider(name),
Api::AzureOpenAiResponses => create_builtin_provider(name),
Api::BedrockConverseStream => create_builtin_provider(name),
Api::OpenAiResponses => create_builtin_provider(name),
Api::OpenAiCompletions => {
let url = resolved_base_url
.as_deref()
.unwrap_or(if builtin.base_url.is_empty() {
"https://api.openai.com/v1"
} else {
builtin.base_url
});
if let Some(key) = resolved_key {
if extra_headers.is_empty() {
Some(Box::new(
super::openai::OpenAiProvider::with_base_url_and_key(url, Some(key)),
))
} else {
Some(Box::new(super::openai::OpenAiProvider::with_config(
url,
Some(key),
extra_headers,
)))
}
} else if url != builtin.base_url || !extra_headers.is_empty() {
Some(Box::new(super::openai::OpenAiProvider::with_config(
url,
None,
extra_headers,
)))
} else {
create_builtin_provider(name)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_builtin_provider_anthropic() {
let p = create_builtin_provider("anthropic").unwrap();
assert_eq!(p.name(), "anthropic");
}
#[test]
fn test_create_builtin_provider_openai() {
let p = create_builtin_provider("openai").unwrap();
assert_eq!(p.name(), "openai");
}
#[test]
fn test_create_builtin_provider_by_alias() {
let p = create_builtin_provider("amazon-bedrock").unwrap();
assert_eq!(p.name(), "bedrock");
}
#[test]
fn test_create_builtin_provider_unknown() {
assert!(create_builtin_provider("unknown").is_none());
}
#[test]
fn layer2_override_adds_provider() {
use std::io::Write;
let dir = std::env::temp_dir().join("oxi-test-layer2");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("overrides.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[provider]]
id = "test-injected-{}"
display_name = "Test Injected"
api = "openai-completions"
env_key = "TEST_INJECTED_KEY"
auth_method = "bearer"
category = "primary"
description = "Test provider from override"
"#,
std::process::id()
)
.unwrap();
drop(f);
unsafe {
std::env::set_var("OXI_CATALOG_OVERRIDE", &path);
}
let files = crate::catalog::find_override_files();
unsafe {
std::env::remove_var("OXI_CATALOG_OVERRIDE");
}
assert!(!files.is_empty(), "OXI_CATALOG_OVERRIDE should be detected");
let (found_path, _content) = &files[0];
assert_eq!(found_path, &path);
}
#[test]
fn test_create_builtin_provider_deepseek() {
let p = create_builtin_provider("deepseek").unwrap();
assert_eq!(p.name(), "openai"); }
#[test]
fn test_create_builtin_provider_minimax() {
let p = create_builtin_provider("minimax").unwrap();
assert_eq!(p.name(), "anthropic"); }
#[test]
fn test_create_builtin_provider_minimax_cn() {
let p = create_builtin_provider("minimax-cn").unwrap();
assert_eq!(p.name(), "anthropic"); }
#[test]
fn test_create_builtin_provider_togetherai() {
let p = create_builtin_provider("togetherai").unwrap();
assert_eq!(p.name(), "openai");
}
#[test]
fn test_create_builtin_provider_openrouter() {
let p = create_builtin_provider("openrouter").unwrap();
assert_eq!(p.name(), "openai");
}
#[test]
fn test_create_builtin_provider_cerebras() {
let p = create_builtin_provider("cerebras").unwrap();
assert_eq!(p.name(), "openai");
}
#[test]
fn test_get_builtin_provider_openai() {
let p = get_builtin_provider("openai").unwrap();
assert_eq!(p.name, "openai");
assert_eq!(p.display_name, "OpenAI");
assert_eq!(p.api, Api::OpenAiCompletions);
assert_eq!(p.auth_method, AuthMethod::Bearer);
}
#[test]
fn test_get_builtin_provider_anthropic() {
let p = get_builtin_provider("anthropic").unwrap();
assert_eq!(p.name, "anthropic");
assert_eq!(p.auth_method, AuthMethod::XApiKey);
}
#[test]
fn test_get_builtin_provider_azure() {
let p = get_builtin_provider("azure").unwrap();
assert_eq!(p.auth_method, AuthMethod::ApiKey);
}
#[test]
fn test_get_builtin_provider_by_alias() {
let p = get_builtin_provider("amazon-bedrock").unwrap();
assert_eq!(p.name, "bedrock");
}
#[test]
fn test_get_builtin_provider_unknown() {
assert!(get_builtin_provider("unknown-provider").is_none());
}
#[test]
fn test_get_provider_env_key() {
assert_eq!(get_provider_env_key("openai"), Some("OPENAI_API_KEY"));
assert_eq!(get_provider_env_key("anthropic"), Some("ANTHROPIC_API_KEY"));
}
#[test]
fn test_get_provider_env_keys_with_extras() {
let keys = get_provider_env_keys("google");
assert!(keys.contains(&"GOOGLE_API_KEY"));
assert!(keys.contains(&"GEMINI_API_KEY"));
}
#[test]
fn test_get_provider_api() {
assert_eq!(get_provider_api("anthropic"), Some(Api::AnthropicMessages));
assert_eq!(get_provider_api("vertex"), Some(Api::GoogleVertex));
}
#[test]
fn test_resolve_provider_name() {
assert_eq!(resolve_provider_name("google-vertex"), Some("vertex"));
assert_eq!(resolve_provider_name("aws-bedrock"), Some("bedrock"));
assert_eq!(resolve_provider_name("openai"), Some("openai"));
}
#[test]
fn test_is_builtin_provider() {
assert!(is_builtin_provider("openai"));
assert!(is_builtin_provider("deepseek"));
assert!(is_builtin_provider("togetherai"));
assert!(!is_builtin_provider("fake-provider"));
}
#[test]
fn test_all_providers_have_env_key() {
for p in get_builtin_providers() {
assert!(!p.env_key.is_empty(), "Provider {} has no env key", p.name);
}
}
#[test]
fn test_all_providers_have_auth_method() {
for p in get_builtin_providers() {
match p.auth_method {
AuthMethod::Bearer
| AuthMethod::XApiKey
| AuthMethod::ApiKey
| AuthMethod::None => {}
}
}
}
#[test]
fn test_get_all_provider_names() {
let names = get_all_provider_names();
assert!(names.contains(&"openai"));
assert!(names.contains(&"anthropic"));
assert!(names.contains(&"bedrock"));
assert!(names.contains(&"togetherai"));
assert!(names.len() >= 20);
}
#[test]
fn test_openclaw_ported_providers_present() {
let names = get_all_provider_names();
for p in [
"chutes",
"venice",
"moonshot",
"byteplus",
"gmi",
"novita",
"arcee",
"qianfan",
"stepfun",
"qwen-portal",
"alibaba",
"anthropic-vertex",
"synthetic",
"ollama",
"ollama-cloud",
"lmstudio",
"vllm",
"sglang",
"litellm",
"microsoft-foundry",
"amazon-bedrock-mantle",
"opencode",
"copilot-proxy",
"xiaomi-token-plan",
"kilocode",
] {
assert!(names.contains(&p), "Missing openclaw-ported provider: {p}");
}
}
#[test]
fn test_openclaw_provider_aliases() {
assert_eq!(resolve_provider_name("gmi-cloud"), Some("gmi"));
assert_eq!(resolve_provider_name("gmicloud"), Some("gmi"));
assert_eq!(resolve_provider_name("dashscope"), Some("alibaba"));
assert_eq!(resolve_provider_name("modelstudio"), Some("alibaba"));
assert_eq!(resolve_provider_name("qwen-oauth"), Some("qwen-portal"));
assert_eq!(resolve_provider_name("qwen-cli"), Some("qwen-portal"));
assert_eq!(resolve_provider_name("novita-ai"), Some("novita"));
assert_eq!(resolve_provider_name("novitaai"), Some("novita"));
assert_eq!(resolve_provider_name("stepfun-plan"), Some("stepfun"));
assert_eq!(resolve_provider_name("kilocode"), Some("kilocode"));
}
#[test]
fn test_openclaw_provider_base_urls() {
assert_eq!(
get_provider_base_url("chutes"),
Some("https://llm.chutes.ai/v1")
);
assert_eq!(
get_provider_base_url("venice"),
Some("https://api.venice.ai/api/v1")
);
assert_eq!(
get_provider_base_url("ollama"),
Some("http://localhost:11434/v1")
);
assert_eq!(
get_provider_base_url("lmstudio"),
Some("http://localhost:1234/v1")
);
assert_eq!(
get_provider_base_url("vllm"),
Some("http://localhost:8000/v1")
);
assert_eq!(
get_provider_base_url("synthetic"),
Some("https://api.synthetic.new/anthropic")
);
}
#[test]
fn test_openclaw_local_providers_use_bearer() {
for p in ["ollama", "ollama-cloud", "lmstudio", "vllm", "sglang"] {
let bp = get_builtin_provider(p).unwrap();
assert_eq!(
bp.auth_method,
AuthMethod::Bearer,
"{p} should use Bearer auth"
);
}
}
#[test]
fn test_openclaw_anthropic_compat_providers() {
for p in ["synthetic", "anthropic-vertex"] {
let bp = get_builtin_provider(p).unwrap();
assert_eq!(
bp.api,
Api::AnthropicMessages,
"{p} should use AnthropicMessages API"
);
}
}
#[test]
fn test_create_openclaw_providers() {
for p in [
"chutes",
"venice",
"moonshot",
"byteplus",
"gmi",
"novita",
"arcee",
"qianfan",
"stepfun",
"qwen-portal",
"alibaba",
"anthropic-vertex",
"synthetic",
"ollama",
"lmstudio",
"vllm",
"sglang",
"litellm",
"microsoft-foundry",
"opencode",
"copilot-proxy",
"xiaomi-token-plan",
"kilocode",
] {
let bp = create_builtin_provider(p);
assert!(bp.is_some(), "create_builtin_provider({p}) failed");
}
}
#[test]
fn test_get_all_provider_aliases() {
let aliases = get_all_provider_aliases();
assert!(aliases.contains(&"amazon-bedrock"));
assert!(aliases.contains(&"aws-bedrock"));
assert!(aliases.contains(&"bedrock"));
assert!(aliases.contains(&"together"));
}
#[test]
fn test_get_provider_base_url() {
assert_eq!(
get_provider_base_url("openai"),
Some("https://api.openai.com/v1")
);
assert_eq!(
get_provider_base_url("anthropic"),
Some("https://api.anthropic.com")
);
assert_eq!(
get_provider_base_url("groq"),
Some("https://api.groq.com/openai/v1")
);
assert_eq!(
get_provider_base_url("togetherai"),
Some("https://api.together.xyz/v1")
);
}
#[test]
fn test_minimax_base_url() {
let p = get_builtin_provider("minimax").unwrap();
assert_eq!(p.base_url, "https://api.minimax.io/anthropic");
assert_eq!(p.api, Api::AnthropicMessages);
}
#[test]
fn test_openrouter_extra_headers() {
let p = get_builtin_provider("openrouter").unwrap();
assert_eq!(
p.extra_headers,
&[("HTTP-Referer", "https://oxi.dev/"), ("X-Title", "oxi")]
);
}
#[test]
fn test_cerebras_extra_headers() {
let p = get_builtin_provider("cerebras").unwrap();
assert_eq!(
p.extra_headers,
&[("X-Cerebras-3rd-Party-Integration", "opencode")]
);
}
#[test]
fn test_create_builtin_provider_with_options_openai() {
let p = create_builtin_provider_with_options(
"openai",
Some("sk-test-key"),
Some("https://my-proxy.example.com/v1"),
);
assert!(p.is_some());
assert_eq!(p.unwrap().name(), "openai");
}
#[test]
fn test_create_builtin_provider_with_options_anthropic() {
let p = create_builtin_provider_with_options("anthropic", Some("sk-ant-test-key"), None);
assert!(p.is_some());
assert_eq!(p.unwrap().name(), "anthropic");
}
#[test]
fn test_create_builtin_provider_with_options_no_override() {
let p = create_builtin_provider_with_options("deepseek", None, None);
assert!(p.is_some());
}
#[test]
fn test_create_builtin_provider_with_options_unknown() {
let p = create_builtin_provider_with_options("nonexistent_provider", None, None);
assert!(p.is_none());
}
}