From 5d491bc6832eb4c887af3af159d21d6ff2123d33 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 5 Jun 2026 20:40:28 -0700 Subject: [PATCH] feat(config): harvest provider metadata registry Add a metadata-only provider registry foundation from #2479. The registry exposes canonical lookup, alias-aware resolution, defaults, config table keys, and API-key env candidates without changing runtime routing or activating fallback providers. Co-authored-by: sximelon <62371427+sximelon@users.noreply.github.com> --- CHANGELOG.md | 5 + README.md | 4 +- crates/config/src/lib.rs | 118 +++++++++++ crates/config/src/provider.rs | 363 ++++++++++++++++++++++++++++++++++ crates/tui/CHANGELOG.md | 5 + 5 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 crates/config/src/provider.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7fafeb..778ec00e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). +- Added a metadata-only `codewhale-config` provider registry with canonical + lookup, alias-aware resolution, provider defaults, config-table keys, and + API-key env candidates. Runtime routing remains unchanged and fallback + providers stay dormant; this harvests the safe provider-trait foundation from + #2479 toward #2075. Thanks @sximelon. - Added optional `[search].base_url` / `CODEWHALE_SEARCH_BASE_URL` support for DuckDuckGo-compatible private search endpoints, while keeping `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by diff --git a/README.md b/README.md index a9b7d3e6..ca35f231 100644 --- a/README.md +++ b/README.md @@ -642,8 +642,8 @@ Current v0.9 track credits: - **[shenjackyuanjie](https://github.com/shenjackyuanjie)** — HarmonyOS / OpenHarmony porting work and MatePad Edge validation trail (#2634) - **[sximelon](https://github.com/sximelon)** — saved-session resume footer - hint work and provider-trait direction reviewed for the v0.9 track (#2758, - #2760, #2479) + hint work plus provider-trait metadata registry direction reviewed and + harvested for the v0.9 track (#2758, #2760, #2479) - **[aboimpinto](https://github.com/aboimpinto)** — sidebar command polish and pausable custom-command lifecycle direction harvested into the v0.9 track (#2788, #2732) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index df472807..2fc34fe0 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,3 +1,5 @@ +pub mod provider; + use std::collections::BTreeMap; use std::fs; #[cfg(unix)] @@ -134,6 +136,27 @@ pub enum ProviderKind { } impl ProviderKind { + pub const ALL: [Self; 18] = [ + Self::Deepseek, + Self::NvidiaNim, + Self::Openai, + Self::Atlascloud, + Self::WanjieArk, + Self::Volcengine, + Self::Openrouter, + Self::XiaomiMimo, + Self::Novita, + Self::Fireworks, + Self::Siliconflow, + Self::SiliconflowCN, + Self::Arcee, + Self::Moonshot, + Self::Sglang, + Self::Vllm, + Self::Ollama, + Self::Huggingface, + ]; + #[must_use] pub fn as_str(self) -> &'static str { match self { @@ -192,6 +215,15 @@ impl ProviderKind { pub fn is_siliconflow(self) -> bool { matches!(self, Self::Siliconflow | Self::SiliconflowCN) } + + /// Return the built-in metadata entry for this provider. + /// + /// This is a metadata foundation only; runtime routing still resolves + /// through [`ConfigToml::resolve_runtime_options`]. + #[must_use] + pub fn provider(self) -> &'static dyn provider::Provider { + provider::provider_for_kind(self) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -4401,6 +4433,92 @@ unix_socket_path = "/tmp/cw-hooks.sock" } } + #[test] + fn provider_metadata_registry_covers_every_provider_kind_once() { + let providers = provider::all_providers(); + assert_eq!(providers.len(), ProviderKind::ALL.len()); + + for (kind, provider) in ProviderKind::ALL.iter().zip(providers.iter()) { + assert_eq!(provider.kind(), *kind); + assert_eq!(provider.id(), kind.as_str()); + assert_eq!(kind.provider().id(), kind.as_str()); + } + + let mut ids = std::collections::BTreeSet::new(); + for provider in providers { + assert!(ids.insert(provider.id()), "duplicate provider id"); + } + } + + #[test] + fn provider_metadata_lookup_does_not_fall_back_to_deepseek() { + assert!(provider::lookup_provider("not-a-provider").is_none()); + assert!(provider::resolve_provider("not-a-provider").is_none()); + assert!(provider::lookup_provider("deepseek-cn").is_none()); + assert_eq!( + provider::resolve_provider("deepseek-cn") + .expect("legacy alias resolves") + .kind(), + ProviderKind::Deepseek + ); + } + + #[test] + fn provider_metadata_preserves_alias_and_config_key_semantics() { + assert_eq!( + provider::resolve_provider("open_router") + .expect("openrouter alias") + .kind(), + ProviderKind::Openrouter + ); + assert_eq!( + provider::resolve_provider("xiaomi") + .expect("xiaomi alias") + .kind(), + ProviderKind::XiaomiMimo + ); + assert_eq!( + provider::resolve_provider("kimi") + .expect("kimi alias") + .kind(), + ProviderKind::Moonshot + ); + assert_eq!( + provider::resolve_provider("hf") + .expect("huggingface alias") + .kind(), + ProviderKind::Huggingface + ); + + let siliconflow_cn = + provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves"); + assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN); + assert_eq!(siliconflow_cn.id(), "siliconflow-CN"); + assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow"); + + let config = ProvidersToml::default(); + let shared_table = config.for_provider(ProviderKind::SiliconflowCN); + assert!(std::ptr::eq( + shared_table, + config.for_provider(ProviderKind::Siliconflow) + )); + } + + #[test] + fn provider_metadata_defaults_match_runtime_helpers() { + for kind in ProviderKind::ALL { + let provider = kind.provider(); + assert_eq!(provider.default_model(), default_model_for_provider(kind)); + assert_eq!( + provider.default_base_url(), + default_base_url_for_provider(kind) + ); + assert!(!provider.display_name().trim().is_empty()); + assert!(!provider.env_vars().is_empty()); + assert_eq!(provider.wire(), provider::WireFormat::ChatCompletions); + } + } + #[test] fn openrouter_provider_defaults_to_canonical_endpoint_and_model() { let _lock = env_lock(); diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs new file mode 100644 index 00000000..73fb96a2 --- /dev/null +++ b/crates/config/src/provider.rs @@ -0,0 +1,363 @@ +//! Built-in provider metadata. +//! +//! This module is a metadata foundation for collapsing provider drift over +//! time. It deliberately does not mutate request bodies or choose fallback +//! providers; runtime routing remains in `ConfigToml::resolve_runtime_options`. + +use super::{ + DEFAULT_ARCEE_BASE_URL, DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL, + DEFAULT_ATLASCLOUD_MODEL, DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, + DEFAULT_FIREWORKS_BASE_URL, DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL, + DEFAULT_NOVITA_BASE_URL, DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, + DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, + DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_OPENROUTER_MODEL, DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, + DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, + DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL, + DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL, + DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, ProviderKind, +}; + +/// Wire protocol spoken by a provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WireFormat { + /// OpenAI-compatible `/v1/chat/completions` style payloads. + ChatCompletions, +} + +/// Static metadata for a built-in model provider. +pub trait Provider: Send + Sync { + /// Provider enum variant represented by this entry. + fn kind(&self) -> ProviderKind; + + /// Canonical provider identifier. + fn id(&self) -> &'static str { + self.kind().as_str() + } + + /// Human-readable provider label for UIs and diagnostics. + fn display_name(&self) -> &'static str; + + /// Default base URL used when no config/env/CLI override is present. + fn default_base_url(&self) -> &'static str; + + /// Default model used when no config/env/CLI override is present. + fn default_model(&self) -> &'static str; + + /// Environment variable candidates used for this provider's API key. + fn env_vars(&self) -> &'static [&'static str]; + + /// TOML table key under `[providers.]`. + fn provider_config_key(&self) -> &'static str; + + /// Wire format used by the provider. + fn wire(&self) -> WireFormat { + WireFormat::ChatCompletions + } +} + +macro_rules! provider { + ( + $struct_name:ident, + $kind:ident, + $display_name:literal, + $base_url:ident, + $model:ident, + [$($env_var:literal),* $(,)?], + $config_key:literal + ) => { + /// Zero-sized metadata entry for this built-in provider. + pub struct $struct_name; + + impl Provider for $struct_name { + fn kind(&self) -> ProviderKind { + ProviderKind::$kind + } + + fn display_name(&self) -> &'static str { + $display_name + } + + fn default_base_url(&self) -> &'static str { + $base_url + } + + fn default_model(&self) -> &'static str { + $model + } + + fn env_vars(&self) -> &'static [&'static str] { + &[$($env_var),*] + } + + fn provider_config_key(&self) -> &'static str { + $config_key + } + } + }; +} + +provider!( + Deepseek, + Deepseek, + "DeepSeek", + DEFAULT_DEEPSEEK_BASE_URL, + DEFAULT_DEEPSEEK_MODEL, + ["DEEPSEEK_API_KEY"], + "deepseek" +); +provider!( + NvidiaNim, + NvidiaNim, + "NVIDIA NIM", + DEFAULT_NVIDIA_NIM_BASE_URL, + DEFAULT_NVIDIA_NIM_MODEL, + ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], + "nvidia_nim" +); +provider!( + Openai, + Openai, + "OpenAI-compatible", + DEFAULT_OPENAI_BASE_URL, + DEFAULT_OPENAI_MODEL, + ["OPENAI_API_KEY"], + "openai" +); +provider!( + Atlascloud, + Atlascloud, + "AtlasCloud", + DEFAULT_ATLASCLOUD_BASE_URL, + DEFAULT_ATLASCLOUD_MODEL, + ["ATLASCLOUD_API_KEY"], + "atlascloud" +); +provider!( + WanjieArk, + WanjieArk, + "Wanjie Ark", + DEFAULT_WANJIE_ARK_BASE_URL, + DEFAULT_WANJIE_ARK_MODEL, + [ + "WANJIE_ARK_API_KEY", + "WANJIE_API_KEY", + "WANJIE_MAAS_API_KEY" + ], + "wanjie_ark" +); +provider!( + Volcengine, + Volcengine, + "Volcengine Ark", + DEFAULT_VOLCENGINE_BASE_URL, + DEFAULT_VOLCENGINE_MODEL, + [ + "VOLCENGINE_API_KEY", + "VOLCENGINE_ARK_API_KEY", + "ARK_API_KEY" + ], + "volcengine" +); +provider!( + Openrouter, + Openrouter, + "OpenRouter", + DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_OPENROUTER_MODEL, + ["OPENROUTER_API_KEY"], + "openrouter" +); +provider!( + XiaomiMimo, + XiaomiMimo, + "Xiaomi MiMo", + DEFAULT_XIAOMI_MIMO_BASE_URL, + DEFAULT_XIAOMI_MIMO_MODEL, + [ + "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + "MIMO_TOKEN_PLAN_API_KEY", + "XIAOMI_MIMO_API_KEY", + "XIAOMI_API_KEY", + "MIMO_API_KEY", + ], + "xiaomi_mimo" +); +provider!( + Novita, + Novita, + "Novita", + DEFAULT_NOVITA_BASE_URL, + DEFAULT_NOVITA_MODEL, + ["NOVITA_API_KEY"], + "novita" +); +provider!( + Fireworks, + Fireworks, + "Fireworks", + DEFAULT_FIREWORKS_BASE_URL, + DEFAULT_FIREWORKS_MODEL, + ["FIREWORKS_API_KEY"], + "fireworks" +); +provider!( + Siliconflow, + Siliconflow, + "SiliconFlow", + DEFAULT_SILICONFLOW_BASE_URL, + DEFAULT_SILICONFLOW_MODEL, + ["SILICONFLOW_API_KEY"], + "siliconflow" +); +provider!( + SiliconflowCN, + SiliconflowCN, + "SiliconFlow CN", + DEFAULT_SILICONFLOW_CN_BASE_URL, + DEFAULT_SILICONFLOW_MODEL, + ["SILICONFLOW_API_KEY"], + "siliconflow" +); +provider!( + Arcee, + Arcee, + "Arcee", + DEFAULT_ARCEE_BASE_URL, + DEFAULT_ARCEE_MODEL, + ["ARCEE_API_KEY"], + "arcee" +); +provider!( + Moonshot, + Moonshot, + "Moonshot", + DEFAULT_MOONSHOT_BASE_URL, + DEFAULT_MOONSHOT_MODEL, + ["MOONSHOT_API_KEY", "KIMI_API_KEY"], + "moonshot" +); +provider!( + Sglang, + Sglang, + "SGLang", + DEFAULT_SGLANG_BASE_URL, + DEFAULT_SGLANG_MODEL, + ["SGLANG_API_KEY"], + "sglang" +); +provider!( + Vllm, + Vllm, + "vLLM", + DEFAULT_VLLM_BASE_URL, + DEFAULT_VLLM_MODEL, + ["VLLM_API_KEY"], + "vllm" +); +provider!( + Ollama, + Ollama, + "Ollama", + DEFAULT_OLLAMA_BASE_URL, + DEFAULT_OLLAMA_MODEL, + ["OLLAMA_API_KEY"], + "ollama" +); +provider!( + Huggingface, + Huggingface, + "Hugging Face", + DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, + ["HUGGINGFACE_API_KEY", "HF_TOKEN"], + "huggingface" +); + +static DEEPSEEK: Deepseek = Deepseek; +static NVIDIA_NIM: NvidiaNim = NvidiaNim; +static OPENAI: Openai = Openai; +static ATLASCLOUD: Atlascloud = Atlascloud; +static WANJIE_ARK: WanjieArk = WanjieArk; +static VOLCENGINE: Volcengine = Volcengine; +static OPENROUTER: Openrouter = Openrouter; +static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo; +static NOVITA: Novita = Novita; +static FIREWORKS: Fireworks = Fireworks; +static SILICONFLOW: Siliconflow = Siliconflow; +static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN; +static ARCEE: Arcee = Arcee; +static MOONSHOT: Moonshot = Moonshot; +static SGLANG: Sglang = Sglang; +static VLLM: Vllm = Vllm; +static OLLAMA: Ollama = Ollama; +static HUGGINGFACE: Huggingface = Huggingface; + +static PROVIDER_REGISTRY: [&dyn Provider; 18] = [ + &DEEPSEEK, + &NVIDIA_NIM, + &OPENAI, + &ATLASCLOUD, + &WANJIE_ARK, + &VOLCENGINE, + &OPENROUTER, + &XIAOMI_MIMO, + &NOVITA, + &FIREWORKS, + &SILICONFLOW, + &SILICONFLOW_CN, + &ARCEE, + &MOONSHOT, + &SGLANG, + &VLLM, + &OLLAMA, + &HUGGINGFACE, +]; + +/// Return all built-in provider metadata entries in `ProviderKind::ALL` order. +#[must_use] +pub fn all_providers() -> &'static [&'static dyn Provider] { + &PROVIDER_REGISTRY +} + +/// Find a provider by canonical id only. +#[must_use] +pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> { + let id = id.trim(); + all_providers() + .iter() + .copied() + .find(|provider| provider.id() == id) +} + +/// Resolve a provider by canonical id or supported legacy alias. +#[must_use] +pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> { + ProviderKind::parse(id_or_alias).map(provider_for_kind) +} + +/// Return metadata for a known provider kind. +#[must_use] +pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider { + match kind { + ProviderKind::Deepseek => &DEEPSEEK, + ProviderKind::NvidiaNim => &NVIDIA_NIM, + ProviderKind::Openai => &OPENAI, + ProviderKind::Atlascloud => &ATLASCLOUD, + ProviderKind::WanjieArk => &WANJIE_ARK, + ProviderKind::Volcengine => &VOLCENGINE, + ProviderKind::Openrouter => &OPENROUTER, + ProviderKind::XiaomiMimo => &XIAOMI_MIMO, + ProviderKind::Novita => &NOVITA, + ProviderKind::Fireworks => &FIREWORKS, + ProviderKind::Siliconflow => &SILICONFLOW, + ProviderKind::SiliconflowCN => &SILICONFLOW_CN, + ProviderKind::Arcee => &ARCEE, + ProviderKind::Moonshot => &MOONSHOT, + ProviderKind::Sglang => &SGLANG, + ProviderKind::Vllm => &VLLM, + ProviderKind::Ollama => &OLLAMA, + ProviderKind::Huggingface => &HUGGINGFACE, + } +} diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 9d7fafeb..778ec00e 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -66,6 +66,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). +- Added a metadata-only `codewhale-config` provider registry with canonical + lookup, alias-aware resolution, provider defaults, config-table keys, and + API-key env candidates. Runtime routing remains unchanged and fallback + providers stay dormant; this harvests the safe provider-trait foundation from + #2479 toward #2075. Thanks @sximelon. - Added optional `[search].base_url` / `CODEWHALE_SEARCH_BASE_URL` support for DuckDuckGo-compatible private search endpoints, while keeping `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by