Skip to main content

lc_providers/openai_compatible/
config.rs

1// lc-providers/src/openai_compatible/config.rs
2//! Configuration for the generic OpenAI-compatible chat client (B5, v0.22.4).
3//!
4//! Every endpoint speaking the OpenAI Chat Completions protocol is reached
5//! through one config type: hosted routers ([`GROQ_BASE_URL`],
6//! [`OPENROUTER_BASE_URL`], [`XAI_BASE_URL`]) as well as self-hosted/private
7//! deployments (vLLM, LM Studio, SGLang, Ollama's `/v1` shim, internal
8//! gateways). The model string is always free-form — new provider flagship
9//! models need no framework upgrade.
10
11use crate::error::ProviderError;
12use crate::openai::OpenAIConfig;
13use std::env;
14
15/// Generic endpoint label used when no preset is selected.
16pub(crate) const LABEL_GENERIC: &str = "openai-compatible";
17/// Groq endpoint label (surfaced in [`ProviderError`](crate::ProviderError)).
18pub(crate) const LABEL_GROQ: &str = "groq";
19/// OpenRouter endpoint label.
20pub(crate) const LABEL_OPENROUTER: &str = "openrouter";
21/// xAI endpoint label.
22pub(crate) const LABEL_XAI: &str = "xai";
23
24/// Groq OpenAI-compatible endpoint.
25pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1";
26/// OpenRouter OpenAI-compatible endpoint.
27pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
28/// xAI (Grok) OpenAI-compatible endpoint.
29pub const XAI_BASE_URL: &str = "https://api.x.ai/v1";
30
31/// Default model for the Groq preset (their long-lived production ID).
32pub const DEFAULT_GROQ_MODEL: &str = "llama-3.3-70b-versatile";
33/// Default model for the xAI preset.
34pub const DEFAULT_XAI_MODEL: &str = "grok-4";
35
36/// A **non-exhaustive** hint list of Groq production model IDs.
37///
38/// The `model` field accepts any string the endpoint serves; check the
39/// provider's docs for the current list.
40pub const GROQ_MODELS: [&str; 6] = [
41    "llama-3.3-70b-versatile",                   // general-purpose 70B
42    "llama-3.1-8b-instant",                      // fast 8B
43    "openai/gpt-oss-120b",                       // open-weight GPT-OSS 120B
44    "openai/gpt-oss-20b",                        // open-weight GPT-OSS 20B
45    "meta-llama/llama-4-scout-17b-16e-instruct", // Llama 4 Scout
46    "moonshotai/kimi-k2-instruct",               // Kimi K2
47];
48
49/// A **non-exhaustive** hint list of xAI (Grok) model IDs.
50pub const XAI_MODELS: [&str; 4] = [
51    "grok-4",      // Grok 4 flagship
52    "grok-4-fast", // Grok 4 fast tier
53    "grok-3",      // Grok 3
54    "grok-3-mini", // Grok 3 mini
55];
56
57/// Configuration for a generic OpenAI-compatible chat endpoint.
58///
59/// Construct either explicitly for a private/self-hosted endpoint
60/// ([`OpenAICompatibleConfig::new`], keyless by default — opt in with
61/// [`with_api_key`](Self::with_api_key)), or through a hosted preset
62/// ([`groq`](Self::groq) / [`openrouter`](Self::openrouter) /
63/// [`xai`](Self::xai)).
64#[derive(Clone)]
65pub struct OpenAICompatibleConfig {
66    /// Bearer token. `None` sends no `Authorization` header at all
67    /// (keyless local servers).
68    pub api_key: Option<String>,
69    /// Base URL ending at the `/v1`-style root; `/chat/completions` is appended.
70    pub base_url: String,
71    /// Free-form model id served by the endpoint.
72    pub model: String,
73    /// Sampling temperature.
74    pub temperature: Option<f32>,
75    /// Maximum number of tokens to generate.
76    pub max_tokens: Option<usize>,
77    /// Nucleus sampling probability mass.
78    pub top_p: Option<f32>,
79    /// Additional HTTP headers on every request (tenant headers, attribution).
80    pub extra_headers: Vec<(String, String)>,
81    /// Endpoint label surfaced in errors (preset name, generic by default).
82    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        // A13 parity: never leak the token through `{:?}`.
88        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    /// Config for a custom (often self-hosted/private) endpoint.
103    ///
104    /// Keyless by default — many local servers ignore auth, and no
105    /// `Authorization` header is sent until
106    /// [`with_api_key`](Self::with_api_key) is called.
107    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    /// Groq preset: `https://api.groq.com/openai/v1`.
121    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    /// OpenRouter preset: `https://openrouter.ai/api/v1`.
128    ///
129    /// `model` is a vendor-qualified id such as `"anthropic/claude-sonnet-4-5"`
130    /// or `"openai/gpt-5"`.
131    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    /// xAI (Grok) preset: `https://api.x.ai/v1`.
138    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    /// Generic config from environment variables.
145    ///
146    /// - `OPENAI_COMPATIBLE_BASE_URL` (required)
147    /// - `OPENAI_COMPATIBLE_MODEL` (required)
148    /// - `OPENAI_COMPATIBLE_API_KEY` (optional; unset ⇒ keyless local endpoint)
149    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    /// Groq preset from `GROQ_API_KEY` (+ optional `GROQ_MODEL`, `GROQ_BASE_URL`).
160    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    /// OpenRouter preset from `OPENROUTER_API_KEY` + `OPENROUTER_MODEL`.
168    ///
169    /// Optional attribution: `OPENROUTER_SITE_URL`, `OPENROUTER_SITE_NAME`.
170    pub fn openrouter_from_env() -> Result<Self, ProviderError> {
171        let key = require_env("OPENROUTER_API_KEY")?;
172        // OpenRouter has no sane default model — vendor/model is the user's choice.
173        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    /// xAI preset from `XAI_API_KEY` (+ optional `XAI_MODEL`, `XAI_BASE_URL`).
184    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    /// Sets the bearer token (enables the `Authorization` header).
192    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    /// Overrides the base URL (preset users can point at a mirror/gateway).
198    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    /// Sets the model id.
204    pub fn with_model(mut self, model: impl Into<String>) -> Self {
205        self.model = model.into();
206        self
207    }
208
209    /// Sets the temperature parameter.
210    pub fn with_temperature(mut self, temp: f32) -> Self {
211        self.temperature = Some(temp);
212        self
213    }
214
215    /// Sets the max tokens limit.
216    pub fn with_max_tokens(mut self, max: usize) -> Self {
217        self.max_tokens = Some(max);
218        self
219    }
220
221    /// Sets the `top_p` parameter.
222    pub fn with_top_p(mut self, top_p: f32) -> Self {
223        self.top_p = Some(top_p);
224        self
225    }
226
227    /// Appends one extra HTTP header sent on every request.
228    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    /// OpenRouter attribution headers (`HTTP-Referer`, `X-Title`) shown on the
234    /// router's analytics/leaderboard for this app.
235    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    /// Endpoint label used in error messages.
248    pub fn provider(&self) -> &str {
249        self.provider
250    }
251
252    /// Configured base URL.
253    pub fn base_url(&self) -> &str {
254        &self.base_url
255    }
256
257    /// Configured model id.
258    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        // Keyless endpoints must not receive a bare `Bearer ` header.
269        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
294/// Drops a trailing `/` so the appended `/chat/completions` never doubles it.
295fn 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        // Keyless local endpoint: no API key env ⇒ still valid, no auth header.
411        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        // With a key set on top of base/model, auth is enabled.
420        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}