feat(providers): add Qianfan support

This commit is contained in:
jimmyzhuu 2026-04-25 13:55:47 +08:00
parent 938f9c4bdf
commit e3911a2f43
9 changed files with 147 additions and 0 deletions

View file

@ -23,6 +23,7 @@ SUPPORTED_PROVIDERS_WITHOUT_BASE_URL = [
"mistral", "mistral",
"openai", "openai",
"xiaomi", "xiaomi",
"qianfan",
"gemini", "gemini",
"anthropic", "anthropic",
"together_ai", "together_ai",

View file

@ -66,6 +66,12 @@ PROVIDER_DEFAULTS: list[ProviderDefault] = [
base_url="https://api.deepseek.com/v1", base_url="https://api.deepseek.com/v1",
model_pattern="deepseek/*", model_pattern="deepseek/*",
), ),
ProviderDefault(
name="qianfan",
env_var="QIANFAN_API_KEY",
base_url="https://qianfan.baidubce.com/v2",
model_pattern="qianfan/*",
),
ProviderDefault( ProviderDefault(
name="mistral", name="mistral",
env_var="MISTRAL_API_KEY", env_var="MISTRAL_API_KEY",

View file

@ -293,6 +293,24 @@ model_providers:
base_url: https://openrouter.ai/api/v1 base_url: https://openrouter.ai/api/v1
passthrough_auth: true passthrough_auth: true
""",
},
{
"id": "qianfan_is_supported_provider",
"expected_error": None,
"plano_config": """
version: v0.4.0
listeners:
- name: llm
type: model
port: 12000
model_providers:
- model: qianfan/*
base_url: https://qianfan.baidubce.com/v2
passthrough_auth: true
""", """,
}, },
{ {

View file

@ -27,6 +27,7 @@ def test_zero_env_vars_produces_pure_passthrough():
assert provider.get("default") is not True assert provider.get("default") is not True
# All known providers should be listed. # All known providers should be listed.
names = {p["name"] for p in cfg["model_providers"]} names = {p["name"] for p in cfg["model_providers"]}
assert "qianfan" in names
assert "digitalocean" in names assert "digitalocean" in names
assert "vercel" in names assert "vercel" in names
assert "openrouter" in names assert "openrouter" in names
@ -80,6 +81,11 @@ def test_synthesized_config_validates_against_schema():
jsonschema.validate(cfg, _schema()) jsonschema.validate(cfg, _schema())
def test_synthesized_config_with_qianfan_validates_against_schema():
cfg = synthesize_default_config(env={"QIANFAN_API_KEY": "qf-1"})
jsonschema.validate(cfg, _schema())
def test_provider_defaults_digitalocean_is_configured(): def test_provider_defaults_digitalocean_is_configured():
by_name = {p.name: p for p in PROVIDER_DEFAULTS} by_name = {p.name: p for p in PROVIDER_DEFAULTS}
assert "digitalocean" in by_name assert "digitalocean" in by_name
@ -104,6 +110,21 @@ def test_provider_defaults_openrouter_is_configured():
assert by_name["openrouter"].model_pattern == "openrouter/*" assert by_name["openrouter"].model_pattern == "openrouter/*"
def test_provider_defaults_qianfan_is_configured():
by_name = {p.name: p for p in PROVIDER_DEFAULTS}
assert "qianfan" in by_name
assert by_name["qianfan"].env_var == "QIANFAN_API_KEY"
assert by_name["qianfan"].base_url == "https://qianfan.baidubce.com/v2"
assert by_name["qianfan"].model_pattern == "qianfan/*"
def test_qianfan_env_key_promotes_to_env_keyed():
cfg = synthesize_default_config(env={"QIANFAN_API_KEY": "qf-1"})
by_name = {p["name"]: p for p in cfg["model_providers"]}
assert by_name["qianfan"].get("access_key") == "$QIANFAN_API_KEY"
assert by_name["qianfan"].get("passthrough_auth") is None
def test_openrouter_env_key_promotes_to_env_keyed(): def test_openrouter_env_key_promotes_to_env_keyed():
cfg = synthesize_default_config(env={"OPENROUTER_API_KEY": "or-1"}) cfg = synthesize_default_config(env={"OPENROUTER_API_KEY": "or-1"})
by_name = {p["name"]: p for p in cfg["model_providers"]} by_name = {p["name"]: p for p in cfg["model_providers"]}

View file

@ -189,6 +189,7 @@ properties:
- mistral - mistral
- openai - openai
- xiaomi - xiaomi
- qianfan
- gemini - gemini
- chatgpt - chatgpt
- digitalocean - digitalocean
@ -247,6 +248,7 @@ properties:
- mistral - mistral
- openai - openai
- xiaomi - xiaomi
- qianfan
- gemini - gemini
- chatgpt - chatgpt
- digitalocean - digitalocean

View file

@ -372,6 +372,8 @@ pub enum LlmProviderType {
OpenAI, OpenAI,
#[serde(rename = "xiaomi")] #[serde(rename = "xiaomi")]
Xiaomi, Xiaomi,
#[serde(rename = "qianfan")]
Qianfan,
#[serde(rename = "gemini")] #[serde(rename = "gemini")]
Gemini, Gemini,
#[serde(rename = "xai")] #[serde(rename = "xai")]
@ -412,6 +414,7 @@ impl Display for LlmProviderType {
LlmProviderType::Mistral => write!(f, "mistral"), LlmProviderType::Mistral => write!(f, "mistral"),
LlmProviderType::OpenAI => write!(f, "openai"), LlmProviderType::OpenAI => write!(f, "openai"),
LlmProviderType::Xiaomi => write!(f, "xiaomi"), LlmProviderType::Xiaomi => write!(f, "xiaomi"),
LlmProviderType::Qianfan => write!(f, "qianfan"),
LlmProviderType::XAI => write!(f, "xai"), LlmProviderType::XAI => write!(f, "xai"),
LlmProviderType::TogetherAI => write!(f, "together_ai"), LlmProviderType::TogetherAI => write!(f, "together_ai"),
LlmProviderType::AzureOpenAI => write!(f, "azure_openai"), LlmProviderType::AzureOpenAI => write!(f, "azure_openai"),
@ -783,6 +786,15 @@ mod test {
} }
} }
#[test]
fn test_llm_provider_type_qianfan_roundtrip() {
let parsed: LlmProviderType =
serde_yaml::from_str("qianfan").expect("variant should deserialize");
assert_eq!(parsed, LlmProviderType::Qianfan);
assert_eq!(parsed.to_string(), "qianfan");
assert_eq!(parsed.to_provider_id(), hermesllm::ProviderId::Qianfan);
}
#[test] #[test]
fn test_overrides_disable_signals_default_none() { fn test_overrides_disable_signals_default_none() {
let overrides = super::Overrides::default(); let overrides = super::Overrides::default();

View file

@ -133,6 +133,13 @@ impl SupportedAPIsFromClient {
build_endpoint("/v1", endpoint_suffix) build_endpoint("/v1", endpoint_suffix)
} }
} }
ProviderId::Qianfan => {
if request_path.starts_with("/v1/") {
build_endpoint("/v2", endpoint_suffix)
} else {
build_endpoint("/v1", endpoint_suffix)
}
}
ProviderId::AzureOpenAI => { ProviderId::AzureOpenAI => {
if request_path.starts_with("/v1/") { if request_path.starts_with("/v1/") {
let suffix = endpoint_suffix.trim_start_matches('/'); let suffix = endpoint_suffix.trim_start_matches('/');
@ -400,6 +407,19 @@ mod tests {
"/compatible-mode/v1/chat/completions" "/compatible-mode/v1/chat/completions"
); );
// Test Qianfan provider
assert_eq!(
api.target_endpoint_for_provider(
&ProviderId::Qianfan,
"/v1/chat/completions",
"ernie-4.0-turbo-8k",
false,
None,
false
),
"/v2/chat/completions"
);
// Test Azure OpenAI provider // Test Azure OpenAI provider
assert_eq!( assert_eq!(
api.target_endpoint_for_provider( api.target_endpoint_for_provider(

View file

@ -29,6 +29,7 @@ fn load_provider_models() -> &'static HashMap<String, Vec<String>> {
pub enum ProviderId { pub enum ProviderId {
OpenAI, OpenAI,
Xiaomi, Xiaomi,
Qianfan,
Mistral, Mistral,
Deepseek, Deepseek,
Groq, Groq,
@ -57,6 +58,8 @@ impl TryFrom<&str> for ProviderId {
match value.to_lowercase().as_str() { match value.to_lowercase().as_str() {
"openai" => Ok(ProviderId::OpenAI), "openai" => Ok(ProviderId::OpenAI),
"xiaomi" => Ok(ProviderId::Xiaomi), "xiaomi" => Ok(ProviderId::Xiaomi),
"qianfan" => Ok(ProviderId::Qianfan),
"baidu" => Ok(ProviderId::Qianfan), // alias
"mistral" => Ok(ProviderId::Mistral), "mistral" => Ok(ProviderId::Mistral),
"deepseek" => Ok(ProviderId::Deepseek), "deepseek" => Ok(ProviderId::Deepseek),
"groq" => Ok(ProviderId::Groq), "groq" => Ok(ProviderId::Groq),
@ -97,6 +100,7 @@ impl ProviderId {
ProviderId::Gemini => "google", ProviderId::Gemini => "google",
ProviderId::OpenAI => "openai", ProviderId::OpenAI => "openai",
ProviderId::Xiaomi => "xiaomi", ProviderId::Xiaomi => "xiaomi",
ProviderId::Qianfan => "qianfan",
ProviderId::Anthropic => "anthropic", ProviderId::Anthropic => "anthropic",
ProviderId::Mistral => "mistralai", ProviderId::Mistral => "mistralai",
ProviderId::Deepseek => "deepseek", ProviderId::Deepseek => "deepseek",
@ -159,6 +163,7 @@ impl ProviderId {
( (
ProviderId::OpenAI ProviderId::OpenAI
| ProviderId::Xiaomi | ProviderId::Xiaomi
| ProviderId::Qianfan
| ProviderId::Groq | ProviderId::Groq
| ProviderId::Mistral | ProviderId::Mistral
| ProviderId::Deepseek | ProviderId::Deepseek
@ -181,6 +186,7 @@ impl ProviderId {
( (
ProviderId::OpenAI ProviderId::OpenAI
| ProviderId::Xiaomi | ProviderId::Xiaomi
| ProviderId::Qianfan
| ProviderId::Groq | ProviderId::Groq
| ProviderId::Mistral | ProviderId::Mistral
| ProviderId::Deepseek | ProviderId::Deepseek
@ -248,6 +254,7 @@ impl Display for ProviderId {
match self { match self {
ProviderId::OpenAI => write!(f, "OpenAI"), ProviderId::OpenAI => write!(f, "OpenAI"),
ProviderId::Xiaomi => write!(f, "xiaomi"), ProviderId::Xiaomi => write!(f, "xiaomi"),
ProviderId::Qianfan => write!(f, "qianfan"),
ProviderId::Mistral => write!(f, "Mistral"), ProviderId::Mistral => write!(f, "Mistral"),
ProviderId::Deepseek => write!(f, "Deepseek"), ProviderId::Deepseek => write!(f, "Deepseek"),
ProviderId::Groq => write!(f, "Groq"), ProviderId::Groq => write!(f, "Groq"),
@ -380,6 +387,13 @@ mod tests {
assert!(ProviderId::try_from("open_router").is_err()); assert!(ProviderId::try_from("open_router").is_err());
} }
#[test]
fn test_qianfan_parsing_and_display() {
assert_eq!(ProviderId::try_from("qianfan"), Ok(ProviderId::Qianfan));
assert_eq!(ProviderId::try_from("baidu"), Ok(ProviderId::Qianfan));
assert_eq!(ProviderId::Qianfan.to_string(), "qianfan");
}
#[test] #[test]
fn test_vercel_compatible_api() { fn test_vercel_compatible_api() {
use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs}; use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
@ -436,6 +450,34 @@ mod tests {
); );
} }
#[test]
fn test_qianfan_compatible_api() {
use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
let openai_client =
SupportedAPIsFromClient::OpenAIChatCompletions(OpenAIApi::ChatCompletions);
let upstream = ProviderId::Qianfan.compatible_api_for_client(&openai_client, false);
assert!(
matches!(upstream, SupportedUpstreamAPIs::OpenAIChatCompletions(_)),
"Qianfan should map OpenAI client to OpenAIChatCompletions upstream"
);
let anthropic_client =
SupportedAPIsFromClient::AnthropicMessagesAPI(AnthropicApi::Messages);
let upstream = ProviderId::Qianfan.compatible_api_for_client(&anthropic_client, false);
assert!(
matches!(upstream, SupportedUpstreamAPIs::OpenAIChatCompletions(_)),
"Qianfan should translate Anthropic client to OpenAIChatCompletions upstream"
);
let responses_client = SupportedAPIsFromClient::OpenAIResponsesAPI(OpenAIApi::Responses);
let upstream = ProviderId::Qianfan.compatible_api_for_client(&responses_client, false);
assert!(
matches!(upstream, SupportedUpstreamAPIs::OpenAIChatCompletions(_)),
"Qianfan should translate Responses API client to OpenAIChatCompletions upstream"
);
}
#[test] #[test]
fn test_vercel_and_openrouter_empty_models() { fn test_vercel_and_openrouter_empty_models() {
assert!(ProviderId::Vercel.models().is_empty()); assert!(ProviderId::Vercel.models().is_empty());

View file

@ -547,6 +547,31 @@ Xiaomi MiMo
- model: xiaomi/mimo-v2-omni - model: xiaomi/mimo-v2-omni
access_key: $MIMO_API_KEY access_key: $MIMO_API_KEY
Baidu Qianfan
~~~~~~~~~~~~~
**Provider Prefix:** ``qianfan/``
**API Endpoint:** ``/v2/chat/completions`` through Qianfan's OpenAI-compatible API.
**Authentication:** API Key - Get your API key from `Baidu AI Cloud Qianfan <https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application>`_ and set ``QIANFAN_API_KEY``.
**Supported Chat Models:** All Qianfan chat models available through the OpenAI-compatible API, including ERNIE models and future chat model releases.
**Configuration Examples:**
.. code-block:: yaml
llm_providers:
# Configure Qianfan models with wildcard routing
- model: qianfan/*
access_key: $QIANFAN_API_KEY
# Or configure a specific ERNIE model
- model: qianfan/ernie-4.0-turbo-8k
access_key: $QIANFAN_API_KEY
default: true
Providers Requiring Base URL Providers Requiring Base URL
---------------------------- ----------------------------