graphify_extract/semantic/
provider.rs1use anyhow::{Context, Result};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum LLMProvider {
5 Anthropic,
6 OpenAI,
7 Ollama,
8 OpenAICompatible,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum AuthType {
13 ApiKey,
14 Bearer,
15}
16
17#[derive(Debug, Default)]
20pub struct LLMConfigRaw {
21 pub provider: String,
22 pub model: String,
23 pub anthropic_api_key: Option<String>,
24 pub anthropic_base_url: Option<String>,
25 pub openai_api_key: Option<String>,
26 pub openai_base_url: Option<String>,
27 pub ollama_base_url: Option<String>,
28 pub openai_compatible_api_key: Option<String>,
29 pub openai_compatible_base_url: Option<String>,
30}
31
32#[derive(Debug, Clone)]
33pub struct LLMProviderConfig {
34 pub provider: LLMProvider,
35 pub model: String,
36 pub api_key: Option<String>,
37 pub base_url: String,
38 pub auth_type: AuthType,
39}
40
41impl LLMProviderConfig {
42 const ANTHROPIC_DEFAULT_URL: &str = "https://api.anthropic.com";
43 const OPENAI_DEFAULT_URL: &str = "https://api.openai.com/v1";
44 const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434/v1";
45
46 pub fn resolve(raw: &LLMConfigRaw) -> Result<Self> {
47 let provider = match raw.provider.as_str() {
48 "anthropic" => LLMProvider::Anthropic,
49 "openai" => LLMProvider::OpenAI,
50 "ollama" => LLMProvider::Ollama,
51 "openai_compatible" => LLMProvider::OpenAICompatible,
52 other => anyhow::bail!(
53 "Unknown LLM provider: '{other}'. Supported: anthropic, openai, ollama, openai_compatible"
54 ),
55 };
56
57 if raw.model.is_empty() {
58 anyhow::bail!("LLM model is required in [llm] config");
59 }
60
61 let (api_key, base_url, auth_type) = match provider {
62 LLMProvider::Anthropic => {
63 let (key, at) = if let Some(ref k) = raw.anthropic_api_key {
64 (Some(k.clone()), AuthType::ApiKey)
65 } else if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
66 (Some(k), AuthType::ApiKey)
67 } else if let Some(token) = super::anthropic_oauth::read_claude_code_oauth_token() {
68 (Some(token), AuthType::Bearer)
69 } else {
70 (None, AuthType::ApiKey)
71 };
72 let url = raw
73 .anthropic_base_url
74 .clone()
75 .unwrap_or_else(|| Self::ANTHROPIC_DEFAULT_URL.to_string());
76 (key, url, at)
77 }
78 LLMProvider::OpenAI => {
79 let key = raw
80 .openai_api_key
81 .clone()
82 .or_else(|| std::env::var("OPENAI_API_KEY").ok());
83 let url = raw
84 .openai_base_url
85 .clone()
86 .unwrap_or_else(|| Self::OPENAI_DEFAULT_URL.to_string());
87 (key, url, AuthType::Bearer)
88 }
89 LLMProvider::Ollama => {
90 let url = raw
91 .ollama_base_url
92 .clone()
93 .unwrap_or_else(|| Self::OLLAMA_DEFAULT_URL.to_string());
94 (None, url, AuthType::Bearer)
95 }
96 LLMProvider::OpenAICompatible => {
97 let key = raw.openai_compatible_api_key.clone();
98 let url = raw.openai_compatible_base_url.clone().context(
99 "openai_compatible_base_url is required for openai_compatible provider",
100 )?;
101 (key, url, AuthType::Bearer)
102 }
103 };
104
105 Ok(LLMProviderConfig {
106 provider,
107 model: raw.model.clone(),
108 api_key,
109 base_url,
110 auth_type,
111 })
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn raw(provider: &str, model: &str) -> LLMConfigRaw {
120 LLMConfigRaw {
121 provider: provider.to_string(),
122 model: model.to_string(),
123 ..Default::default()
124 }
125 }
126
127 #[test]
128 fn resolve_anthropic_with_api_key() {
129 let r = LLMConfigRaw {
130 provider: "anthropic".into(),
131 model: "claude-sonnet-4.6".into(),
132 anthropic_api_key: Some("sk-test-key".into()),
133 ..Default::default()
134 };
135 let config = LLMProviderConfig::resolve(&r).unwrap();
136 assert_eq!(config.provider, LLMProvider::Anthropic);
137 assert_eq!(config.model, "claude-sonnet-4.6");
138 assert_eq!(config.api_key.as_deref(), Some("sk-test-key"));
139 assert_eq!(config.base_url, "https://api.anthropic.com");
140 assert_eq!(config.auth_type, AuthType::ApiKey);
141 }
142
143 #[test]
144 fn resolve_openai_with_base_url_override() {
145 let r = LLMConfigRaw {
146 provider: "openai".into(),
147 model: "gpt-4o".into(),
148 openai_api_key: Some("sk-openai-key".into()),
149 openai_base_url: Some("https://custom.api.com/v1".into()),
150 ..Default::default()
151 };
152 let config = LLMProviderConfig::resolve(&r).unwrap();
153 assert_eq!(config.provider, LLMProvider::OpenAI);
154 assert_eq!(config.base_url, "https://custom.api.com/v1");
155 assert_eq!(config.auth_type, AuthType::Bearer);
156 }
157
158 #[test]
159 fn resolve_ollama_defaults() {
160 let config = LLMProviderConfig::resolve(&raw("ollama", "llama3")).unwrap();
161 assert_eq!(config.provider, LLMProvider::Ollama);
162 assert_eq!(config.base_url, "http://localhost:11434/v1");
163 assert!(config.api_key.is_none());
164 }
165
166 #[test]
167 fn resolve_openai_compatible_requires_base_url() {
168 let result = LLMProviderConfig::resolve(&raw("openai_compatible", "my-model"));
169 assert!(result.is_err());
170 assert!(
171 result
172 .unwrap_err()
173 .to_string()
174 .contains("openai_compatible_base_url")
175 );
176 }
177
178 #[test]
179 fn resolve_openai_compatible_with_base_url() {
180 let r = LLMConfigRaw {
181 provider: "openai_compatible".into(),
182 model: "my-model".into(),
183 openai_compatible_api_key: Some("optional-key".into()),
184 openai_compatible_base_url: Some("http://localhost:8000/v1".into()),
185 ..Default::default()
186 };
187 let config = LLMProviderConfig::resolve(&r).unwrap();
188 assert_eq!(config.provider, LLMProvider::OpenAICompatible);
189 assert_eq!(config.base_url, "http://localhost:8000/v1");
190 assert_eq!(config.api_key.as_deref(), Some("optional-key"));
191 }
192
193 #[test]
194 fn reject_unknown_provider() {
195 let result = LLMProviderConfig::resolve(&raw("unknown", "model"));
196 assert!(result.is_err());
197 assert!(
198 result
199 .unwrap_err()
200 .to_string()
201 .contains("Unknown LLM provider")
202 );
203 }
204
205 #[test]
206 fn reject_empty_model() {
207 let result = LLMProviderConfig::resolve(&raw("anthropic", ""));
208 assert!(result.is_err());
209 assert!(
210 result
211 .unwrap_err()
212 .to_string()
213 .contains("model is required")
214 );
215 }
216}