Skip to main content

lean_ctx/proxy_setup/
grok.rs

1//! Grok (xAI Build CLI) dual-rail proxy wiring.
2
3use std::path::Path;
4
5use super::util::{GROK_OMITTED_NOTE, is_local_lean_ctx_url, is_proxy_reachable};
6
7/// Grok (xAI Build CLI) dual-rail proxy wiring.
8///
9/// | Auth | How Grok authenticates | lean-ctx entry | Upstream |
10/// |------|------------------------|----------------|----------|
11/// | **Subscription** (`grok login` / OIDC session in `~/.grok/auth.json`) | Bearer session token | `GROK_CLI_CHAT_PROXY_BASE_URL` → `/providers/grok-chat/v1` | `https://cli-chat-proxy.grok.com` |
12/// | **API key** (`XAI_API_KEY`) | Bearer API key | `[endpoints].models_base_url` + `GROK_MODELS_BASE_URL` → `/providers/xai/v1` | `https://api.x.ai` |
13///
14/// Docs: setting `models_base_url` forces API-key mode and drops session auth —
15/// subscription must never write that field. OIDC/subscription docs use
16/// `GROK_CLI_CHAT_PROXY_BASE_URL` and send the session Bearer to the proxy
17/// (lean-ctx forwards `Authorization` upstream).
18pub(crate) fn install_grok_env(home: &Path, port: u16, quiet: bool, force: bool) {
19    let grok_dir = home.join(".grok");
20    let mode = effective_grok_auth_mode(home, force);
21    if grok_dir.exists() && mode != GrokAuthMode::None {
22        // Seed registry providers only on the live install path. Under
23        // `--force` with no detected auth, `effective_grok_auth_mode` coerces
24        // to Subscription so the grok-chat rail is seeded (not a no-op success).
25        match mode {
26            GrokAuthMode::Subscription => {
27                ensure_proxy_provider(GROK_CHAT_PROVIDER_ID, GROK_CHAT_UPSTREAM, quiet);
28            }
29            GrokAuthMode::ApiKey => ensure_proxy_provider(XAI_PROVIDER_ID, XAI_UPSTREAM, quiet),
30            GrokAuthMode::None => {}
31        }
32    }
33    install_grok_env_at(&grok_dir, port, quiet, force, mode);
34}
35
36/// Auth mode used for install + shell exports.
37///
38/// `--force` with no detected credentials coerces to the subscription rail so
39/// provider seed and `GROK_CLI_CHAT_PROXY_BASE_URL` exports stay consistent
40/// (do not claim success while skipping both).
41pub(crate) fn effective_grok_auth_mode(home: &Path, force: bool) -> GrokAuthMode {
42    match grok_auth_mode(home) {
43        GrokAuthMode::None if force => GrokAuthMode::Subscription,
44        other => other,
45    }
46}
47
48pub(crate) fn uninstall_grok_env(home: &Path, quiet: bool) {
49    uninstall_grok_env_at(&home.join(".grok"), quiet);
50}
51
52/// True when an xAI API key is available for the API-key rail.
53pub fn xai_api_key_available() -> bool {
54    for var in ["XAI_API_KEY", "GROK_CODE_XAI_API_KEY"] {
55        if let Ok(v) = std::env::var(var)
56            && !v.trim().is_empty()
57        {
58            return true;
59        }
60    }
61    false
62}
63
64/// True when `~/.grok/auth.json` holds a session/OIDC access token (subscription).
65pub fn grok_session_auth_available(home: &Path) -> bool {
66    let path = home.join(".grok/auth.json");
67    let Ok(raw) = std::fs::read_to_string(path) else {
68        return false;
69    };
70    let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw) else {
71        return false;
72    };
73    // Shape: { "<issuer>::<id>": { "key": "...", "auth_mode": "oidc"|"...", ... }, ... }
74    doc.as_object().is_some_and(|entries| {
75        entries.values().any(|v| {
76            v.get("key")
77                .and_then(serde_json::Value::as_str)
78                .is_some_and(|k| !k.trim().is_empty())
79        })
80    })
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub(crate) enum GrokAuthMode {
85    /// Browser/OIDC session — use cli-chat-proxy rail (never models_base_url).
86    Subscription,
87    /// Pay-as-you-go API key — use models_base_url → api.x.ai.
88    ApiKey,
89    None,
90}
91
92/// Prefer subscription when a session token is present (Grok itself prefers
93/// session over `XAI_API_KEY`). Fall back to API-key rail only when no session.
94pub(crate) fn grok_auth_mode(home: &Path) -> GrokAuthMode {
95    if grok_session_auth_available(home) {
96        GrokAuthMode::Subscription
97    } else if xai_api_key_available() {
98        GrokAuthMode::ApiKey
99    } else {
100        GrokAuthMode::None
101    }
102}
103
104pub(crate) const XAI_PROVIDER_ID: &str = "xai";
105pub(crate) const XAI_UPSTREAM: &str = "https://api.x.ai";
106pub(crate) const GROK_CHAT_PROVIDER_ID: &str = "grok-chat";
107pub(crate) const GROK_CHAT_UPSTREAM: &str = "https://cli-chat-proxy.grok.com";
108
109#[derive(Debug, Clone, Copy)]
110pub(crate) enum ShellFlavor {
111    Posix,
112    Fish,
113    PowerShell,
114}
115
116pub(crate) fn grok_proxy_base_url(port: u16, mode: GrokAuthMode) -> Option<String> {
117    let base = format!("http://127.0.0.1:{port}");
118    match mode {
119        GrokAuthMode::Subscription => Some(format!("{base}/providers/{GROK_CHAT_PROVIDER_ID}/v1")),
120        GrokAuthMode::ApiKey => Some(format!("{base}/providers/{XAI_PROVIDER_ID}/v1")),
121        GrokAuthMode::None => None,
122    }
123}
124
125pub(crate) fn render_grok_shell_exports(
126    base: &str,
127    mode: GrokAuthMode,
128    flavor: ShellFlavor,
129) -> String {
130    match mode {
131        GrokAuthMode::None => format!("# {GROK_OMITTED_NOTE}"),
132        GrokAuthMode::Subscription => {
133            // Session Bearer stays on the cli-chat-proxy rail. Do NOT set
134            // GROK_MODELS_BASE_URL — that switches Grok into API-key auth.
135            let url = format!("{base}/providers/{GROK_CHAT_PROVIDER_ID}/v1");
136            match flavor {
137                ShellFlavor::Posix => {
138                    format!(r#"export GROK_CLI_CHAT_PROXY_BASE_URL="{url}""#)
139                }
140                ShellFlavor::Fish => {
141                    format!(r#"set -gx GROK_CLI_CHAT_PROXY_BASE_URL "{url}""#)
142                }
143                ShellFlavor::PowerShell => {
144                    format!(r#"$env:GROK_CLI_CHAT_PROXY_BASE_URL = "{url}""#)
145                }
146            }
147        }
148        GrokAuthMode::ApiKey => {
149            let url = format!("{base}/providers/{XAI_PROVIDER_ID}/v1");
150            match flavor {
151                ShellFlavor::Posix => format!(
152                    r#"export GROK_MODELS_BASE_URL="{url}"
153export GROK_CLI_CHAT_PROXY_BASE_URL="{url}""#
154                ),
155                ShellFlavor::Fish => format!(
156                    r#"set -gx GROK_MODELS_BASE_URL "{url}"
157set -gx GROK_CLI_CHAT_PROXY_BASE_URL "{url}""#
158                ),
159                ShellFlavor::PowerShell => format!(
160                    r#"$env:GROK_MODELS_BASE_URL = "{url}"
161$env:GROK_CLI_CHAT_PROXY_BASE_URL = "{url}""#
162                ),
163            }
164        }
165    }
166}
167
168/// Ensure lean-ctx config has a `[[proxy.providers]]` entry. Idempotent.
169/// Result of ensuring a `[[proxy.providers]]` entry matches the rail upstream.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub(super) enum ProviderEnsureAction {
172    /// Id already present with the desired `base_url` (normalized).
173    Unchanged,
174    /// No matching id — entry was appended.
175    Seeded,
176    /// Id present but `base_url` was stale; rewritten to the rail upstream.
177    Updated { previous: String },
178}
179
180/// Seed or repair a provider row so `id` maps to `base_url`.
181///
182/// When the id already exists with a different base URL (manual edit, host
183/// rename), updates it in place. Other fields (`shape`, `api_key_env`, …) are
184/// left alone. Comparison uses [`normalize_url`] (trim + strip trailing `/`).
185pub(super) fn reconcile_proxy_provider(
186    providers: &mut Vec<crate::core::config::ProviderEntry>,
187    id: &str,
188    base_url: &str,
189) -> ProviderEnsureAction {
190    use crate::core::config::{ProviderEntry, WireShape, normalize_url};
191
192    let desired = normalize_url(base_url);
193    if let Some(existing) = providers
194        .iter_mut()
195        .find(|p| p.id.trim().eq_ignore_ascii_case(id))
196    {
197        if normalize_url(&existing.base_url) == desired {
198            return ProviderEnsureAction::Unchanged;
199        }
200        let previous = existing.base_url.clone();
201        existing.base_url = desired;
202        return ProviderEnsureAction::Updated { previous };
203    }
204
205    providers.push(ProviderEntry {
206        id: id.to_string(),
207        shape: WireShape::OpenAi,
208        base_url: desired,
209        api_key_env: None, // forward caller's Bearer (session or XAI_API_KEY)
210        aws_region: None,
211        enabled: None,
212        local: None,
213    });
214    ProviderEnsureAction::Seeded
215}
216
217/// Ensure `[[proxy.providers]]` has `id` pointing at the rail `base_url`.
218///
219/// Re-running proxy enable repairs a stale/wrong base_url for a matching id
220/// (manual edit or host rename). Logs seed/update when `quiet` is false.
221pub(crate) fn ensure_proxy_provider(id: &str, base_url: &str, quiet: bool) {
222    use crate::core::config::normalize_url;
223
224    let desired = normalize_url(base_url);
225    let cfg = crate::core::config::Config::load();
226    if cfg
227        .proxy
228        .providers
229        .iter()
230        .any(|p| p.id.trim().eq_ignore_ascii_case(id) && normalize_url(&p.base_url) == desired)
231    {
232        return;
233    }
234
235    let mut action = ProviderEnsureAction::Unchanged;
236    match crate::core::config::Config::update_global(|c| {
237        action = reconcile_proxy_provider(&mut c.proxy.providers, id, &desired);
238    }) {
239        Ok(_) => {
240            if quiet {
241                return;
242            }
243            match action {
244                ProviderEnsureAction::Unchanged => {}
245                ProviderEnsureAction::Seeded => {
246                    println!("  \x1b[32m✓\x1b[0m Seeded [[proxy.providers]] id={id} → {desired}");
247                }
248                ProviderEnsureAction::Updated { previous } => {
249                    println!(
250                        "  \x1b[33m!\x1b[0m Updated [[proxy.providers]] id={id} base_url\n    was: {previous}\n    now: {desired}"
251                    );
252                }
253            }
254        }
255        Err(e) => {
256            tracing::warn!("could not ensure {id} proxy provider: {e}");
257            if !quiet {
258                eprintln!(
259                    "  \u{26a0} Could not ensure {id} provider in config.toml: {e}\n    \
260                     Fix manually:\n      [[proxy.providers]]\n      id = \"{id}\"\n      \
261                     shape = \"openai\"\n      base_url = \"{desired}\""
262                );
263            }
264        }
265    }
266}
267
268/// Testable core of [`install_grok_env`].
269pub(crate) fn install_grok_env_at(
270    grok_dir: &Path,
271    port: u16,
272    quiet: bool,
273    force: bool,
274    mode: GrokAuthMode,
275) {
276    use crate::core::config::{is_local_proxy_url, normalize_url_opt};
277
278    if !grok_dir.exists() {
279        return;
280    }
281    if mode == GrokAuthMode::None && !force {
282        if !quiet {
283            eprintln!("  \u{26a0} Grok: no session token and no XAI_API_KEY.");
284            eprintln!("    Subscription: run `grok login`, then `lean-ctx proxy enable`.");
285            eprintln!("    API key:      export XAI_API_KEY=…, then re-run proxy enable.");
286        }
287        return;
288    }
289    // force with no auth still needs a mode — prefer subscription rail if forced.
290    let mode = if mode == GrokAuthMode::None && force {
291        GrokAuthMode::Subscription
292    } else {
293        mode
294    };
295
296    if !is_proxy_reachable(port) {
297        if !quiet {
298            println!("  Skipping Grok proxy env (proxy not running on port {port})");
299        }
300        return;
301    }
302
303    let Some(proxy_url) = grok_proxy_base_url(port, mode) else {
304        return;
305    };
306    let config_path = grok_dir.join("config.toml");
307    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
308
309    match mode {
310        GrokAuthMode::Subscription => {
311            // Critical: strip any prior models_base_url we wrote in API-key mode —
312            // that field forces API-key auth and breaks subscription.
313            if grok_config_has_local_proxy_entry(&existing) {
314                let cleaned = strip_grok_proxy_entries(&existing);
315                if cleaned != existing {
316                    let _ = std::fs::write(&config_path, cleaned);
317                    if !quiet {
318                        println!(
319                            "  \x1b[32m✓\x1b[0m Grok subscription: removed [endpoints].models_base_url \
320                             (would force API-key auth)"
321                        );
322                    }
323                }
324            }
325            if !quiet {
326                println!(
327                    "  Configured Grok subscription rail: GROK_CLI_CHAT_PROXY_BASE_URL → {proxy_url}"
328                );
329                println!(
330                    "    (session Bearer forwarded to {GROK_CHAT_UPSTREAM}; shell export applied)"
331                );
332            }
333        }
334        GrokAuthMode::ApiKey => {
335            // Never clobber a custom remote models_base_url unless --force.
336            if let Some(current) = grok_models_base_url(&existing) {
337                if current == proxy_url {
338                    if !quiet {
339                        println!("  Grok API-key rail already configured");
340                    }
341                    return;
342                }
343                if !force
344                    && let Some(custom) = normalize_url_opt(&current)
345                    && !is_local_proxy_url(&custom)
346                    && !custom.contains("/providers/xai/")
347                {
348                    if !quiet {
349                        eprintln!(
350                            "  \u{26a0} Grok: kept custom models_base_url ({current}); \
351                             use `lean-ctx proxy enable --force` to override."
352                        );
353                    }
354                    return;
355                }
356            }
357
358            let updated = upsert_grok_models_base_url(&existing, &proxy_url);
359            if updated != existing {
360                if let Some(parent) = config_path.parent() {
361                    let _ = std::fs::create_dir_all(parent);
362                }
363                let _ = std::fs::write(&config_path, updated);
364                if !quiet {
365                    println!("  Configured Grok [endpoints].models_base_url → proxy ({proxy_url})");
366                }
367            }
368        }
369        GrokAuthMode::None => {}
370    }
371}
372
373pub(crate) fn uninstall_grok_env_at(grok_dir: &Path, quiet: bool) {
374    let config_path = grok_dir.join("config.toml");
375    let existing = match std::fs::read_to_string(&config_path) {
376        Ok(s) if !s.trim().is_empty() => s,
377        _ => return,
378    };
379    if !grok_config_has_local_proxy_entry(&existing) {
380        return;
381    }
382    let cleaned = strip_grok_proxy_entries(&existing);
383    let _ = std::fs::write(&config_path, cleaned);
384    if !quiet {
385        println!("  \x1b[32m✓\x1b[0m Removed Grok proxy models_base_url from ~/.grok/config.toml");
386    }
387}
388
389/// Read `[endpoints].models_base_url` from a Grok config.toml body.
390pub(crate) fn grok_models_base_url(content: &str) -> Option<String> {
391    let doc = content.parse::<toml_edit::DocumentMut>().ok()?;
392    doc.get("endpoints")?
393        .get("models_base_url")?
394        .as_str()
395        .map(String::from)
396}
397
398pub(crate) fn grok_config_has_local_proxy_entry(content: &str) -> bool {
399    grok_models_base_url(content).is_some_and(|u| {
400        is_local_lean_ctx_url(&u) && (u.contains("/providers/xai") || u.contains("127.0.0.1"))
401    })
402}
403
404/// Upsert `[endpoints].models_base_url = "..."` preserving other content.
405///
406/// Fail-closed on invalid TOML (returns `existing` unchanged). If `endpoints`
407/// exists as a non-table (scalar/array), it is replaced with a table so index
408/// assignment cannot panic.
409pub(crate) fn upsert_grok_models_base_url(existing: &str, proxy_url: &str) -> String {
410    let Ok(mut doc) = existing.parse::<toml_edit::DocumentMut>() else {
411        return existing.to_string();
412    };
413    // Scalar/array `endpoints` cannot be indexed; replace with a real table.
414    if doc
415        .get("endpoints")
416        .is_some_and(|item| !item.is_table() && !item.is_inline_table() && !item.is_none())
417    {
418        doc["endpoints"] = toml_edit::table();
419    }
420    let endpoints = doc["endpoints"].or_insert(toml_edit::table());
421    endpoints["models_base_url"] = toml_edit::value(proxy_url);
422    doc.to_string()
423}
424
425/// Remove only a local lean-ctx proxy `models_base_url` from Grok config.
426///
427/// Handles standard tables (`[endpoints]`) and inline tables
428/// (`endpoints = { ... }`). Fail-closed on invalid TOML.
429pub(crate) fn strip_grok_proxy_entries(content: &str) -> String {
430    let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
431        return content.to_string();
432    };
433    let should_strip = doc
434        .get("endpoints")
435        .and_then(|e| e.get("models_base_url"))
436        .and_then(|v| v.as_str())
437        .is_some_and(is_local_lean_ctx_url);
438    if !should_strip {
439        return content.to_string();
440    }
441    let empty = if let Some(tbl) = doc["endpoints"].as_table_mut() {
442        tbl.remove("models_base_url");
443        tbl.is_empty()
444    } else if let Some(tbl) = doc["endpoints"].as_inline_table_mut() {
445        tbl.remove("models_base_url");
446        tbl.is_empty()
447    } else {
448        return content.to_string();
449    };
450    if empty {
451        doc.remove("endpoints");
452    }
453    doc.to_string()
454}