Skip to main content

koda_core/
provider_catalog.rs

1//! Provider catalog — static metadata for every supported LLM provider.
2//!
3//! Spun out of `config.rs` in #1082 to separate two unrelated concerns:
4//!
5//! - **This file (`provider_catalog.rs`)** — static, compile-time data.
6//!   `ProviderMeta` struct + `ProviderType` enum + the `meta()` lookup
7//!   table + URL/name auto-detection. No I/O, no env-var reads, no
8//!   file-system access. Pure data.
9//!
10//! - **`config.rs`** — runtime concerns. `ModelSettings`, `AgentConfig`,
11//!   `KodaConfig`, file loading, env merging, validation. Reads
12//!   `ProviderMeta` from here but owns nothing about it.
13//!
14//! ## Adding a new provider
15//!
16//! 1. Add a variant to `ProviderType`.
17//! 2. Add the corresponding arm in `ProviderType::meta()`.
18//! 3. (Optional) Add a name alias in `from_url_or_name()` if the
19//!    provider has common alternate spellings (e.g. `claude` →
20//!    `Anthropic`).
21//! 4. (Optional) Add a URL fingerprint in `from_url_or_name()` if
22//!    the provider's base URL is auto-detectable.
23//! 5. Add a row to `koda-core/tests/snapshot_test.rs` so the
24//!    metadata gets pinned by regression test.
25//!
26//! That's the entire surface — no separate registration, no
27//! plugin system, no factory. Per `DESIGN.md § P1: Personal`,
28//! a `match` arm is the canonical extension point.
29
30use serde::Deserialize;
31
32/// Metadata for a provider — single source of truth.
33pub struct ProviderMeta {
34    /// Display name.
35    pub name: &'static str,
36    /// Default API base URL.
37    pub url: &'static str,
38    /// Default model identifier.
39    pub model: &'static str,
40    /// Environment variable for the API key.
41    pub env_key: &'static str,
42    /// Whether this provider requires an API key.
43    pub api_key: bool,
44}
45
46/// Supported LLM provider types.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum ProviderType {
50    /// OpenAI API.
51    OpenAI,
52    /// Anthropic Claude API.
53    Anthropic,
54    /// LM Studio (local, OpenAI-compatible).
55    LMStudio,
56    /// Google Gemini API.
57    Gemini,
58    /// Groq (OpenAI-compatible).
59    Groq,
60    /// Grok / xAI API.
61    Grok,
62    /// Ollama (local, OpenAI-compatible).
63    Ollama,
64    /// DeepSeek API.
65    DeepSeek,
66    /// Mistral AI API.
67    Mistral,
68    /// MiniMax API.
69    MiniMax,
70    /// OpenRouter (multi-provider gateway).
71    OpenRouter,
72    /// Together AI API.
73    Together,
74    /// Fireworks AI API.
75    Fireworks,
76    /// vLLM (local, OpenAI-compatible).
77    Vllm,
78    /// Mock provider for testing (reads KODA_MOCK_RESPONSES env var).
79    #[cfg(any(test, feature = "test-support"))]
80    Mock,
81}
82
83impl ProviderType {
84    /// Consolidated provider metadata.
85    pub fn meta(&self) -> ProviderMeta {
86        match self {
87            Self::OpenAI => ProviderMeta {
88                name: "openai",
89                url: "https://api.openai.com/v1",
90                model: "gpt-4o",
91                env_key: "OPENAI_API_KEY",
92                api_key: true,
93            },
94            Self::Anthropic => ProviderMeta {
95                name: "anthropic",
96                url: "https://api.anthropic.com",
97                model: "claude-sonnet-4-6",
98                env_key: "ANTHROPIC_API_KEY",
99                api_key: true,
100            },
101            Self::LMStudio => ProviderMeta {
102                name: "lm-studio",
103                url: "http://localhost:1234/v1",
104                model: "auto-detect",
105                env_key: "KODA_API_KEY",
106                api_key: false,
107            },
108            Self::Gemini => ProviderMeta {
109                name: "gemini",
110                url: "https://generativelanguage.googleapis.com",
111                model: "gemini-flash-latest",
112                env_key: "GEMINI_API_KEY",
113                api_key: true,
114            },
115            Self::Groq => ProviderMeta {
116                name: "groq",
117                url: "https://api.groq.com/openai/v1",
118                model: "llama-3.3-70b-versatile",
119                env_key: "GROQ_API_KEY",
120                api_key: true,
121            },
122            Self::Grok => ProviderMeta {
123                name: "grok",
124                url: "https://api.x.ai/v1",
125                model: "grok-3",
126                env_key: "XAI_API_KEY",
127                api_key: true,
128            },
129            Self::Ollama => ProviderMeta {
130                name: "ollama",
131                url: "http://localhost:11434/v1",
132                model: "auto-detect",
133                env_key: "KODA_API_KEY",
134                api_key: false,
135            },
136            Self::DeepSeek => ProviderMeta {
137                name: "deepseek",
138                url: "https://api.deepseek.com/v1",
139                model: "deepseek-chat",
140                env_key: "DEEPSEEK_API_KEY",
141                api_key: true,
142            },
143            Self::Mistral => ProviderMeta {
144                name: "mistral",
145                url: "https://api.mistral.ai/v1",
146                model: "mistral-large-latest",
147                env_key: "MISTRAL_API_KEY",
148                api_key: true,
149            },
150            Self::MiniMax => ProviderMeta {
151                name: "minimax",
152                url: "https://api.minimax.io/v1",
153                model: "minimax-text-01",
154                env_key: "MINIMAX_API_KEY",
155                api_key: true,
156            },
157            Self::OpenRouter => ProviderMeta {
158                name: "openrouter",
159                url: "https://openrouter.ai/api/v1",
160                model: "anthropic/claude-3.5-sonnet",
161                env_key: "OPENROUTER_API_KEY",
162                api_key: true,
163            },
164            Self::Together => ProviderMeta {
165                name: "together",
166                url: "https://api.together.xyz/v1",
167                model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
168                env_key: "TOGETHER_API_KEY",
169                api_key: true,
170            },
171            Self::Fireworks => ProviderMeta {
172                name: "fireworks",
173                url: "https://api.fireworks.ai/inference/v1",
174                model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
175                env_key: "FIREWORKS_API_KEY",
176                api_key: true,
177            },
178            Self::Vllm => ProviderMeta {
179                name: "vllm",
180                url: "http://localhost:8000/v1",
181                model: "auto-detect",
182                env_key: "KODA_API_KEY",
183                api_key: false,
184            },
185            #[cfg(any(test, feature = "test-support"))]
186            Self::Mock => ProviderMeta {
187                name: "mock",
188                url: "http://localhost:0",
189                model: "mock-model",
190                env_key: "KODA_API_KEY",
191                api_key: false,
192            },
193        }
194    }
195
196    /// Whether this provider requires an API key.
197    pub fn requires_api_key(&self) -> bool {
198        self.meta().api_key
199    }
200    /// Default API base URL for this provider.
201    pub fn default_base_url(&self) -> &str {
202        self.meta().url
203    }
204    /// Default model identifier for this provider.
205    pub fn default_model(&self) -> &str {
206        self.meta().model
207    }
208    /// Environment variable name for this provider's API key.
209    pub fn env_key_name(&self) -> &str {
210        self.meta().env_key
211    }
212
213    /// Detect provider type from a base URL or explicit name.
214    pub fn from_url_or_name(url: &str, name: Option<&str>) -> Self {
215        if let Some(n) = name {
216            return match n.to_lowercase().as_str() {
217                "anthropic" | "claude" => Self::Anthropic,
218                "gemini" | "google" => Self::Gemini,
219                "groq" => Self::Groq,
220                "grok" | "xai" => Self::Grok,
221                "lmstudio" | "lm-studio" => Self::LMStudio,
222                "ollama" => Self::Ollama,
223                "deepseek" => Self::DeepSeek,
224                "mistral" => Self::Mistral,
225                "minimax" => Self::MiniMax,
226                "openrouter" => Self::OpenRouter,
227                "together" => Self::Together,
228                "fireworks" => Self::Fireworks,
229                "vllm" => Self::Vllm,
230                #[cfg(any(test, feature = "test-support"))]
231                "mock" => Self::Mock,
232                _ => Self::OpenAI,
233            };
234        }
235        // Auto-detect from URL
236        let url = url.to_lowercase();
237        if url.contains("anthropic.com") {
238            Self::Anthropic
239        } else if url.contains("localhost:11434") || url.contains("127.0.0.1:11434") {
240            Self::Ollama
241        } else if url.contains("localhost:8000") || url.contains("127.0.0.1:8000") {
242            Self::Vllm
243        } else if url.contains("localhost") || url.contains("127.0.0.1") {
244            Self::LMStudio
245        } else if url.contains("generativelanguage.googleapis.com") {
246            Self::Gemini
247        } else if url.contains("groq.com") {
248            Self::Groq
249        } else if url.contains("x.ai") {
250            Self::Grok
251        } else if url.contains("deepseek.com") {
252            Self::DeepSeek
253        } else if url.contains("mistral.ai") {
254            Self::Mistral
255        } else if url.contains("minimax.chat") || url.contains("minimaxi.com") {
256            Self::MiniMax
257        } else if url.contains("openrouter.ai") {
258            Self::OpenRouter
259        } else if url.contains("together.xyz") {
260            Self::Together
261        } else if url.contains("fireworks.ai") {
262            Self::Fireworks
263        } else {
264            Self::OpenAI
265        }
266    }
267}
268
269impl std::fmt::Display for ProviderType {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        write!(f, "{}", self.meta().name)
272    }
273}