diff --git a/crates/openfang-cli/src/launcher.rs b/crates/openfang-cli/src/launcher.rs index 18a8f1236..178d94d07 100644 --- a/crates/openfang-cli/src/launcher.rs +++ b/crates/openfang-cli/src/launcher.rs @@ -20,6 +20,7 @@ const PROVIDER_ENV_VARS: &[(&str, &str)] = &[ ("ANTHROPIC_API_KEY", "Anthropic"), ("OPENAI_API_KEY", "OpenAI"), ("DEEPSEEK_API_KEY", "DeepSeek"), + ("VOLCENGINE_API_KEY", "Volcano Engine"), ("GEMINI_API_KEY", "Gemini"), ("GOOGLE_API_KEY", "Gemini"), ("GROQ_API_KEY", "Groq"), @@ -31,7 +32,7 @@ const PROVIDER_ENV_VARS: &[(&str, &str)] = &[ fn detect_provider() -> Option<(&'static str, &'static str)> { for &(var, name) in PROVIDER_ENV_VARS { - if std::env::var(var).is_ok() { + if std::env::var(var).ok().filter(|v| !v.is_empty()).is_some() { return Some((name, var)); } } diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 104286a8e..72bbc95b8 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1420,6 +1420,18 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st ("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile", "Groq"), ("gemini", "GEMINI_API_KEY", "gemini-2.5-flash", "Gemini"), ("deepseek", "DEEPSEEK_API_KEY", "deepseek-chat", "DeepSeek"), + ( + "volcengine", + "VOLCENGINE_API_KEY", + "doubao-seed-1-6-251015", + "Volcano Engine", + ), + ( + "volcengine_coding", + "VOLCENGINE_API_KEY", + "ark-code-latest", + "Volcano Engine Coding Plan", + ), ( "anthropic", "ANTHROPIC_API_KEY", @@ -4541,6 +4553,7 @@ fn provider_to_env_var(provider: &str) -> String { "perplexity" => "PERPLEXITY_API_KEY".to_string(), "cohere" => "COHERE_API_KEY".to_string(), "xai" => "XAI_API_KEY".to_string(), + "volcengine" | "doubao" | "volcengine_coding" => "VOLCENGINE_API_KEY".to_string(), "brave" => "BRAVE_API_KEY".to_string(), "tavily" => "TAVILY_API_KEY".to_string(), other => format!("{}_API_KEY", other.to_uppercase()), @@ -4592,6 +4605,15 @@ pub(crate) fn test_api_key(provider: &str, env_var: &str) -> bool { .get("https://openrouter.ai/api/v1/models") .bearer_auth(&key) .send(), + "volcengine" | "doubao" => { + let base = openfang_types::model_catalog::VOLCENGINE_BASE_URL.trim_end_matches('/'); + client.get(format!("{base}/models")).bearer_auth(&key).send() + } + "volcengine_coding" => { + let base = openfang_types::model_catalog::VOLCENGINE_CODING_BASE_URL + .trim_end_matches('/'); + client.get(format!("{base}/models")).bearer_auth(&key).send() + } _ => return true, // unknown provider — skip test }; diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 279b1e6cf..daa1a0804 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -68,6 +68,22 @@ const PROVIDERS: &[ProviderInfo] = &[ needs_key: true, hint: "", }, + ProviderInfo { + name: "volcengine", + display: "Volcano Engine", + env_var: "VOLCENGINE_API_KEY", + default_model: "doubao-seed-1-6-251015", + needs_key: true, + hint: "cn-beijing; override base_url for other regions", + }, + ProviderInfo { + name: "volcengine_coding", + display: "Volcano Engine (Coding Plan)", + env_var: "VOLCENGINE_API_KEY", + default_model: "ark-code-latest", + needs_key: true, + hint: "ByteDance Ark — coding models (cn-beijing)", + }, ProviderInfo { name: "openrouter", display: "OpenRouter", @@ -143,7 +159,7 @@ const PROVIDERS: &[ProviderInfo] = &[ ProviderInfo { name: "qwen", display: "Qwen (Alibaba)", - env_var: "QWEN_API_KEY", + env_var: "DASHSCOPE_API_KEY", default_model: "qwen-plus", needs_key: true, hint: "", @@ -151,7 +167,7 @@ const PROVIDERS: &[ProviderInfo] = &[ ProviderInfo { name: "huggingface", display: "Hugging Face", - env_var: "HUGGINGFACE_API_KEY", + env_var: "HF_API_KEY", default_model: "meta-llama/Llama-3.3-70B-Instruct", needs_key: true, hint: "", @@ -167,7 +183,7 @@ const PROVIDERS: &[ProviderInfo] = &[ ProviderInfo { name: "replicate", display: "Replicate", - env_var: "REPLICATE_API_KEY", + env_var: "REPLICATE_API_TOKEN", default_model: "meta/meta-llama-3-70b-instruct", needs_key: true, hint: "", diff --git a/crates/openfang-cli/src/tui/screens/welcome.rs b/crates/openfang-cli/src/tui/screens/welcome.rs index 768a51ca4..96f94d115 100644 --- a/crates/openfang-cli/src/tui/screens/welcome.rs +++ b/crates/openfang-cli/src/tui/screens/welcome.rs @@ -32,6 +32,7 @@ const PROVIDER_ENV_VARS: &[(&str, &str)] = &[ ("ANTHROPIC_API_KEY", "Anthropic"), ("OPENAI_API_KEY", "OpenAI"), ("DEEPSEEK_API_KEY", "DeepSeek"), + ("VOLCENGINE_API_KEY", "Volcano Engine"), ("GEMINI_API_KEY", "Gemini"), ("GOOGLE_API_KEY", "Gemini"), ("GROQ_API_KEY", "Groq"), @@ -47,7 +48,7 @@ const PROVIDER_ENV_VARS: &[(&str, &str)] = &[ /// Returns (provider_name, env_var_name) for the first detected key, or None. fn detect_provider() -> Option<(&'static str, &'static str)> { for &(var, name) in PROVIDER_ENV_VARS { - if std::env::var(var).is_ok() { + if std::env::var(var).ok().filter(|v| !v.is_empty()).is_some() { return Some((name, var)); } } diff --git a/crates/openfang-cli/src/tui/screens/wizard.rs b/crates/openfang-cli/src/tui/screens/wizard.rs index f15b8f8c8..27b6ae782 100644 --- a/crates/openfang-cli/src/tui/screens/wizard.rs +++ b/crates/openfang-cli/src/tui/screens/wizard.rs @@ -13,6 +13,7 @@ use crate::tui::theme; /// Provider metadata for the setup wizard. struct ProviderInfo { name: &'static str, + display: &'static str, env_var: &'static str, default_model: &'static str, needs_key: bool, @@ -21,108 +22,140 @@ struct ProviderInfo { const PROVIDERS: &[ProviderInfo] = &[ ProviderInfo { name: "groq", + display: "Groq", env_var: "GROQ_API_KEY", default_model: "llama-3.3-70b-versatile", needs_key: true, }, ProviderInfo { name: "anthropic", + display: "Anthropic", env_var: "ANTHROPIC_API_KEY", default_model: "claude-sonnet-4-20250514", needs_key: true, }, ProviderInfo { name: "openai", + display: "OpenAI", env_var: "OPENAI_API_KEY", default_model: "gpt-4o", needs_key: true, }, ProviderInfo { name: "openrouter", + display: "OpenRouter", env_var: "OPENROUTER_API_KEY", default_model: "google/gemini-2.5-flash", needs_key: true, }, ProviderInfo { name: "deepseek", + display: "DeepSeek", env_var: "DEEPSEEK_API_KEY", default_model: "deepseek-chat", needs_key: true, }, ProviderInfo { name: "together", + display: "Together AI", env_var: "TOGETHER_API_KEY", default_model: "meta-llama/Llama-3.3-70B-Instruct-Turbo", needs_key: true, }, ProviderInfo { name: "mistral", + display: "Mistral", env_var: "MISTRAL_API_KEY", default_model: "mistral-large-latest", needs_key: true, }, ProviderInfo { name: "fireworks", + display: "Fireworks AI", env_var: "FIREWORKS_API_KEY", default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct", needs_key: true, }, ProviderInfo { name: "gemini", + display: "Gemini", env_var: "GEMINI_API_KEY", default_model: "gemini-2.5-flash", needs_key: true, }, ProviderInfo { name: "xai", + display: "xAI", env_var: "XAI_API_KEY", default_model: "grok-4-0709", needs_key: true, }, ProviderInfo { name: "qwen", + display: "Qwen", env_var: "DASHSCOPE_API_KEY", default_model: "qwen-plus", needs_key: true, }, + ProviderInfo { + name: "volcengine", + display: "Volcano Engine", + env_var: "VOLCENGINE_API_KEY", + default_model: "doubao-seed-1-6-251015", + needs_key: true, + }, + ProviderInfo { + name: "volcengine_coding", + display: "Volcano Engine (Coding Plan)", + env_var: "VOLCENGINE_API_KEY", + default_model: "ark-code-latest", + needs_key: true, + }, ProviderInfo { name: "perplexity", + display: "Perplexity", env_var: "PERPLEXITY_API_KEY", default_model: "sonar-pro", needs_key: true, }, ProviderInfo { name: "cohere", - env_var: "CO_API_KEY", + display: "Cohere", + env_var: "COHERE_API_KEY", default_model: "command-a", needs_key: true, }, ProviderInfo { name: "cerebras", + display: "Cerebras", env_var: "CEREBRAS_API_KEY", default_model: "llama-3.3-70b", needs_key: true, }, ProviderInfo { name: "sambanova", + display: "SambaNova", env_var: "SAMBANOVA_API_KEY", default_model: "Meta-Llama-3.3-70B-Instruct", needs_key: true, }, ProviderInfo { name: "moonshot", + display: "Moonshot", env_var: "MOONSHOT_API_KEY", default_model: "moonshot-v1-128k", needs_key: true, }, ProviderInfo { name: "zhipu", + display: "Zhipu AI", env_var: "ZHIPU_API_KEY", default_model: "glm-4-plus", needs_key: true, }, ProviderInfo { name: "zhipu_coding", + display: "Zhipu AI (Coding)", env_var: "ZHIPU_API_KEY", default_model: "codegeex-4", needs_key: true, @@ -135,24 +168,28 @@ const PROVIDERS: &[ProviderInfo] = &[ }, ProviderInfo { name: "claude-code", + display: "Claude Code", env_var: "", default_model: "claude-code/sonnet", needs_key: false, }, ProviderInfo { name: "ollama", + display: "Ollama", env_var: "OLLAMA_API_KEY", default_model: "llama3.2", needs_key: false, }, ProviderInfo { name: "vllm", + display: "vLLM", env_var: "VLLM_API_KEY", default_model: "local-model", needs_key: false, }, ProviderInfo { name: "lmstudio", + display: "LM Studio", env_var: "LMSTUDIO_API_KEY", default_model: "local-model", needs_key: false, @@ -544,7 +581,7 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) { format!("requires {}", p.env_var) }; ListItem::new(Line::from(vec![ - Span::raw(format!(" {:<14}", p.name)), + Span::raw(format!(" {:<24}", p.display)), Span::styled(hint, theme::dim_style()), ])) }) diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index f773def41..d08f782f4 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -109,13 +109,24 @@ fn append_tool_error_guidance(tool_result_blocks: &mut Vec) { pub fn strip_provider_prefix(model: &str, provider: &str) -> String { let slash_prefix = format!("{}/", provider); let colon_prefix = format!("{}:", provider); - if model.starts_with(&slash_prefix) { + let mut result = if model.starts_with(&slash_prefix) { model[slash_prefix.len()..].to_string() } else if model.starts_with(&colon_prefix) { model[colon_prefix.len()..].to_string() } else { model.to_string() + }; + // Strip "ark/" catalog namespace prefix before sending to Ark API. + // "ark/" is used internally to disambiguate Ark marketplace models from + // native provider models with the same name (e.g. ark/minimax-m2.5 vs + // minimax provider's minimax-m2.5). The Ark API endpoint expects the bare + // model name (e.g. "minimax-m2.5"), not the namespaced form. + if (provider == "volcengine_coding" || provider == "volcengine" || provider == "doubao") + && result.starts_with("ark/") + { + result = result["ark/".len()..].to_string(); } + result } /// Default context window size (tokens) for token-based trimming. @@ -3022,6 +3033,42 @@ mod tests { assert_eq!(MAX_HISTORY_MESSAGES, 20); } + #[test] + fn test_strip_ark_catalog_prefix_for_volcengine_coding() { + // ark/ is catalog-only; Ark API expects bare name + assert_eq!( + strip_provider_prefix("ark/doubao-seed-code", "volcengine_coding"), + "doubao-seed-code" + ); + } + + #[test] + fn test_strip_provider_prefix_ark_volcengine() { + // Should strip ark/ for volcengine + assert_eq!( + strip_provider_prefix("ark/doubao-seed-code", "volcengine"), + "doubao-seed-code" + ); + } + + #[test] + fn test_strip_provider_prefix_ark_doubao() { + // Should strip ark/ for doubao provider alias + assert_eq!( + strip_provider_prefix("ark/some-model", "doubao"), + "some-model" + ); + } + + #[test] + fn test_strip_provider_prefix_ark_not_stripped_for_other_providers() { + // Must NOT strip ark/ for non-volcengine providers + assert_eq!( + strip_provider_prefix("ark/some-model", "openai"), + "ark/some-model" + ); + } + // --- Integration tests for empty response guards --- fn test_manifest() -> AgentManifest { diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index 2df8923d4..ef0b52fd3 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -2,7 +2,8 @@ //! //! Contains drivers for Anthropic Claude, Google Gemini, OpenAI-compatible APIs, and more. //! Supports: Anthropic, Gemini, OpenAI, Groq, OpenRouter, DeepSeek, Together, -//! Mistral, Fireworks, Ollama, vLLM, Chutes.ai, and any OpenAI-compatible endpoint. +//! Mistral, Fireworks, Ollama, vLLM, Chutes.ai, volcengine (Doubao / Ark), +//! and any OpenAI-compatible endpoint. pub mod anthropic; pub mod claude_code; @@ -197,6 +198,7 @@ fn provider_defaults(provider: &str) -> Option { api_key_env: "QIANFAN_API_KEY", key_required: true, }), + // "doubao" is also a model alias in builtin_aliases; here it acts as a provider alias "volcengine" | "doubao" => Some(ProviderDefaults { base_url: VOLCENGINE_BASE_URL, api_key_env: "VOLCENGINE_API_KEY", @@ -260,6 +262,7 @@ fn provider_defaults(provider: &str) -> Option { /// - `xai` — xAI (Grok) /// - `replicate` — Replicate /// - `chutes` — Chutes.ai (serverless open-source model inference) +/// - `volcengine` — Volcano Engine (Doubao/Ark) /// - Any custom provider with `base_url` set uses OpenAI-compatible format pub fn create_driver(config: &DriverConfig) -> Result, LlmError> { let provider = config.provider.as_str(); @@ -489,7 +492,8 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr "Unknown provider '{}'. Supported: anthropic, gemini, openai, azure, groq, openrouter, \ deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \ cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \ - chutes, venice, nvidia, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.", + chutes, venice, nvidia, codex, claude-code, volcengine (doubao), volcengine_coding. \ + Or set base_url for a custom OpenAI-compatible endpoint.", provider ), }) @@ -507,6 +511,7 @@ pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'stat ("gemini", "gemini-2.5-flash", "GEMINI_API_KEY"), ("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY"), ("deepseek", "deepseek-chat", "DEEPSEEK_API_KEY"), + ("volcengine", "doubao-seed-1-6-251015", "VOLCENGINE_API_KEY"), ( "openrouter", "openrouter/google/gemini-2.5-flash", @@ -584,6 +589,7 @@ pub fn known_providers() -> &'static [&'static str] { "kimi_coding", "qianfan", "volcengine", + "volcengine_coding", "chutes", "venice", "nvidia", @@ -689,13 +695,14 @@ mod tests { assert!(providers.contains(&"kimi_coding")); assert!(providers.contains(&"qianfan")); assert!(providers.contains(&"volcengine")); + assert!(providers.contains(&"volcengine_coding")); assert!(providers.contains(&"chutes")); assert!(providers.contains(&"nvidia")); assert!(providers.contains(&"codex")); assert!(providers.contains(&"claude-code")); assert!(providers.contains(&"qwen-code")); assert!(providers.contains(&"azure")); - assert_eq!(providers.len(), 37); + assert_eq!(providers.len(), 38); } #[test] @@ -888,4 +895,31 @@ mod tests { "azure-openai alias should create driver successfully" ); } + + #[test] + fn test_provider_defaults_volcengine() { + let d = provider_defaults("volcengine").unwrap(); + assert_eq!(d.base_url, "https://ark.cn-beijing.volces.com/api/v3"); + assert_eq!(d.api_key_env, "VOLCENGINE_API_KEY"); + assert!(d.key_required); + } + + #[test] + fn test_provider_defaults_volcengine_doubao_alias() { + let d = provider_defaults("doubao").unwrap(); + assert_eq!(d.base_url, "https://ark.cn-beijing.volces.com/api/v3"); + assert_eq!(d.api_key_env, "VOLCENGINE_API_KEY"); + assert!(d.key_required); + } + + #[test] + fn test_provider_defaults_volcengine_coding() { + let d = provider_defaults("volcengine_coding").unwrap(); + assert_eq!( + d.base_url, + "https://ark.cn-beijing.volces.com/api/coding/v3" + ); + assert_eq!(d.api_key_env, "VOLCENGINE_API_KEY"); + assert!(d.key_required); + } } diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 62b81c74e..0dff6b5c1 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -850,7 +850,7 @@ fn builtin_providers() -> Vec { // ── Volcano Engine (Doubao) ────────────────────────────────── ProviderInfo { id: "volcengine".into(), - display_name: "Volcano Engine (Doubao)".into(), + display_name: "Volcano Engine".into(), api_key_env: "VOLCENGINE_API_KEY".into(), base_url: VOLCENGINE_BASE_URL.into(), key_required: true, @@ -3462,9 +3462,156 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec![], }, + // ══════════════════════════════════════════════════════════════ + // Volcano Engine Coding Plan (9) + // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "ark-code-latest".into(), + display_name: "Ark Code (Latest)".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Smart, + context_window: 131_072, + max_output_tokens: 8_192, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec!["ark-code".into()], + }, + ModelCatalogEntry { + id: "doubao-seed-2.0-code".into(), + display_name: "Doubao Seed 2.0 Code".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Smart, + context_window: 262_144, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "doubao-seed-2.0-pro".into(), + display_name: "Doubao Seed 2.0 Pro".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Frontier, + context_window: 262_144, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "doubao-seed-2.0-lite".into(), + display_name: "Doubao Seed 2.0 Lite (Ark Coding)".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Fast, + context_window: 262_144, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "doubao-seed-code-ark".into(), + display_name: "Doubao Seed Code (Ark)".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Smart, + context_window: 262_144, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + // Third-party models available via Ark marketplace. + // The "ark/" prefix is the canonical ID for these Ark marketplace models to avoid + // collisions with native provider entries (minimax, zhipu, moonshot). + // The bare model name is kept as an alias only where no collision exists. + // Pricing not publicly documented for Ark-routed third-party models; set to 0.0 + ModelCatalogEntry { + id: "ark/minimax-m2.5".into(), + display_name: "MiniMax M2.5 (via Ark)".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Smart, + context_window: 200_000, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + // "minimax-m2.5" NOT added as alias — already canonical on the minimax provider entry + aliases: vec![], + }, + ModelCatalogEntry { + id: "ark/glm-4.7".into(), + display_name: "GLM 4.7 (via Ark)".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Balanced, + context_window: 200_000, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + // "glm-4.7" NOT added as alias — already canonical on the zhipu provider entry + aliases: vec![], + }, + ModelCatalogEntry { + id: "ark/deepseek-v3.2".into(), + display_name: "DeepSeek V3.2 (via Ark)".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Smart, + context_window: 131_072, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + // "deepseek-v3.2" kept as alias — no collision with other providers + aliases: vec!["deepseek-v3.2".into()], + }, + ModelCatalogEntry { + id: "ark/kimi-k2.5".into(), + display_name: "Kimi K2.5 (via Ark)".into(), + provider: "volcengine_coding".into(), + tier: ModelTier::Smart, + context_window: 262_144, + max_output_tokens: 16_384, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + // "kimi-k2.5" NOT added as alias — already canonical on the moonshot provider entry + aliases: vec![], + }, + // ══════════════════════════════════════════════════════════════ // Volcano Engine / Doubao (4) // ══════════════════════════════════════════════════════════════ + // + // NOTE on separators: the volcengine provider uses hyphen-only IDs + // (e.g. "doubao-seed-2-0-lite") because the Ark /api/v3 endpoint uses + // endpoint-access-point names that don't contain dots. The + // volcengine_coding entries above use dot notation + // (e.g. "doubao-seed-2.0-lite") which is the model version string used + // by the /api/coding/v3 endpoint. These are different endpoint paths + // and the IDs must not be unified. ModelCatalogEntry { id: "doubao-seed-1-6-251015".into(), display_name: "Doubao Seed 1.6 Pro".into(), @@ -3477,6 +3624,8 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: false, supports_streaming: true, + // "doubao" also maps to the volcengine provider in provider_defaults() — intentional dual alias + // Also matched as a provider alias in provider_defaults() — keep in sync aliases: vec!["doubao".into(), "doubao-pro".into()], }, ModelCatalogEntry { @@ -3507,6 +3656,7 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec!["doubao-mini".into()], }, + // Standard plan (/api/v3) variant; see volcengine_coding for the coding-plan endpoint ModelCatalogEntry { id: "doubao-seed-code".into(), display_name: "Doubao Seed Code".into(), @@ -4523,4 +4673,47 @@ mod tests { assert_eq!(found.provider, "custom_provider"); assert_eq!(found.id, "My-Custom-LLM"); } + + #[test] + fn test_ark_alias_resolution() { + let catalog = ModelCatalog::new(); + // ark/ IDs are now canonical — resolve_alias returns the id itself (no alias mapping needed) + // deepseek-v3.2 is still an alias pointing to ark/deepseek-v3.2 + assert_eq!(catalog.resolve_alias("deepseek-v3.2"), Some("ark/deepseek-v3.2")); + // find_model via ark/ canonical ID returns the volcengine_coding entry directly. + let m25 = catalog.find_model("ark/minimax-m2.5").unwrap(); + assert_eq!(m25.id, "ark/minimax-m2.5"); + assert_eq!(m25.provider, "volcengine_coding"); + // glm-4.7 canonical ID now belongs to ark entry; zhipu entry is unaffected. + let glm = catalog.find_model("ark/glm-4.7").unwrap(); + assert_eq!(glm.id, "ark/glm-4.7"); + assert_eq!(glm.provider, "volcengine_coding"); + // deepseek-v3.2 exists only under volcengine_coding; bare alias still resolves. + let ds = catalog.find_model("ark/deepseek-v3.2").unwrap(); + assert_eq!(ds.id, "ark/deepseek-v3.2"); + assert_eq!(ds.provider, "volcengine_coding"); + let ds_alias = catalog.find_model("deepseek-v3.2").unwrap(); + assert_eq!(ds_alias.id, "ark/deepseek-v3.2"); + let kimi = catalog.find_model("ark/kimi-k2.5").unwrap(); + assert_eq!(kimi.id, "ark/kimi-k2.5"); + assert_eq!(kimi.provider, "volcengine_coding"); + // Native provider entries are unaffected by the ark/ rename + let minimax_native = catalog.find_model("minimax-m2.5").unwrap(); + assert_eq!(minimax_native.provider, "minimax"); + let glm_native = catalog.find_model("glm-4.7").unwrap(); + assert_eq!(glm_native.provider, "zhipu"); + let kimi_native = catalog.find_model("kimi-k2.5").unwrap(); + assert_eq!(kimi_native.provider, "moonshot"); + } + + #[test] + fn test_doubao_alias_resolves_to_volcengine_model() { + let catalog = ModelCatalog::new(); + // "doubao" alias should resolve to the model ID + let resolved = catalog.resolve_alias("doubao"); + assert_eq!(resolved, Some("doubao-seed-1-6-251015")); + // The model should belong to the volcengine provider + let model = catalog.find_model("doubao-seed-1-6-251015").unwrap(); + assert_eq!(model.provider, "volcengine"); + } } diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index a7d2627ca..6dc391968 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -47,6 +47,8 @@ pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; pub const KIMI_CODING_BASE_URL: &str = "https://api.kimi.com/coding"; pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2"; +// Hardcoded to cn-beijing region. Operators in other regions can override via +// `base_url` in the provider config (e.g. `[provider_urls] volcengine = "https://ark..volces.com/api/v3"`). pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3"; pub const VOLCENGINE_CODING_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";