| 1 | //! The canonical [`ProviderKind`] enum (#3311): the set of built-in provider |
| 2 | //! kinds, their serde aliases, and identity helpers (`all`, `as_str`, `parse`, |
| 3 | //! `provider`). Extracted verbatim from `lib.rs` to separate provider identity |
| 4 | //! from config schema/loading; re-exported at the crate root so |
| 5 | //! `codewhale_config::ProviderKind` is unchanged. Behavior is identical. |
| 6 | |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | |
| 9 | use crate::provider; |
| 10 | |
| 11 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] |
| 12 | #[serde(rename_all = "kebab-case")] |
| 13 | pub enum ProviderKind { |
| 14 | #[default] |
| 15 | #[serde( |
| 16 | alias = "deepseek-cn", |
| 17 | alias = "deepseek_china", |
| 18 | alias = "deepseekcn", |
| 19 | alias = "deepseek-china" |
| 20 | )] |
| 21 | Deepseek, |
| 22 | #[serde( |
| 23 | alias = "deepseek-anthropic", |
| 24 | alias = "deepseek_anthropic", |
| 25 | alias = "deepseek-claude", |
| 26 | alias = "deepseek_claude" |
| 27 | )] |
| 28 | DeepseekAnthropic, |
| 29 | NvidiaNim, |
| 30 | #[serde(alias = "open-ai")] |
| 31 | Openai, |
| 32 | Atlascloud, |
| 33 | #[serde( |
| 34 | alias = "wanjie", |
| 35 | alias = "wanjie_ark", |
| 36 | alias = "ark-wanjie", |
| 37 | alias = "ark_wanjie", |
| 38 | alias = "wanjie-maas", |
| 39 | alias = "wanjie_maas" |
| 40 | )] |
| 41 | WanjieArk, |
| 42 | #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")] |
| 43 | Volcengine, |
| 44 | Openrouter, |
| 45 | #[serde(alias = "orca_router", alias = "orca")] |
| 46 | Orcarouter, |
| 47 | #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")] |
| 48 | XiaomiMimo, |
| 49 | #[serde(alias = "novita-ai", alias = "novita_ai")] |
| 50 | Novita, |
| 51 | #[serde(alias = "fireworks-ai", alias = "fireworks_ai")] |
| 52 | Fireworks, |
| 53 | #[serde(alias = "silicon-flow", alias = "silicon_flow")] |
| 54 | Siliconflow, |
| 55 | #[serde(alias = "arcee-ai", alias = "arcee_ai")] |
| 56 | Arcee, |
| 57 | #[serde(alias = "siliconflow-cn", alias = "siliconflow-CN")] |
| 58 | SiliconflowCN, |
| 59 | #[serde(alias = "moonshot-ai", alias = "moonshotai", alias = "moonshot_ai")] |
| 60 | Moonshot, |
| 61 | Sglang, |
| 62 | Vllm, |
| 63 | Ollama, |
| 64 | #[serde(alias = "ollama_cloud")] |
| 65 | OllamaCloud, |
| 66 | #[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")] |
| 67 | Huggingface, |
| 68 | #[serde(alias = "together-ai", alias = "together_ai", alias = "togetherai")] |
| 69 | Together, |
| 70 | #[serde(alias = "baidu-qianfan", alias = "baidu_qianfan", alias = "baidu")] |
| 71 | Qianfan, |
| 72 | #[serde( |
| 73 | alias = "openai-codex", |
| 74 | alias = "openai_codex", |
| 75 | alias = "codex", |
| 76 | alias = "chatgpt", |
| 77 | alias = "chatgpt-codex", |
| 78 | alias = "chatgpt_codex" |
| 79 | )] |
| 80 | OpenaiCodex, |
| 81 | #[serde(alias = "claude")] |
| 82 | Anthropic, |
| 83 | #[serde(alias = "open-model", alias = "open_model")] |
| 84 | Openmodel, |
| 85 | #[serde( |
| 86 | alias = "z-ai", |
| 87 | alias = "z_ai", |
| 88 | alias = "z.ai", |
| 89 | alias = "zhipu", |
| 90 | alias = "zhipuai", |
| 91 | alias = "bigmodel", |
| 92 | alias = "big-model" |
| 93 | )] |
| 94 | Zai, |
| 95 | #[serde( |
| 96 | alias = "step-fun", |
| 97 | alias = "step_fun", |
| 98 | alias = "stepfun", |
| 99 | alias = "stepflash", |
| 100 | alias = "step-flash", |
| 101 | alias = "step_flash" |
| 102 | )] |
| 103 | Stepfun, |
| 104 | #[serde(alias = "mini-max", alias = "mini_max", alias = "minimax")] |
| 105 | Minimax, |
| 106 | #[serde( |
| 107 | alias = "minimax_anthropic", |
| 108 | alias = "mini-max-anthropic", |
| 109 | alias = "mini_max_anthropic" |
| 110 | )] |
| 111 | MinimaxAnthropic, |
| 112 | #[serde(alias = "deep-infra", alias = "deep_infra")] |
| 113 | Deepinfra, |
| 114 | #[serde(alias = "sakana-ai", alias = "sakana_ai", alias = "fugu")] |
| 115 | Sakana, |
| 116 | #[serde(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")] |
| 117 | LongCat, |
| 118 | #[serde(alias = "opencode_go", alias = "opencodego")] |
| 119 | OpencodeGo, |
| 120 | #[serde( |
| 121 | alias = "opencode_zen", |
| 122 | alias = "opencodezen", |
| 123 | alias = "zen", |
| 124 | alias = "opencode" |
| 125 | )] |
| 126 | OpencodeZen, |
| 127 | #[serde( |
| 128 | alias = "meta-ai", |
| 129 | alias = "meta_ai", |
| 130 | alias = "meta-model-api", |
| 131 | alias = "meta_model_api", |
| 132 | alias = "muse", |
| 133 | alias = "muse-spark" |
| 134 | )] |
| 135 | Meta, |
| 136 | #[serde(alias = "x-ai", alias = "x_ai", alias = "grok")] |
| 137 | Xai, |
| 138 | /// Mistral AI — la Plateforme (OpenAI-compatible Chat Completions). |
| 139 | #[serde( |
| 140 | alias = "mistral-ai", |
| 141 | alias = "mistral_ai", |
| 142 | alias = "mistralai", |
| 143 | alias = "la-plateforme", |
| 144 | alias = "la_plateforme" |
| 145 | )] |
| 146 | Mistral, |
| 147 | /// Jiangsu Telecom TokenHub (OpenAI-compatible). |
| 148 | /// |
| 149 | /// An AI gateway operated by Jiangsu Telecom that speaks the OpenAI Chat |
| 150 | /// Completions wire protocol and serves a broad model catalog; each API key |
| 151 | /// may access a different subset of models. |
| 152 | #[serde( |
| 153 | alias = "telecom-js", |
| 154 | alias = "telecom_js", |
| 155 | alias = "telecomjs-cn", |
| 156 | alias = "tokenhub" |
| 157 | )] |
| 158 | Telecomjs, |
| 159 | /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions). |
| 160 | /// |
| 161 | /// Token Plan Personal and Team share the same endpoint. Both the OpenAI |
| 162 | /// and Anthropic dialects are available; select the Anthropic dialect via |
| 163 | /// `modelstudio-token-plan-anthropic`. Pay-as-you-go workspace-id templating |
| 164 | /// is out of scope for v1; use a custom provider for that plan. |
| 165 | #[serde( |
| 166 | alias = "modelstudio-token-plan", |
| 167 | alias = "modelstudio_token_plan", |
| 168 | alias = "alibaba-token-plan", |
| 169 | alias = "dashscope-token-plan" |
| 170 | )] |
| 171 | ModelstudioTokenPlan, |
| 172 | /// Alibaba Cloud Model Studio — Token Plan Anthropic-compatible endpoint. |
| 173 | #[serde( |
| 174 | alias = "modelstudio-token-plan-anthropic", |
| 175 | alias = "modelstudio_token_plan_anthropic", |
| 176 | alias = "alibaba-token-plan-anthropic" |
| 177 | )] |
| 178 | ModelstudioTokenPlanAnthropic, |
| 179 | /// Alibaba Cloud Model Studio — Coding Plan (OpenAI-compatible Chat Completions). |
| 180 | #[serde( |
| 181 | alias = "modelstudio-coding-plan", |
| 182 | alias = "modelstudio_coding_plan", |
| 183 | alias = "alibaba-coding-plan", |
| 184 | alias = "dashscope-coding-plan" |
| 185 | )] |
| 186 | ModelstudioCodingPlan, |
| 187 | /// Alibaba Cloud Model Studio — Coding Plan Anthropic-compatible endpoint. |
| 188 | #[serde( |
| 189 | alias = "modelstudio-coding-plan-anthropic", |
| 190 | alias = "modelstudio_coding_plan_anthropic", |
| 191 | alias = "alibaba-coding-plan-anthropic" |
| 192 | )] |
| 193 | ModelstudioCodingPlanAnthropic, |
| 194 | /// Legacy Antigravity configuration identity. |
| 195 | /// |
| 196 | /// Kept only so existing configuration can be read and cleared. It is not |
| 197 | /// a selectable or runnable provider; Gemini users should use [`Google`]. |
| 198 | /// |
| 199 | /// [`Google`]: Self::Google |
| 200 | #[serde(alias = "agy")] |
| 201 | Antigravity, |
| 202 | /// ModelScope — Alibaba's ModelScope inference API (OpenAI-compatible). |
| 203 | #[serde( |
| 204 | alias = "model-scope", |
| 205 | alias = "model_scope", |
| 206 | alias = "modelscope-cn", |
| 207 | alias = "modelscope_cn" |
| 208 | )] |
| 209 | Modelscope, |
| 210 | /// Google — Gemini OpenAI-compatible endpoint. Its own backend, not an |
| 211 | /// OpenAI alias: thought signatures on tool calls are captured and |
| 212 | /// replayed per Google's contract. |
| 213 | #[serde( |
| 214 | alias = "google-gemini", |
| 215 | alias = "google_gemini", |
| 216 | alias = "gemini", |
| 217 | alias = "google-ai", |
| 218 | alias = "google_ai", |
| 219 | alias = "ai-studio", |
| 220 | alias = "aistudio" |
| 221 | )] |
| 222 | Google, |
| 223 | /// Eden AI — OpenAI-compatible AI gateway (aggregator). |
| 224 | /// |
| 225 | /// Serves a broad catalog of upstream models under `provider/model` |
| 226 | /// namespaced wire ids over the OpenAI Chat Completions protocol. |
| 227 | #[serde(alias = "eden-ai", alias = "eden_ai", alias = "edenai")] |
| 228 | Edenai, |
| 229 | /// ZenMux — OpenAI-compatible AI gateway (aggregator). |
| 230 | /// |
| 231 | /// Serves ~200 upstream models under `provider/model` namespaced wire |
| 232 | /// ids over the OpenAI Chat Completions protocol at |
| 233 | /// `https://zenmux.ai/api/v1`. The `/models` catalog is keyless-readable. |
| 234 | #[serde(alias = "zen-mux", alias = "zen_mux")] |
| 235 | Zenmux, |
| 236 | /// CSDN 星图 (Starmap) — CSDN's hosted OpenAI-compatible model platform |
| 237 | /// and Coding Plan subscription. |
| 238 | /// |
| 239 | /// The plan model id `glm_for_coding` (GLM-5.2 underneath, 200k context |
| 240 | /// cap) bills against plan quota and requires the dedicated Coding Plan |
| 241 | /// key type; other marketplace model ids and general keys bill metered |
| 242 | /// through the same endpoint. |
| 243 | #[serde( |
| 244 | alias = "csdn-ai", |
| 245 | alias = "csdn_ai", |
| 246 | alias = "csdn-coding-plan", |
| 247 | alias = "csdn_coding_plan", |
| 248 | alias = "starmap" |
| 249 | )] |
| 250 | Csdn, |
| 251 | /// Concentrate — OpenAI Responses-compatible AI gateway (aggregator). |
| 252 | /// |
| 253 | /// Serves a broad catalog of upstream models over the OpenAI Responses |
| 254 | /// protocol at `/v1/responses` with a bearer Universal API key. Model ids |
| 255 | /// pass through verbatim: a plain catalog id (`gpt-5.6-sol`) lets the |
| 256 | /// gateway choose the upstream provider, `provider/model` pins one, and |
| 257 | /// `concentrate/auto` reaches the gateway's own `auto` router. Opt-in and |
| 258 | /// BYOK only: the key lives in the local secret store and Codewhale adds |
| 259 | /// no fee, no managed default, and no resale lane (see docs/PROVIDERS.md). |
| 260 | #[serde( |
| 261 | alias = "concentrate-ai", |
| 262 | alias = "concentrate_ai", |
| 263 | alias = "concentrateai" |
| 264 | )] |
| 265 | Concentrate, |
| 266 | /// Codewhale API — account-backed model access over the customer's own |
| 267 | /// connected provider keys. |
| 268 | /// |
| 269 | /// One base URL, one `cwc_key_…` account API key with the `models:infer` |
| 270 | /// scope, and a per-model wire chosen from the account's live catalog: |
| 271 | /// `GET /v1/models` returns `provider/model` rows carrying |
| 272 | /// `codewhale.protocol` (`chat-completions`, `anthropic-messages`, or |
| 273 | /// `responses`). Every protocol authenticates with `Authorization: Bearer` |
| 274 | /// — the Anthropic passthrough does **not** take `x-api-key`. |
| 275 | #[serde( |
| 276 | alias = "codewhale-api", |
| 277 | alias = "codewhale_api", |
| 278 | alias = "cw-api", |
| 279 | alias = "codewhale-cloud" |
| 280 | )] |
| 281 | Codewhale, |
| 282 | /// User-defined OpenAI-compatible endpoint (#1519). |
| 283 | /// |
| 284 | /// A single dynamic identity for arbitrary `[providers.<name>] |
| 285 | /// kind="openai-compatible"` entries. It speaks the OpenAI Chat Completions |
| 286 | /// wire protocol and carries no built-in base URL/model — the concrete |
| 287 | /// endpoint and model arrive via config (`base_url` / `model`) and the |
| 288 | /// route's `base_url_override`, never from this static descriptor. |
| 289 | Custom, |
| 290 | } |
| 291 | |
| 292 | impl ProviderKind { |
| 293 | /// Catalog / picker surface: one identity per vendor. |
| 294 | /// |
| 295 | /// Dual-wire dialect kinds (`*Anthropic`) and Model Studio plan variants |
| 296 | /// stay on the enum for serde and `provider_for_kind`, but they are not |
| 297 | /// first-class catalog rows. Plan is `mode` / base_url; dialect is |
| 298 | /// `wire = openai|anthropic` on the primary provider config. |
| 299 | pub const ALL: [Self; 46] = [ |
| 300 | Self::Deepseek, |
| 301 | Self::NvidiaNim, |
| 302 | Self::Openai, |
| 303 | Self::Atlascloud, |
| 304 | Self::WanjieArk, |
| 305 | Self::Volcengine, |
| 306 | Self::Openrouter, |
| 307 | Self::Orcarouter, |
| 308 | Self::XiaomiMimo, |
| 309 | Self::Novita, |
| 310 | Self::Fireworks, |
| 311 | Self::Siliconflow, |
| 312 | Self::Arcee, |
| 313 | Self::SiliconflowCN, |
| 314 | Self::Moonshot, |
| 315 | Self::Sglang, |
| 316 | Self::Vllm, |
| 317 | Self::Ollama, |
| 318 | Self::OllamaCloud, |
| 319 | Self::Huggingface, |
| 320 | Self::Together, |
| 321 | Self::Qianfan, |
| 322 | Self::OpenaiCodex, |
| 323 | Self::Anthropic, |
| 324 | Self::Openmodel, |
| 325 | Self::Zai, |
| 326 | Self::Stepfun, |
| 327 | Self::Minimax, |
| 328 | Self::Deepinfra, |
| 329 | Self::Sakana, |
| 330 | Self::LongCat, |
| 331 | Self::OpencodeGo, |
| 332 | Self::OpencodeZen, |
| 333 | Self::Meta, |
| 334 | Self::Xai, |
| 335 | Self::Mistral, |
| 336 | Self::Telecomjs, |
| 337 | Self::ModelstudioTokenPlan, |
| 338 | Self::Modelscope, |
| 339 | Self::Google, |
| 340 | Self::Edenai, |
| 341 | Self::Zenmux, |
| 342 | Self::Csdn, |
| 343 | Self::Concentrate, |
| 344 | Self::Codewhale, |
| 345 | Self::Custom, |
| 346 | ]; |
| 347 | |
| 348 | #[must_use] |
| 349 | pub fn all() -> &'static [Self] { |
| 350 | &Self::ALL |
| 351 | } |
| 352 | |
| 353 | #[must_use] |
| 354 | pub fn names_hint() -> String { |
| 355 | Self::all() |
| 356 | .iter() |
| 357 | .map(|provider| provider.as_str()) |
| 358 | .collect::<Vec<_>>() |
| 359 | .join(", ") |
| 360 | } |
| 361 | |
| 362 | #[must_use] |
| 363 | pub fn as_str(self) -> &'static str { |
| 364 | self.provider().id() |
| 365 | } |
| 366 | |
| 367 | #[must_use] |
| 368 | pub fn parse(value: &str) -> Option<Self> { |
| 369 | let trimmed = value.trim(); |
| 370 | // Gated through `ALL`, not the full registry: the registry still holds |
| 371 | // retired tombstones so old config deserializes, and a selection |
| 372 | // surface must never be able to resolve one back into a route. |
| 373 | Self::all().iter().copied().find(|kind| { |
| 374 | let p = kind.provider(); |
| 375 | trimmed.eq_ignore_ascii_case(p.id()) |
| 376 | || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a)) |
| 377 | }) |
| 378 | } |
| 379 | |
| 380 | /// Parse a provider identifier for **config-table identity** — the kind |
| 381 | /// used to look up credentials, model, and base URL in the user's |
| 382 | /// `[providers.*]` tables. |
| 383 | /// |
| 384 | /// [`parse`](Self::parse) is *catalog* identity: legacy dual-wire |
| 385 | /// spellings (`deepseek-anthropic`, `minimax-anthropic`, the Model Studio |
| 386 | /// plan/dialect kinds) are aliases of the vendor primary and collapse |
| 387 | /// onto it so pickers show one row per vendor. That collapse must not |
| 388 | /// decide which config table holds the user's credentials: TOML serde |
| 389 | /// keeps the legacy kind for `provider = "deepseek-anthropic"`, so env |
| 390 | /// (`CODEWHALE_PROVIDER`) and `config set provider` must resolve the same |
| 391 | /// way or the user's own named table is orphaned with the key present. |
| 392 | /// |
| 393 | /// An exact canonical-id or `provider_config_key` match across the full |
| 394 | /// registry (including legacy dialect/plan kinds) therefore wins over |
| 395 | /// alias collapse; everything else falls back to [`parse`](Self::parse). |
| 396 | /// Wire-endpoint selection is unaffected: it keys off the resolved kind's |
| 397 | /// `wire` config, not this parse. |
| 398 | #[must_use] |
| 399 | pub fn parse_config_identity(value: &str) -> Option<Self> { |
| 400 | let trimmed = value.trim(); |
| 401 | provider::all_providers() |
| 402 | .iter() |
| 403 | .find(|p| { |
| 404 | trimmed.eq_ignore_ascii_case(p.id()) |
| 405 | || trimmed.eq_ignore_ascii_case(p.provider_config_key()) |
| 406 | }) |
| 407 | .map(|p| p.kind()) |
| 408 | .or_else(|| Self::parse(trimmed)) |
| 409 | .or_else(|| Self::parse_retired_alias(trimmed)) |
| 410 | } |
| 411 | |
| 412 | /// Alias lookup restricted to registry entries that are *not* in the |
| 413 | /// selectable catalog. Catalog aliases are handled by [`parse`](Self::parse) |
| 414 | /// and always take precedence. |
| 415 | /// |
| 416 | /// Retired kinds still need to answer to their aliases here so a selection |
| 417 | /// surface can *name* the tombstone and refuse it, instead of failing to |
| 418 | /// recognize `agy` and minting a fresh `[providers.agy]` table that serde |
| 419 | /// would fold straight back onto the legacy one. |
| 420 | fn parse_retired_alias(trimmed: &str) -> Option<Self> { |
| 421 | provider::all_providers() |
| 422 | .iter() |
| 423 | .filter(|p| !Self::all().contains(&p.kind())) |
| 424 | .find(|p| p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))) |
| 425 | .map(|p| p.kind()) |
| 426 | } |
| 427 | |
| 428 | /// Canonical durable-credential slot in the local secret store. |
| 429 | /// |
| 430 | /// Most providers own a slot named after their id. Variants authenticated |
| 431 | /// by the SAME account share one slot so a single saved key (or logout) |
| 432 | /// applies to the whole family: |
| 433 | /// |
| 434 | /// - `SiliconflowCN` shares `siliconflow` (historical China-endpoint slot, |
| 435 | /// already the TUI/CLI convention). |
| 436 | /// - The four Alibaba Cloud Model Studio variants share |
| 437 | /// `modelstudio-token-plan`: one Model Studio account/key authenticates |
| 438 | /// the Token Plan and Coding Plan endpoints in both wire dialects, so |
| 439 | /// per-variant slots produced three bogus "missing key" rows whenever |
| 440 | /// one variant held the key. |
| 441 | #[must_use] |
| 442 | pub fn secret_store_slot(self) -> &'static str { |
| 443 | match self { |
| 444 | Self::SiliconflowCN => "siliconflow", |
| 445 | Self::ModelstudioTokenPlan |
| 446 | | Self::ModelstudioTokenPlanAnthropic |
| 447 | | Self::ModelstudioCodingPlan |
| 448 | | Self::ModelstudioCodingPlanAnthropic => "modelstudio-token-plan", |
| 449 | _ => self.as_str(), |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | /// Return the built-in metadata entry for this provider. |
| 454 | /// |
| 455 | /// This is a metadata foundation only; runtime routing still resolves |
| 456 | /// through [`crate::ConfigToml::resolve_runtime_options`]. |
| 457 | #[must_use] |
| 458 | pub fn provider(self) -> &'static dyn provider::Provider { |
| 459 | provider::provider_for_kind(self) |
| 460 | } |
| 461 | } |
| 462 |