lc_providers/openai_compatible/
config.rs1use crate::error::ProviderError;
12use crate::openai::OpenAIConfig;
13use std::env;
14
15pub(crate) const LABEL_GENERIC: &str = "openai-compatible";
17pub(crate) const LABEL_GROQ: &str = "groq";
19pub(crate) const LABEL_OPENROUTER: &str = "openrouter";
21pub(crate) const LABEL_XAI: &str = "xai";
23
24pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1";
26pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
28pub const XAI_BASE_URL: &str = "https://api.x.ai/v1";
30
31pub const DEFAULT_GROQ_MODEL: &str = "llama-3.3-70b-versatile";
33pub const DEFAULT_XAI_MODEL: &str = "grok-4";
35
36pub const GROQ_MODELS: [&str; 6] = [
41 "llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b", "openai/gpt-oss-20b", "meta-llama/llama-4-scout-17b-16e-instruct", "moonshotai/kimi-k2-instruct", ];
48
49pub const XAI_MODELS: [&str; 4] = [
51 "grok-4", "grok-4-fast", "grok-3", "grok-3-mini", ];
56
57#[derive(Clone)]
65pub struct OpenAICompatibleConfig {
66 pub api_key: Option<String>,
69 pub base_url: String,
71 pub model: String,
73 pub temperature: Option<f32>,
75 pub max_tokens: Option<usize>,
77 pub top_p: Option<f32>,
79 pub extra_headers: Vec<(String, String)>,
81 pub(crate) provider: &'static str,
83}
84
85impl std::fmt::Debug for OpenAICompatibleConfig {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.debug_struct("OpenAICompatibleConfig")
89 .field("api_key", &self.api_key.as_ref().map(|_| "***"))
90 .field("base_url", &self.base_url)
91 .field("model", &self.model)
92 .field("temperature", &self.temperature)
93 .field("max_tokens", &self.max_tokens)
94 .field("top_p", &self.top_p)
95 .field("extra_headers", &self.extra_headers)
96 .field("provider", &self.provider)
97 .finish()
98 }
99}
100
101impl OpenAICompatibleConfig {
102 pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
108 Self {
109 api_key: None,
110 base_url: normalize_base_url(base_url.into()),
111 model: model.into(),
112 temperature: None,
113 max_tokens: None,
114 top_p: None,
115 extra_headers: Vec::new(),
116 provider: LABEL_GENERIC,
117 }
118 }
119
120 pub fn groq(api_key: impl Into<String>, model: impl Into<String>) -> Self {
122 Self::new(GROQ_BASE_URL, model)
123 .with_provider(LABEL_GROQ)
124 .with_api_key(api_key)
125 }
126
127 pub fn openrouter(api_key: impl Into<String>, model: impl Into<String>) -> Self {
132 Self::new(OPENROUTER_BASE_URL, model)
133 .with_provider(LABEL_OPENROUTER)
134 .with_api_key(api_key)
135 }
136
137 pub fn xai(api_key: impl Into<String>, model: impl Into<String>) -> Self {
139 Self::new(XAI_BASE_URL, model)
140 .with_provider(LABEL_XAI)
141 .with_api_key(api_key)
142 }
143
144 pub fn from_env_result() -> Result<Self, ProviderError> {
150 let base_url = require_env("OPENAI_COMPATIBLE_BASE_URL")?;
151 let model = require_env("OPENAI_COMPATIBLE_MODEL")?;
152 let mut config = Self::new(base_url, model);
153 if let Ok(key) = env::var("OPENAI_COMPATIBLE_API_KEY") {
154 config = config.with_api_key(key);
155 }
156 Ok(config)
157 }
158
159 pub fn groq_from_env() -> Result<Self, ProviderError> {
161 let key = require_env("GROQ_API_KEY")?;
162 let base_url = env::var("GROQ_BASE_URL").unwrap_or_else(|_| GROQ_BASE_URL.to_string());
163 let model = env::var("GROQ_MODEL").unwrap_or_else(|_| DEFAULT_GROQ_MODEL.to_string());
164 Ok(Self::groq(key, model).with_base_url(base_url))
165 }
166
167 pub fn openrouter_from_env() -> Result<Self, ProviderError> {
171 let key = require_env("OPENROUTER_API_KEY")?;
172 let model = require_env("OPENROUTER_MODEL")?;
174 let mut config = Self::openrouter(key, model);
175 if let Ok(site_url) = env::var("OPENROUTER_SITE_URL") {
176 if let Ok(site_name) = env::var("OPENROUTER_SITE_NAME") {
177 config = config.with_openrouter_attribution(site_url, site_name);
178 }
179 }
180 Ok(config)
181 }
182
183 pub fn xai_from_env() -> Result<Self, ProviderError> {
185 let key = require_env("XAI_API_KEY")?;
186 let base_url = env::var("XAI_BASE_URL").unwrap_or_else(|_| XAI_BASE_URL.to_string());
187 let model = env::var("XAI_MODEL").unwrap_or_else(|_| DEFAULT_XAI_MODEL.to_string());
188 Ok(Self::xai(key, model).with_base_url(base_url))
189 }
190
191 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
193 self.api_key = Some(api_key.into());
194 self
195 }
196
197 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
199 self.base_url = normalize_base_url(base_url.into());
200 self
201 }
202
203 pub fn with_model(mut self, model: impl Into<String>) -> Self {
205 self.model = model.into();
206 self
207 }
208
209 pub fn with_temperature(mut self, temp: f32) -> Self {
211 self.temperature = Some(temp);
212 self
213 }
214
215 pub fn with_max_tokens(mut self, max: usize) -> Self {
217 self.max_tokens = Some(max);
218 self
219 }
220
221 pub fn with_top_p(mut self, top_p: f32) -> Self {
223 self.top_p = Some(top_p);
224 self
225 }
226
227 pub fn with_extra_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
229 self.extra_headers.push((name.into(), value.into()));
230 self
231 }
232
233 pub fn with_openrouter_attribution(
236 mut self,
237 site_url: impl Into<String>,
238 site_name: impl Into<String>,
239 ) -> Self {
240 self.extra_headers
241 .push(("HTTP-Referer".to_string(), site_url.into()));
242 self.extra_headers
243 .push(("X-Title".to_string(), site_name.into()));
244 self
245 }
246
247 pub fn provider(&self) -> &str {
249 self.provider
250 }
251
252 pub fn base_url(&self) -> &str {
254 &self.base_url
255 }
256
257 pub fn model(&self) -> &str {
259 &self.model
260 }
261
262 fn with_provider(mut self, provider: &'static str) -> Self {
263 self.provider = provider;
264 self
265 }
266
267 pub(crate) fn into_openai_config(self) -> OpenAIConfig {
268 let send_auth = self.api_key.is_some();
270 OpenAIConfig {
271 api_key: self.api_key.unwrap_or_default(),
272 base_url: self.base_url,
273 model: self.model,
274 temperature: self.temperature,
275 max_tokens: self.max_tokens,
276 top_p: self.top_p,
277 frequency_penalty: None,
278 presence_penalty: None,
279 streaming: false,
280 organization: None,
281 tools: None,
282 tool_choice: None,
283 response_format: None,
284 extra_headers: self.extra_headers,
285 send_auth,
286 }
287 }
288}
289
290fn require_env(key: &str) -> Result<String, ProviderError> {
291 env::var(key).map_err(|_| ProviderError::Config(format!("{key} environment variable not set")))
292}
293
294fn normalize_base_url(mut url: String) -> String {
296 while url.ends_with('/') {
297 url.pop();
298 }
299 url
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::ENV_TEST_LOCK;
306
307 fn save_set_restore<'a>(vars: &'a [(&'a str, &'a str)]) -> Vec<(&'a str, Option<String>)> {
308 let saved: Vec<_> = vars
309 .iter()
310 .map(|(k, v)| {
311 let old = env::var(k).ok();
312 env::set_var(k, v);
313 (*k, old)
314 })
315 .collect();
316 saved
317 }
318
319 fn restore(saved: Vec<(&str, Option<String>)>) {
320 for (k, old) in saved {
321 match old {
322 Some(v) => env::set_var(k, v),
323 None => env::remove_var(k),
324 }
325 }
326 }
327
328 fn remove_env<'a>(keys: &'a [&'a str]) -> Vec<(&'a str, Option<String>)> {
329 keys.iter()
330 .map(|k| {
331 let old = env::var(k).ok();
332 env::remove_var(k);
333 (*k, old)
334 })
335 .collect()
336 }
337
338 #[test]
339 fn new_is_keyless_and_trims_trailing_slash() {
340 let config = OpenAICompatibleConfig::new("http://127.0.0.1:8000/v1/", "local-model");
341 assert_eq!(config.base_url(), "http://127.0.0.1:8000/v1");
342 assert_eq!(config.model(), "local-model");
343 assert_eq!(config.provider(), LABEL_GENERIC);
344 let converted = config.into_openai_config();
345 assert!(!converted.send_auth, "keyless config sends no auth header");
346 assert!(converted.extra_headers.is_empty());
347 }
348
349 #[test]
350 fn api_key_enables_auth_and_extra_headers_are_carried() {
351 let config = OpenAICompatibleConfig::new("https://gw.example/v1", "m")
352 .with_api_key("sk-x")
353 .with_extra_header("X-Tenant", "acme");
354 let converted = config.into_openai_config();
355 assert!(converted.send_auth);
356 assert_eq!(converted.api_key, "sk-x");
357 assert_eq!(
358 converted.extra_headers,
359 vec![("X-Tenant".to_string(), "acme".to_string())]
360 );
361 }
362
363 #[test]
364 fn hosted_presets_pin_endpoint_and_label() {
365 let groq = OpenAICompatibleConfig::groq("k", "llama-3.3-70b-versatile");
366 assert_eq!(groq.base_url(), GROQ_BASE_URL);
367 assert_eq!(groq.provider(), LABEL_GROQ);
368
369 let router = OpenAICompatibleConfig::openrouter("k", "anthropic/claude-sonnet-4-5");
370 assert_eq!(router.base_url(), OPENROUTER_BASE_URL);
371 assert_eq!(router.provider(), LABEL_OPENROUTER);
372
373 let xai = OpenAICompatibleConfig::xai("k", "grok-4");
374 assert_eq!(xai.base_url(), XAI_BASE_URL);
375 assert_eq!(xai.provider(), LABEL_XAI);
376 assert!(xai.into_openai_config().send_auth);
377 }
378
379 #[test]
380 fn openrouter_attribution_headers() {
381 let config = OpenAICompatibleConfig::openrouter("k", "openai/gpt-5")
382 .with_openrouter_attribution("https://app.example", "Example App");
383 let names: Vec<_> = config
384 .extra_headers
385 .iter()
386 .map(|(k, _)| k.as_str())
387 .collect();
388 assert!(names.contains(&"HTTP-Referer"));
389 assert!(names.contains(&"X-Title"));
390 }
391
392 #[test]
393 fn from_env_generic_requires_base_url_and_model() {
394 let _lock = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
395 let saved = remove_env(&[
396 "OPENAI_COMPATIBLE_BASE_URL",
397 "OPENAI_COMPATIBLE_MODEL",
398 "OPENAI_COMPATIBLE_API_KEY",
399 ]);
400
401 let err = OpenAICompatibleConfig::from_env_result().unwrap_err();
402 assert!(err.to_string().contains("OPENAI_COMPATIBLE_BASE_URL"));
403
404 let only_base =
405 save_set_restore(&[("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:1234/v1")]);
406 let err = OpenAICompatibleConfig::from_env_result().unwrap_err();
407 assert!(err.to_string().contains("OPENAI_COMPATIBLE_MODEL"));
408 restore(only_base);
409
410 let base_and_model = save_set_restore(&[
412 ("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:1234/v1/"),
413 ("OPENAI_COMPATIBLE_MODEL", "local"),
414 ]);
415 let config = OpenAICompatibleConfig::from_env_result().unwrap();
416 assert_eq!(config.base_url(), "http://localhost:1234/v1");
417 assert!(!config.into_openai_config().send_auth);
418
419 let only_key = save_set_restore(&[("OPENAI_COMPATIBLE_API_KEY", "secret")]);
421 let config = OpenAICompatibleConfig::from_env_result().unwrap();
422 assert!(config.into_openai_config().send_auth);
423 restore(only_key);
424 restore(base_and_model);
425
426 restore(saved);
427 }
428
429 #[test]
430 fn groq_from_env_with_defaults_and_base_override() {
431 let _lock = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
432 let saved = remove_env(&["GROQ_API_KEY", "GROQ_MODEL", "GROQ_BASE_URL"]);
433
434 let err = OpenAICompatibleConfig::groq_from_env().unwrap_err();
435 assert!(err.to_string().contains("GROQ_API_KEY"));
436
437 let set = save_set_restore(&[
438 ("GROQ_API_KEY", "gsk-x"),
439 ("GROQ_BASE_URL", "https://groq.mirror.example/v1/"),
440 ]);
441 let config = OpenAICompatibleConfig::groq_from_env().unwrap();
442 assert_eq!(config.model(), DEFAULT_GROQ_MODEL);
443 assert_eq!(config.base_url(), "https://groq.mirror.example/v1");
444 assert_eq!(config.provider(), LABEL_GROQ);
445 restore(set);
446 restore(saved);
447 }
448
449 #[test]
450 fn openrouter_from_env_requires_model_and_reads_attribution() {
451 let _lock = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
452 let saved = remove_env(&[
453 "OPENROUTER_API_KEY",
454 "OPENROUTER_MODEL",
455 "OPENROUTER_SITE_URL",
456 "OPENROUTER_SITE_NAME",
457 ]);
458
459 let set = save_set_restore(&[
460 ("OPENROUTER_API_KEY", "or-x"),
461 ("OPENROUTER_MODEL", "x-ai/grok-4"),
462 ("OPENROUTER_SITE_URL", "https://app.example"),
463 ("OPENROUTER_SITE_NAME", "Example"),
464 ]);
465 let config = OpenAICompatibleConfig::openrouter_from_env().unwrap();
466 assert_eq!(config.model(), "x-ai/grok-4");
467 assert_eq!(config.extra_headers.len(), 2);
468 restore(set);
469
470 let set = save_set_restore(&[("OPENROUTER_API_KEY", "or-x")]);
471 assert!(OpenAICompatibleConfig::openrouter_from_env().is_err());
472 restore(set);
473 restore(saved);
474 }
475
476 #[test]
477 fn xai_from_env_defaults_to_grok_4() {
478 let _lock = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
479 let saved = remove_env(&["XAI_API_KEY", "XAI_MODEL", "XAI_BASE_URL"]);
480 let set = save_set_restore(&[("XAI_API_KEY", "xai-x")]);
481 let config = OpenAICompatibleConfig::xai_from_env().unwrap();
482 assert_eq!(config.model(), DEFAULT_XAI_MODEL);
483 assert_eq!(config.base_url(), XAI_BASE_URL);
484 assert_eq!(config.provider(), LABEL_XAI);
485 restore(set);
486 restore(saved);
487 }
488
489 #[test]
490 fn debug_redacts_api_key() {
491 let config = OpenAICompatibleConfig::groq("gsk-secret", "m");
492 let debug = format!("{config:?}");
493 assert!(!debug.contains("gsk-secret"));
494 assert!(debug.contains("***"));
495
496 let keyless = OpenAICompatibleConfig::new("http://localhost/v1", "m");
497 assert!(format!("{keyless:?}").contains("api_key: None"));
498 }
499}