diff --git a/cli/planoai/config_generator.py b/cli/planoai/config_generator.py index cb07767e0..7f5496081 100644 --- a/cli/planoai/config_generator.py +++ b/cli/planoai/config_generator.py @@ -23,6 +23,7 @@ "mistral", "openai", "xiaomi", + "qianfan", "gemini", "anthropic", "together_ai", diff --git a/cli/planoai/defaults.py b/cli/planoai/defaults.py index 1d9468ff3..cd77b5c96 100644 --- a/cli/planoai/defaults.py +++ b/cli/planoai/defaults.py @@ -66,6 +66,12 @@ class ProviderDefault: base_url="https://api.deepseek.com/v1", model_pattern="deepseek/*", ), + ProviderDefault( + name="qianfan", + env_var="QIANFAN_API_KEY", + base_url="https://qianfan.baidubce.com/v2", + model_pattern="qianfan/*", + ), ProviderDefault( name="mistral", env_var="MISTRAL_API_KEY", diff --git a/cli/test/test_config_generator.py b/cli/test/test_config_generator.py index 77b5b4803..e1d22b2be 100644 --- a/cli/test/test_config_generator.py +++ b/cli/test/test_config_generator.py @@ -293,6 +293,24 @@ def test_validate_and_render_happy_path_agent_config(monkeypatch): base_url: https://openrouter.ai/api/v1 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 + """, }, { diff --git a/cli/test/test_defaults.py b/cli/test/test_defaults.py index 7017a70c4..a9ee052e9 100644 --- a/cli/test/test_defaults.py +++ b/cli/test/test_defaults.py @@ -27,6 +27,7 @@ def test_zero_env_vars_produces_pure_passthrough(): assert provider.get("default") is not True # All known providers should be listed. names = {p["name"] for p in cfg["model_providers"]} + assert "qianfan" in names assert "digitalocean" in names assert "vercel" in names assert "openrouter" in names @@ -80,6 +81,11 @@ def test_synthesized_config_validates_against_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(): by_name = {p.name: p for p in PROVIDER_DEFAULTS} assert "digitalocean" in by_name @@ -104,6 +110,21 @@ def test_provider_defaults_openrouter_is_configured(): 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(): cfg = synthesize_default_config(env={"OPENROUTER_API_KEY": "or-1"}) by_name = {p["name"]: p for p in cfg["model_providers"]} diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml index 9560b4376..e210f953c 100644 --- a/config/plano_config_schema.yaml +++ b/config/plano_config_schema.yaml @@ -189,6 +189,7 @@ properties: - mistral - openai - xiaomi + - qianfan - gemini - chatgpt - digitalocean @@ -247,6 +248,7 @@ properties: - mistral - openai - xiaomi + - qianfan - gemini - chatgpt - digitalocean diff --git a/crates/common/src/configuration.rs b/crates/common/src/configuration.rs index 37492904d..bfeec2eac 100644 --- a/crates/common/src/configuration.rs +++ b/crates/common/src/configuration.rs @@ -372,6 +372,8 @@ pub enum LlmProviderType { OpenAI, #[serde(rename = "xiaomi")] Xiaomi, + #[serde(rename = "qianfan")] + Qianfan, #[serde(rename = "gemini")] Gemini, #[serde(rename = "xai")] @@ -412,6 +414,7 @@ impl Display for LlmProviderType { LlmProviderType::Mistral => write!(f, "mistral"), LlmProviderType::OpenAI => write!(f, "openai"), LlmProviderType::Xiaomi => write!(f, "xiaomi"), + LlmProviderType::Qianfan => write!(f, "qianfan"), LlmProviderType::XAI => write!(f, "xai"), LlmProviderType::TogetherAI => write!(f, "together_ai"), 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] fn test_overrides_disable_signals_default_none() { let overrides = super::Overrides::default(); diff --git a/crates/hermesllm/src/clients/endpoints.rs b/crates/hermesllm/src/clients/endpoints.rs index eeef88565..0769eaffa 100644 --- a/crates/hermesllm/src/clients/endpoints.rs +++ b/crates/hermesllm/src/clients/endpoints.rs @@ -133,6 +133,13 @@ impl SupportedAPIsFromClient { 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 => { if request_path.starts_with("/v1/") { let suffix = endpoint_suffix.trim_start_matches('/'); @@ -400,6 +407,19 @@ mod tests { "/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 assert_eq!( api.target_endpoint_for_provider( diff --git a/crates/hermesllm/src/providers/id.rs b/crates/hermesllm/src/providers/id.rs index 4fa7d19d1..d01a71031 100644 --- a/crates/hermesllm/src/providers/id.rs +++ b/crates/hermesllm/src/providers/id.rs @@ -29,6 +29,7 @@ fn load_provider_models() -> &'static HashMap> { pub enum ProviderId { OpenAI, Xiaomi, + Qianfan, Mistral, Deepseek, Groq, @@ -57,6 +58,8 @@ impl TryFrom<&str> for ProviderId { match value.to_lowercase().as_str() { "openai" => Ok(ProviderId::OpenAI), "xiaomi" => Ok(ProviderId::Xiaomi), + "qianfan" => Ok(ProviderId::Qianfan), + "baidu" => Ok(ProviderId::Qianfan), // alias "mistral" => Ok(ProviderId::Mistral), "deepseek" => Ok(ProviderId::Deepseek), "groq" => Ok(ProviderId::Groq), @@ -97,6 +100,7 @@ impl ProviderId { ProviderId::Gemini => "google", ProviderId::OpenAI => "openai", ProviderId::Xiaomi => "xiaomi", + ProviderId::Qianfan => "qianfan", ProviderId::Anthropic => "anthropic", ProviderId::Mistral => "mistralai", ProviderId::Deepseek => "deepseek", @@ -159,6 +163,7 @@ impl ProviderId { ( ProviderId::OpenAI | ProviderId::Xiaomi + | ProviderId::Qianfan | ProviderId::Groq | ProviderId::Mistral | ProviderId::Deepseek @@ -181,6 +186,7 @@ impl ProviderId { ( ProviderId::OpenAI | ProviderId::Xiaomi + | ProviderId::Qianfan | ProviderId::Groq | ProviderId::Mistral | ProviderId::Deepseek @@ -248,6 +254,7 @@ impl Display for ProviderId { match self { ProviderId::OpenAI => write!(f, "OpenAI"), ProviderId::Xiaomi => write!(f, "xiaomi"), + ProviderId::Qianfan => write!(f, "qianfan"), ProviderId::Mistral => write!(f, "Mistral"), ProviderId::Deepseek => write!(f, "Deepseek"), ProviderId::Groq => write!(f, "Groq"), @@ -380,6 +387,13 @@ mod tests { 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] fn test_vercel_compatible_api() { 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] fn test_vercel_and_openrouter_empty_models() { assert!(ProviderId::Vercel.models().is_empty()); diff --git a/docs/source/concepts/llm_providers/supported_providers.rst b/docs/source/concepts/llm_providers/supported_providers.rst index 60f468e0a..a2b145424 100644 --- a/docs/source/concepts/llm_providers/supported_providers.rst +++ b/docs/source/concepts/llm_providers/supported_providers.rst @@ -547,6 +547,31 @@ Xiaomi MiMo - model: xiaomi/mimo-v2-omni 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 `_ 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 ----------------------------