From 85f350797b646657c456befd96c4d7d9e2c0f099 Mon Sep 17 00:00:00 2001 From: Nelo Puchades Date: Wed, 15 Jul 2026 21:08:05 +0200 Subject: [PATCH 1/6] feat: add Grok SuperGrok / X Premium+ OAuth backend Add a grok cloud backend that authenticates via xAI subscription OAuth (browser PKCE or device-code), stores tokens in auth.json, refreshes automatically, and calls OpenAI-compatible chat completions at api.x.ai. Wire /login, /logout, /auth, /backend, /doctor, setup, and a Grok-specific stock system prompt so subscription traffic works without an API key. --- src/auth.rs | 6 +- src/backends.rs | 56 ++- src/commands/config_cmds.rs | 160 ++++--- src/commands/doctor.rs | 9 + src/commands/mod.rs | 6 +- src/config.rs | 70 ++- src/main.rs | 33 +- src/openai.rs | 43 +- src/setup.rs | 3 + src/xai_oauth.rs | 823 ++++++++++++++++++++++++++++++++++++ 10 files changed, 1128 insertions(+), 81 deletions(-) create mode 100644 src/xai_oauth.rs diff --git a/src/auth.rs b/src/auth.rs index a084325..0921a10 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,9 +7,9 @@ use std::path::PathBuf; /// Persistent on-disk credential store. /// /// Legacy files used `{"": ""}`. New files may also store -/// typed entries so OAuth providers (notably `openai-codex`, which uses a -/// ChatGPT/Codex subscription login rather than an API key) can live beside API -/// keys without changing existing user config. +/// typed entries so OAuth providers (`openai-codex`, `grok`, …) that use a +/// subscription login rather than an API key can live beside API keys without +/// changing existing user config. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct AuthStore { #[serde(flatten)] diff --git a/src/backends.rs b/src/backends.rs index fef3026..b0ebb39 100644 --- a/src/backends.rs +++ b/src/backends.rs @@ -11,6 +11,7 @@ pub enum BackendName { Openrouter, OpenAi, OpenAiCodex, + Grok, } impl BackendName { @@ -23,6 +24,7 @@ impl BackendName { BackendName::Openrouter => "openrouter", BackendName::OpenAi => "openai", BackendName::OpenAiCodex => "openai-codex", + BackendName::Grok => "grok", } } pub fn parse(s: &str) -> Option { @@ -34,6 +36,7 @@ impl BackendName { "openrouter" => Some(Self::Openrouter), "openai" | "open-ai" => Some(Self::OpenAi), "openai-codex" | "open-ai-codex" | "codex" | "chatgpt" => Some(Self::OpenAiCodex), + "grok" | "xai" | "xai-oauth" | "x-ai" | "supergrok" | "grok-oauth" => Some(Self::Grok), _ => None, } } @@ -46,6 +49,7 @@ impl BackendName { Self::Openrouter, Self::OpenAi, Self::OpenAiCodex, + Self::Grok, ] } /// True for backends that talk to a process on the user's machine, false @@ -55,9 +59,13 @@ impl BackendName { pub fn is_local(&self) -> bool { match self { Self::Ollama | Self::LmStudio | Self::Mlx | Self::LlamaCpp => true, - Self::Openrouter | Self::OpenAi | Self::OpenAiCodex => false, + Self::Openrouter | Self::OpenAi | Self::OpenAiCodex | Self::Grok => false, } } + + pub fn is_oauth_login(&self) -> bool { + matches!(self, Self::OpenAiCodex | Self::Grok) + } } #[derive(Debug, Clone)] @@ -151,6 +159,15 @@ pub fn backend(name: BackendName) -> BackendDescriptor { is_local: false, openrouter: OpenRouterConfig::default(), }, + BackendName::Grok => BackendDescriptor { + name, + base_url: std::env::var("XAI_BASE_URL") + .or_else(|_| std::env::var("GROK_BASE_URL")) + .unwrap_or_else(|_| "https://api.x.ai/v1".into()), + api_key: String::new(), + is_local: false, + openrouter: OpenRouterConfig::default(), + }, } } @@ -165,6 +182,11 @@ pub fn default_model(b: &BackendDescriptor, override_: Option<&str>) -> String { .unwrap_or("gpt-5.5") .to_string(); } + if matches!(b.name, BackendName::Grok) { + return crate::xai_oauth::canonical_grok_model(m) + .unwrap_or("grok-4.5") + .to_string(); + } return m.to_string(); } match b.name { @@ -180,6 +202,7 @@ pub fn default_model(b: &BackendDescriptor, override_: Option<&str>) -> String { BackendName::Openrouter => "qwen/qwen-2.5-coder-32b-instruct", BackendName::OpenAi => "gpt-4o-mini", BackendName::OpenAiCodex => "gpt-5.5", + BackendName::Grok => "grok-4.5", } .to_string() } @@ -202,6 +225,15 @@ pub fn validate(b: &BackendDescriptor) -> Result<()> { "ChatGPT/Codex login is required when BACKEND=openai-codex. Run `/login openai-codex`." )); } + if matches!(b.name, BackendName::Grok) + && crate::auth::AuthStore::load() + .get_oauth(crate::xai_oauth::PROVIDER) + .is_none() + { + return Err(anyhow!( + "Grok login is required when BACKEND=grok. Run `/login grok`." + )); + } Ok(()) } @@ -258,6 +290,28 @@ mod tests { assert!(!BackendName::Openrouter.is_local()); assert!(!BackendName::OpenAi.is_local()); assert!(!BackendName::OpenAiCodex.is_local()); + assert!(!BackendName::Grok.is_local()); + assert!(BackendName::Grok.is_oauth_login()); + } + + #[test] + fn parses_grok_aliases() { + assert_eq!(BackendName::parse("grok"), Some(BackendName::Grok)); + assert_eq!(BackendName::parse("xai"), Some(BackendName::Grok)); + assert_eq!(BackendName::parse("xai-oauth"), Some(BackendName::Grok)); + assert_eq!(BackendName::parse("supergrok"), Some(BackendName::Grok)); + assert_eq!(BackendName::Grok.as_str(), "grok"); + } + + #[test] + fn lists_grok_as_switchable_backend() { + assert!(BackendName::all().contains(&BackendName::Grok)); + } + + #[test] + fn defaults_grok_to_grok_4_5() { + let model = default_model(&descriptor(BackendName::Grok), None); + assert_eq!(model, "grok-4.5"); } #[test] diff --git a/src/commands/config_cmds.rs b/src/commands/config_cmds.rs index b5b89a5..03a5828 100644 --- a/src/commands/config_cmds.rs +++ b/src/commands/config_cmds.rs @@ -212,35 +212,49 @@ pub(super) async fn cmd_auth(args: &str) -> Result<()> { struct AppStateLoginOnly; +fn normalize_login_provider(raw: &str) -> Option<&'static str> { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "openai-codex" | "codex" | "chatgpt" => Some("openai-codex"), + "grok" | "xai" | "xai-oauth" | "x-ai" | "supergrok" | "grok-oauth" => Some("grok"), + _ => None, + } +} + pub(super) async fn cmd_login(args: &str, state: &mut impl LoginState) -> Result<()> { - let provider = if args.trim().is_empty() { - "openai-codex" - } else { - args.trim() - }; - if !matches!(provider, "openai-codex" | "codex" | "chatgpt") { + let Some(provider) = normalize_login_provider(args) else { println!( - " {RED}✗{RESET} {DIM}unknown login provider: {provider} (try: openai-codex){RESET}" + " {RED}✗{RESET} {DIM}unknown login provider: {} (try: openai-codex, grok){RESET}", + args.trim() ); return Ok(()); - } + }; - println!(" {BOLD}ChatGPT / Codex login{RESET}"); - println!( - " {DIM}This uses your ChatGPT/Codex subscription OAuth token, not OPENAI_API_KEY.{RESET}" - ); + let (title, note) = match provider { + "grok" => ( + "Grok / SuperGrok login", + "This uses your SuperGrok or X Premium+ subscription OAuth, not XAI_API_KEY.", + ), + _ => ( + "ChatGPT / Codex login", + "This uses your ChatGPT/Codex subscription OAuth token, not OPENAI_API_KEY.", + ), + }; + println!(" {BOLD}{title}{RESET}"); + println!(" {DIM}{note}{RESET}"); println!(" {DIM}1) Browser login (default){RESET}"); println!(" {DIM}2) Device-code login (headless/SSH){RESET}"); let pick = plain_read_line(format!(" {DIM}Select [1]: {RESET}")).await?; - let result = if pick.trim() == "2" || pick.trim().eq_ignore_ascii_case("device") { - crate::codex_oauth::login_and_save_device_code(state.http()).await - } else { - crate::codex_oauth::login_and_save_browser(state.http()).await + let device = pick.trim() == "2" || pick.trim().eq_ignore_ascii_case("device"); + let result = match (provider, device) { + ("grok", true) => crate::xai_oauth::login_and_save_device_code(state.http()).await, + ("grok", false) => crate::xai_oauth::login_and_save_browser(state.http()).await, + (_, true) => crate::codex_oauth::login_and_save_device_code(state.http()).await, + (_, false) => crate::codex_oauth::login_and_save_browser(state.http()).await, }; match result { Ok(path) => { println!( - " {GREEN}✓{RESET} {DIM}logged in to openai-codex; saved to {}{RESET}", + " {GREEN}✓{RESET} {DIM}logged in to {provider}; saved to {}{RESET}", path.display() ); state.after_login()?; @@ -260,7 +274,7 @@ impl LoginState for AppState { &self.http } fn after_login(&mut self) -> Result<()> { - if matches!(self.config.backend, BackendName::OpenAiCodex) { + if self.config.backend.is_oauth_login() { self.rebuild_client()?; self.resolve_model(); } @@ -279,43 +293,25 @@ impl LoginState for AppStateLoginOnly { } pub(super) fn cmd_logout(args: &str) -> Result<()> { - let provider = if args.trim().is_empty() { - "openai-codex" - } else { - args.trim() - }; - if !matches!(provider, "openai-codex" | "codex" | "chatgpt") { + let Some(provider) = normalize_login_provider(args) else { println!( - " {RED}✗{RESET} {DIM}unknown logout provider: {provider} (try: openai-codex){RESET}" + " {RED}✗{RESET} {DIM}unknown logout provider: {} (try: openai-codex, grok){RESET}", + args.trim() ); return Ok(()); - } + }; let mut store = crate::auth::AuthStore::load(); - if store.clear("openai-codex") { + if store.clear(provider) { store.save()?; - println!(" {GREEN}✓{RESET} {DIM}cleared openai-codex login{RESET}"); + println!(" {GREEN}✓{RESET} {DIM}cleared {provider} login{RESET}"); } else { - println!(" {DIM}no stored openai-codex login{RESET}"); + println!(" {DIM}no stored {provider} login{RESET}"); } Ok(()) } -fn print_auth_status() { - use crate::auth::{auth_file_path, mask_key, AuthStore, KNOWN_PROVIDERS}; - let store = AuthStore::load(); - println!(" {DIM}provider status source{RESET}"); - for (provider, env_name) in KNOWN_PROVIDERS { - let env_val = std::env::var(env_name).unwrap_or_default(); - let (display, source) = if !env_val.is_empty() { - (mask_key(&env_val), format!("env: {env_name}")) - } else if let Some(k) = store.get(provider) { - (mask_key(k), "auth file".into()) - } else { - ("(not set)".into(), "—".into()) - }; - println!(" {:<12} {:<22} {DIM}{}{RESET}", provider, display, source); - } - if let Some(oauth) = store.get_oauth("openai-codex") { +fn print_oauth_status(store: &crate::auth::AuthStore, provider: &str, login_hint: &str) { + if let Some(oauth) = store.get_oauth(provider) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -330,16 +326,32 @@ fn print_auth_status() { .as_ref() .map(|id| format!("auth file · account {id}")) .unwrap_or_else(|| "auth file".into()); - println!( - " {:<12} {:<22} {DIM}{}{RESET}", - "openai-codex", status, source - ); + println!(" {:<12} {:<22} {DIM}{}{RESET}", provider, status, source); } else { println!( - " {:<12} {:<22} {DIM}/login openai-codex{RESET}", - "openai-codex", "(not logged in)" + " {:<12} {:<22} {DIM}{login_hint}{RESET}", + provider, "(not logged in)" ); } +} + +fn print_auth_status() { + use crate::auth::{auth_file_path, mask_key, AuthStore, KNOWN_PROVIDERS}; + let store = AuthStore::load(); + println!(" {DIM}provider status source{RESET}"); + for (provider, env_name) in KNOWN_PROVIDERS { + let env_val = std::env::var(env_name).unwrap_or_default(); + let (display, source) = if !env_val.is_empty() { + (mask_key(&env_val), format!("env: {env_name}")) + } else if let Some(k) = store.get(provider) { + (mask_key(k), "auth file".into()) + } else { + ("(not set)".into(), "—".into()) + }; + println!(" {:<12} {:<22} {DIM}{}{RESET}", provider, display, source); + } + print_oauth_status(&store, "openai-codex", "/login openai-codex"); + print_oauth_status(&store, "grok", "/login grok"); if let Some(path) = auth_file_path() { println!(" {DIM}file{RESET} {}", path.display()); } @@ -531,14 +543,22 @@ pub(super) async fn cmd_backend(args: &str, state: &mut AppState) -> Result<()> ); return Ok(()); } - if !chosen.is_local() - && !matches!(chosen, BackendName::OpenAiCodex) - && backend(chosen).api_key.is_empty() + if matches!(chosen, BackendName::Grok) + && crate::auth::AuthStore::load() + .get_oauth(crate::xai_oauth::PROVIDER) + .is_none() { + println!( + " {RED}✗{RESET} {DIM}not logged in for grok. Run /login grok to sign in with SuperGrok / X Premium+.{RESET}" + ); + return Ok(()); + } + if !chosen.is_local() && !chosen.is_oauth_login() && backend(chosen).api_key.is_empty() { let env_name = match chosen { BackendName::Openrouter => "OPENROUTER_API_KEY", BackendName::OpenAi => "OPENAI_API_KEY", BackendName::OpenAiCodex => "ChatGPT login", + BackendName::Grok => "Grok login", _ => "API key", }; println!(" {RED}✗{RESET} {DIM}{env_name} not set in environment.{RESET}"); @@ -569,6 +589,15 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> { return Ok(()); }; canonical.to_string() + } else if matches!(state.config.backend, BackendName::Grok) { + let Some(canonical) = crate::xai_oauth::canonical_grok_model(args) else { + println!( + " {RED}✗{RESET} {DIM}empty model id. Try one of: {}{RESET}", + crate::xai_oauth::grok_model_list().join(", ") + ); + return Ok(()); + }; + canonical.to_string() } else { args.to_string() }; @@ -602,14 +631,23 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> { println!(" {DIM}No models available.{RESET}"); return Ok(()); } - let prompt = format!(" {DIM}Filter (blank for all):{RESET} "); - let filter = plain_read_line(prompt).await?.trim().to_lowercase(); - let matches: Vec = if filter.is_empty() { - ids + println!( + " {DIM}{} model(s) from {}{RESET}", + ids.len(), + state.config.backend.as_str() + ); + let matches: Vec = if ids.len() > 20 { + let prompt = format!(" {DIM}Filter (blank for all):{RESET} "); + let filter = plain_read_line(prompt).await?.trim().to_lowercase(); + if filter.is_empty() { + ids + } else { + ids.into_iter() + .filter(|m| m.to_lowercase().contains(&filter)) + .collect() + } } else { - ids.into_iter() - .filter(|m| m.to_lowercase().contains(&filter)) - .collect() + ids }; let total = matches.len(); let shown: Vec = matches.into_iter().take(20).collect(); diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 0266a01..598b91d 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -69,11 +69,19 @@ async fn cmd_doctor_probe(args: &str, state: &AppState) -> Result<()> { " {RED}✗{RESET} {DIM}openai-codex login missing; run /login openai-codex{RESET}" ); } + } else if matches!(state.config.backend, BackendName::Grok) { + if crate::auth::AuthStore::load() + .get_oauth(crate::xai_oauth::PROVIDER) + .is_none() + { + println!(" {RED}✗{RESET} {DIM}grok login missing; run /login grok{RESET}"); + } } else if !state.config.backend.is_local() && state.backend.api_key.is_empty() { let env_name = match state.config.backend { BackendName::Openrouter => "OPENROUTER_API_KEY", BackendName::OpenAi => "OPENAI_API_KEY", BackendName::OpenAiCodex => "ChatGPT login", + BackendName::Grok => "Grok login", _ => "API key", }; println!(" {RED}✗{RESET} {DIM}{env_name} missing{RESET}"); @@ -961,6 +969,7 @@ fn backend_model_hint(backend_name: BackendName, model: &str) -> String { BackendName::Openrouter => "set OPENROUTER_API_KEY before using OpenRouter".into(), BackendName::OpenAi => "set OPENAI_API_KEY before using OpenAI".into(), BackendName::OpenAiCodex => "run /login openai-codex before using ChatGPT/Codex".into(), + BackendName::Grok => "run /login grok before using SuperGrok / X Premium+".into(), } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2607a2f..6b60cea 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -164,9 +164,9 @@ pub const COMMANDS: &[(&str, &str)] = &[ ), ( "/login", - "Browser/device-code login for subscription providers (openai-codex)", + "Browser/device-code login for subscription providers (openai-codex, grok)", ), - ("/logout", "Clear an OAuth login (openai-codex)"), + ("/logout", "Clear an OAuth login (openai-codex, grok)"), ( "/image", "Attach an image to the next user prompt (vision-capable models only)", @@ -189,7 +189,7 @@ pub const COMMANDS: &[(&str, &str)] = &[ ), ( "/backend", - "Switch backend (ollama, lm-studio, mlx, llamacpp, openrouter, openai, openai-codex)", + "Switch backend (ollama, lm-studio, mlx, llamacpp, openrouter, openai, openai-codex, grok)", ), ( "/model", diff --git a/src/config.rs b/src/config.rs index 6124492..e69ca30 100644 --- a/src/config.rs +++ b/src/config.rs @@ -694,6 +694,14 @@ const SYSTEM_PROMPT: &str = concat!( "Current working directory: {cwd}", ); +const GROK_SYSTEM_PROMPT: &str = concat!( + "You are Grok, built by xAI, running inside Small Harness (a terminal coding harness).\n", + "Do not claim to be Cursor, Auto, Claude, GPT, or any other product.\n", + "Available tools: {tools}.\n", + "Use a tool only when the request needs filesystem or shell access; otherwise answer directly.\n", + "Current working directory: {cwd}", +); + impl Default for AgentConfig { fn default() -> Self { Self { @@ -863,6 +871,16 @@ impl AgentConfig { self.render_system_prompt_for_tools(&self.tools) } + pub fn system_prompt_template(&self) -> &str { + if self.system_prompt.is_empty() { + return ""; + } + if matches!(self.backend, BackendName::Grok) && self.system_prompt == SYSTEM_PROMPT { + return GROK_SYSTEM_PROMPT; + } + self.system_prompt.as_str() + } + pub fn render_system_prompt_for_tools(&self, tools: &[String]) -> String { let cwd = std::env::current_dir() .map(|p| p.display().to_string()) @@ -873,10 +891,10 @@ impl AgentConfig { tools.join(", ") }; let mut prompt = self - .system_prompt + .system_prompt_template() .replace("{cwd}", &cwd) .replace("{tools}", &tool_list); - if self.mode == OperatorMode::Ship { + if self.mode == OperatorMode::Ship && !prompt.is_empty() { prompt.push_str( "\n\nShip mode:\n\ - Prefer run_tests over raw shell for test execution.\n\ @@ -1306,6 +1324,54 @@ pub fn load_config() -> AgentConfig { mod tests { use super::*; + #[test] + fn grok_backend_uses_minimal_stock_system_prompt() { + let config = AgentConfig { + backend: BackendName::Grok, + ..Default::default() + }; + assert_eq!(config.system_prompt_template(), GROK_SYSTEM_PROMPT); + let rendered = config.render_system_prompt_for_tools(&["file_read".into()]); + assert!(!rendered.contains("small open-weight LLM")); + assert!(rendered.contains("You are Grok, built by xAI")); + assert!(rendered.contains("Do not claim to be Cursor")); + assert!(rendered.contains("file_read")); + assert!(rendered.contains("Current working directory:")); + } + + #[test] + fn non_grok_backends_keep_full_stock_system_prompt() { + let config = AgentConfig::default(); + assert_eq!(config.backend, BackendName::Ollama); + assert_eq!(config.system_prompt_template(), SYSTEM_PROMPT); + let rendered = config.render_system_prompt(); + assert!(rendered.contains("small open-weight LLM")); + } + + #[test] + fn custom_system_prompt_wins_on_grok() { + let config = AgentConfig { + backend: BackendName::Grok, + system_prompt: "You are Grok in test mode.".into(), + ..Default::default() + }; + assert_eq!(config.system_prompt_template(), "You are Grok in test mode."); + assert!(config + .render_system_prompt() + .starts_with("You are Grok in test mode.")); + } + + #[test] + fn empty_system_prompt_sends_nothing_on_grok() { + let config = AgentConfig { + backend: BackendName::Grok, + system_prompt: String::new(), + ..Default::default() + }; + assert_eq!(config.system_prompt_template(), ""); + assert_eq!(config.render_system_prompt(), ""); + } + #[test] fn parses_dotenv_quotes_and_comments() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index 3733d16..6c053e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,7 @@ mod turn_checkpoint; mod turn_trace; mod update_check; mod warmup; +mod xai_oauth; use std::io::{IsTerminal, Read, Write}; @@ -653,6 +654,9 @@ async fn probe_backend( crate::backends::BackendName::OpenAiCodex => { "Run `/login openai-codex` to sign in with ChatGPT/Codex." } + crate::backends::BackendName::Grok => { + "Run `/login grok` to sign in with SuperGrok / X Premium+." + } }; Err(format!("{e}. {hint}")) } @@ -697,15 +701,25 @@ async fn main() -> anyhow::Result<()> { crate::theme::init(config.display.color, config.display.ascii); let http = crate::openai::build_http_client(); let backend_desc = config.backend_descriptor(); - let missing_codex_login = matches!(config.backend, BackendName::OpenAiCodex) - && crate::auth::AuthStore::load() - .get_oauth("openai-codex") - .is_none(); + let missing_oauth_login = config.backend.is_oauth_login() + && match config.backend { + BackendName::OpenAiCodex => crate::auth::AuthStore::load() + .get_oauth("openai-codex") + .is_none(), + BackendName::Grok => crate::auth::AuthStore::load() + .get_oauth(crate::xai_oauth::PROVIDER) + .is_none(), + _ => false, + }; if let Err(e) = validate(&backend_desc) { - if missing_codex_login { + if missing_oauth_login { + let login_cmd = match config.backend { + BackendName::Grok => "/login grok", + _ => "/login openai-codex", + }; println!(" {YELLOW}!{RESET} {DIM}{e}{RESET}"); println!( - " {DIM}Starting anyway so you can run /login openai-codex, or /backend to switch.{RESET}" + " {DIM}Starting anyway so you can run {login_cmd}, or /backend to switch.{RESET}" ); } else { eprintln!("{e}"); @@ -736,8 +750,11 @@ async fn main() -> anyhow::Result<()> { } let mut warmed_fingerprint = None; - let probe = if missing_codex_login { - Err("Run /login openai-codex to sign in with ChatGPT/Codex.".to_string()) + let probe = if missing_oauth_login { + Err(match config.backend { + BackendName::Grok => "Run /login grok to sign in with SuperGrok / X Premium+.".into(), + _ => "Run /login openai-codex to sign in with ChatGPT/Codex.".into(), + }) } else { probe_backend(&http, &backend_desc).await }; diff --git a/src/openai.rs b/src/openai.rs index 8deff62..3c6b84d 100644 --- a/src/openai.rs +++ b/src/openai.rs @@ -205,6 +205,13 @@ pub fn build_http_client() -> reqwest::Client { .expect("failed to build HTTP client") } +async fn resolve_bearer(client: &reqwest::Client, backend: &BackendDescriptor) -> Result { + if matches!(backend.name, BackendName::Grok) { + return crate::xai_oauth::access_token(client).await; + } + Ok(backend.api_key.clone()) +} + pub async fn list_models( client: &reqwest::Client, backend: &BackendDescriptor, @@ -212,6 +219,34 @@ pub async fn list_models( if matches!(backend.name, BackendName::OpenAiCodex) { return Ok(crate::codex_responses::codex_model_list()); } + if matches!(backend.name, BackendName::Grok) { + let mut ids = crate::xai_oauth::grok_model_list(); + if let Ok(token) = crate::xai_oauth::access_token(client).await { + let url = format!("{}/models", backend.base_url.trim_end_matches('/')); + if let Ok(resp) = client.get(url).bearer_auth(&token).send().await { + if resp.status().is_success() { + #[derive(Deserialize)] + struct ModelsResp { + data: Vec, + } + #[derive(Deserialize)] + struct Model { + id: String, + } + if let Ok(parsed) = resp.json::().await { + for model in parsed.data { + if !model.id.is_empty() && !ids.iter().any(|id| id == &model.id) { + ids.push(model.id); + } + } + } + } + } + } + ids.sort(); + ids.dedup(); + return Ok(ids); + } let url = format!("{}/models", backend.base_url.trim_end_matches('/')); let resp = client.get(url).bearer_auth(&backend.api_key).send().await?; if !resp.status().is_success() { @@ -243,9 +278,10 @@ pub async fn chat_oneshot( backend.base_url.trim_end_matches('/') ); let body = request_body(backend, req)?; + let bearer = resolve_bearer(client, backend).await?; let resp = client .post(url) - .bearer_auth(&backend.api_key) + .bearer_auth(bearer) .json(&body) .send() .await?; @@ -343,9 +379,10 @@ where backend.base_url.trim_end_matches('/') ); let body = request_body(backend, req)?; + let bearer = resolve_bearer(client, backend).await?; let resp = client .post(url) - .bearer_auth(&backend.api_key) + .bearer_auth(bearer) .json(&body) .send() .await?; @@ -413,7 +450,7 @@ fn apply_effort_to_request( serde_json::json!({ "effort": effort.openrouter_reasoning_effort() }), ); } - BackendName::OpenAi => { + BackendName::OpenAi | BackendName::Grok => { if let Some(value) = effort.openai_reasoning_effort() { obj.insert("reasoning_effort".into(), Value::String(value.into())); } diff --git a/src/setup.rs b/src/setup.rs index d5fc8ec..6105a97 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -439,6 +439,9 @@ fn backend_hint(backend: BackendName) -> &'static str { BackendName::OpenAiCodex => { "run `/login openai-codex` to sign in with ChatGPT/Codex subscription OAuth." } + BackendName::Grok => { + "run `/login grok` to sign in with SuperGrok / X Premium+ (browser or device-code)." + } } } diff --git a/src/xai_oauth.rs b/src/xai_oauth.rs new file mode 100644 index 0000000..eec55c9 --- /dev/null +++ b/src/xai_oauth.rs @@ -0,0 +1,823 @@ +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::auth::{auth_file_path, AuthStore, OAuthCredential}; +use crate::input::plain_read_line; + +pub const PROVIDER: &str = "grok"; +const CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; +const ISSUER: &str = "https://auth.x.ai"; +const DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration"; +const SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; +const REDIRECT_HOST: &str = "127.0.0.1"; +const PREFERRED_REDIRECT_PORT: u16 = 56121; +const REDIRECT_PATH: &str = "/callback"; +const GROK_CLI_AUTH_SCOPE_KEY: &str = "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"; +const GROK_CLI_LEGACY_SCOPE_KEY: &str = "https://accounts.x.ai/sign-in"; +pub const GROK_MODEL_LIST: &[&str] = &[ + "grok-4.5", + "grok-4.3", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-4.20-multi-agent-0309", + "grok-build-0.1", + "grok-4", + "grok-3", + "grok-3-mini", +]; + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn random_bytes() -> [u8; N] { + let mut out = [0u8; N]; + getrandom::getrandom(&mut out).expect("OS CSPRNG (getrandom) unavailable"); + out +} + +fn random_hex(bytes: usize) -> String { + random_bytes::<32>()[..bytes] + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn pkce_pair() -> (String, String) { + let verifier = URL_SAFE_NO_PAD.encode(random_bytes::<32>()); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +fn percent_encode(input: &str) -> String { + let mut out = String::new(); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn form_urlencoded(params: &[(&str, &str)]) -> String { + params + .iter() + .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v))) + .collect::>() + .join("&") +} + +fn percent_decode(input: &str) -> String { + let mut out = Vec::new(); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) { + if let Ok(value) = u8::from_str_radix(hex, 16) { + out.push(value); + i += 3; + continue; + } + } + } + out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] }); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn query_param(path: &str, name: &str) -> Option { + let raw_query = path.split_once('?').map(|(_, q)| q).unwrap_or(path); + let query = raw_query + .split_once('#') + .map(|(q, _)| q) + .unwrap_or(raw_query); + for pair in query.split('&') { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + if k == name { + return Some(percent_decode(v)); + } + } + None +} + +fn parse_authorization_input(input: &str) -> (Option, Option) { + let value = input.trim(); + if value.contains("code=") || value.contains("state=") { + (query_param(value, "code"), query_param(value, "state")) + } else if let Some((code, state)) = value.split_once('#') { + (Some(code.to_string()), Some(state.to_string())) + } else if value.is_empty() { + (None, None) + } else { + (Some(value.to_string()), None) + } +} + +#[derive(Debug, Clone)] +struct Discovery { + authorization_endpoint: String, + token_endpoint: String, + device_authorization_endpoint: String, +} + +fn validate_xai_endpoint(url: &str) -> Result { + let parsed = reqwest::Url::parse(url).context("parsing xAI OAuth endpoint")?; + if parsed.scheme() != "https" { + return Err(anyhow!( + "xAI OAuth discovery returned non-https endpoint: {url}" + )); + } + let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); + if host != "x.ai" && !host.ends_with(".x.ai") { + return Err(anyhow!( + "xAI OAuth discovery returned unexpected host: {url}" + )); + } + Ok(url.to_string()) +} + +async fn discover(client: &reqwest::Client) -> Result { + let resp = client + .get(DISCOVERY_URL) + .header("Accept", "application/json") + .send() + .await + .context("fetching xAI OIDC discovery")?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!( + "xAI OAuth discovery failed ({status}): {}", + body.trim() + )); + } + let data: Value = resp.json().await?; + let auth = data + .get("authorization_endpoint") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("xAI discovery missing authorization_endpoint"))?; + let token = data + .get("token_endpoint") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("xAI discovery missing token_endpoint"))?; + let device = data + .get("device_authorization_endpoint") + .and_then(|v| v.as_str()) + .unwrap_or("https://auth.x.ai/oauth2/device/code"); + Ok(Discovery { + authorization_endpoint: validate_xai_endpoint(auth)?, + token_endpoint: validate_xai_endpoint(token)?, + device_authorization_endpoint: validate_xai_endpoint(device)?, + }) +} + +fn authorization_url( + discovery: &Discovery, + redirect_uri: &str, + challenge: &str, + state: &str, + nonce: &str, +) -> String { + let params = [ + ("response_type", "code"), + ("client_id", CLIENT_ID), + ("redirect_uri", redirect_uri), + ("scope", SCOPE), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ("state", state), + ("nonce", nonce), + ]; + format!( + "{}?{}", + discovery.authorization_endpoint, + form_urlencoded(¶ms) + ) +} + +fn write_callback_response(mut stream: TcpStream, ok: bool, message: &str, origin: Option<&str>) { + let title = if ok { + "Grok login complete" + } else { + "Grok login failed" + }; + let body = format!( + "{}

{}

{}

", + title, title, message + ); + let status = if ok { "200 OK" } else { "400 Bad Request" }; + let mut headers = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n", + body.len() + ); + if let Some(origin) = origin { + headers.push_str(&format!( + "access-control-allow-origin: {origin}\r\naccess-control-allow-methods: GET, OPTIONS\r\naccess-control-allow-headers: Content-Type\r\naccess-control-allow-private-network: true\r\nvary: Origin\r\n" + )); + } + headers.push_str("\r\n"); + headers.push_str(&body); + let _ = stream.write_all(headers.as_bytes()); +} + +fn cors_origin(headers: &str) -> Option { + for line in headers.lines() { + let line = line.trim(); + if let Some(rest) = line + .strip_prefix("Origin:") + .or_else(|| line.strip_prefix("origin:")) + { + let origin = rest.trim(); + if origin == "https://accounts.x.ai" || origin == "https://auth.x.ai" { + return Some(origin.to_string()); + } + } + } + None +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut cmd = std::process::Command::new("open"); + #[cfg(target_os = "linux")] + let mut cmd = std::process::Command::new("xdg-open"); + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", ""]); + c + }; + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = url; + return Err(anyhow!("no browser open helper on this platform")); + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + { + cmd.arg(url); + cmd.spawn().context("opening browser")?; + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, +} + +async fn read_token_response( + resp: reqwest::Response, + operation: &str, + fallback_refresh: Option<&str>, +) -> Result { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!( + "xAI Grok token {operation} failed ({status}): {}", + body.trim() + )); + } + let token: TokenResponse = resp.json().await?; + let refresh = token + .refresh_token + .filter(|s| !s.is_empty()) + .or_else(|| fallback_refresh.map(str::to_string)) + .ok_or_else(|| anyhow!("xAI token response missing refresh_token"))?; + Ok(OAuthCredential { + credential_type: "oauth".into(), + access: token.access_token, + refresh, + expires: now_secs() + token.expires_in.unwrap_or(3600), + account_id: None, + }) +} + +async fn exchange_authorization_code( + client: &reqwest::Client, + token_endpoint: &str, + code: &str, + verifier: &str, + redirect_uri: &str, +) -> Result { + let body = form_urlencoded(&[ + ("grant_type", "authorization_code"), + ("client_id", CLIENT_ID), + ("code", code), + ("code_verifier", verifier), + ("redirect_uri", redirect_uri), + ]); + let resp = client + .post(token_endpoint) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .body(body) + .send() + .await?; + read_token_response(resp, "exchange", None).await +} + +pub async fn refresh_oauth( + client: &reqwest::Client, + refresh: &str, + token_endpoint: Option<&str>, +) -> Result { + let endpoint = match token_endpoint { + Some(url) => validate_xai_endpoint(url)?, + None => discover(client).await?.token_endpoint, + }; + let body = form_urlencoded(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh), + ("client_id", CLIENT_ID), + ]); + let resp = client + .post(endpoint) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .body(body) + .send() + .await?; + read_token_response(resp, "refresh", Some(refresh)).await +} + +fn save_oauth(credential: OAuthCredential) -> Result { + let mut store = AuthStore::load(); + store.set_oauth(PROVIDER, credential); + store.save()?; + auth_file_path().context("no auth file path") +} + +fn bind_callback_port() -> Result<(TcpListener, u16)> { + match TcpListener::bind((REDIRECT_HOST, PREFERRED_REDIRECT_PORT)) { + Ok(listener) => Ok((listener, PREFERRED_REDIRECT_PORT)), + Err(_) => { + let listener = TcpListener::bind((REDIRECT_HOST, 0)) + .context("binding ephemeral OAuth callback port")?; + let port = listener.local_addr()?.port(); + Ok((listener, port)) + } + } +} + +fn wait_for_browser_callback_with_listener(listener: TcpListener, state: String) -> Result { + loop { + let (mut stream, _) = listener.accept().context("waiting for OAuth callback")?; + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]); + let first = request.lines().next().unwrap_or_default(); + let method = first.split_whitespace().next().unwrap_or_default(); + let path = first.split_whitespace().nth(1).unwrap_or_default(); + let origin = cors_origin(&request); + + if method.eq_ignore_ascii_case("OPTIONS") { + let mut headers = "HTTP/1.1 204 No Content\r\nconnection: close\r\n".to_string(); + if let Some(ref o) = origin { + headers.push_str(&format!( + "access-control-allow-origin: {o}\r\naccess-control-allow-methods: GET, OPTIONS\r\naccess-control-allow-headers: Content-Type\r\naccess-control-allow-private-network: true\r\nvary: Origin\r\n" + )); + } + headers.push_str("\r\n"); + let _ = stream.write_all(headers.as_bytes()); + continue; + } + + if !path.starts_with(REDIRECT_PATH) { + write_callback_response( + stream, + false, + "Callback route not found.", + origin.as_deref(), + ); + return Err(anyhow!("unexpected OAuth callback path")); + } + if let Some(err) = query_param(path, "error") { + let desc = query_param(path, "error_description").unwrap_or_default(); + write_callback_response( + stream, + false, + &format!("Authorization failed: {err} {desc}"), + origin.as_deref(), + ); + return Err(anyhow!("xAI authorization failed: {err} {desc}")); + } + let got_state = query_param(path, "state"); + if got_state.as_deref() != Some(state.as_str()) { + write_callback_response(stream, false, "State mismatch.", origin.as_deref()); + return Err(anyhow!("OAuth state mismatch")); + } + let code = + query_param(path, "code").ok_or_else(|| anyhow!("OAuth callback missing code"))?; + write_callback_response( + stream, + true, + "Grok authentication completed. You can close this window.", + origin.as_deref(), + ); + return Ok(code); + } +} + +fn parse_expiry_ms(value: &Value) -> Option { + if let Some(n) = value.as_u64() { + return Some(if n > 10_000_000_000 { n / 1000 } else { n }); + } + if let Some(s) = value.as_str() { + if let Ok(n) = s.parse::() { + return Some(if n > 10_000_000_000 { n / 1000 } else { n }); + } + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + return Some(dt.timestamp().max(0) as u64); + } + } + None +} + +pub fn load_grok_cli_credentials() -> Option { + let home = std::env::var_os("HOME")?; + let path = PathBuf::from(home).join(".grok").join("auth.json"); + let text = std::fs::read_to_string(path).ok()?; + let data: Value = serde_json::from_str(&text).ok()?; + + let try_entry = |entry: &Value| -> Option { + let access = entry + .get("key") + .or_else(|| entry.get("access_token")) + .or_else(|| entry.get("token")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty())? + .to_string(); + let refresh = entry + .get("refresh_token") + .or_else(|| entry.get("refresh")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let expires = entry + .get("expires_at") + .or_else(|| entry.get("expires")) + .and_then(parse_expiry_ms) + .unwrap_or_else(|| now_secs() + 3600); + Some(OAuthCredential { + credential_type: "oauth".into(), + access, + refresh, + expires, + account_id: entry + .get("user_id") + .or_else(|| entry.get("principal_id")) + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + }; + + if let Some(entry) = data.get(GROK_CLI_AUTH_SCOPE_KEY) { + if let Some(cred) = try_entry(entry) { + return Some(cred); + } + } + if let Some(entry) = data.get(GROK_CLI_LEGACY_SCOPE_KEY) { + if let Some(cred) = try_entry(entry) { + return Some(cred); + } + } + try_entry(&data) +} + +async fn maybe_import_grok_cli(client: &reqwest::Client) -> Result> { + let Some(existing) = load_grok_cli_credentials() else { + return Ok(None); + }; + println!(" Found existing Grok CLI credentials in ~/.grok/auth.json."); + let pick = plain_read_line(" Use them instead of a new OAuth login? [Y/n]: ".into()).await?; + let trimmed = pick.trim().to_lowercase(); + if trimmed.is_empty() || trimmed == "y" || trimmed == "yes" { + if existing.expires <= now_secs() + 60 { + if existing.refresh.is_empty() { + println!(" Stored Grok CLI token is expired and has no refresh token."); + return Ok(None); + } + match refresh_oauth( + client, + &existing.refresh, + Some(&format!("{ISSUER}/oauth2/token")), + ) + .await + { + Ok(refreshed) => return Ok(Some(refreshed)), + Err(e) => { + println!( + " Could not refresh Grok CLI credentials ({e}); starting fresh login." + ); + return Ok(None); + } + } + } + return Ok(Some(existing)); + } + Ok(None) +} + +pub async fn login_browser(client: &reqwest::Client) -> Result { + if let Some(imported) = maybe_import_grok_cli(client).await? { + return Ok(imported); + } + + let discovery = discover(client).await?; + let (verifier, challenge) = pkce_pair(); + let state = random_hex(16); + let nonce = random_hex(16); + let (listener, port) = bind_callback_port()?; + let redirect_uri = format!("http://{REDIRECT_HOST}:{port}{REDIRECT_PATH}"); + let url = authorization_url(&discovery, &redirect_uri, &challenge, &state, &nonce); + + println!(" Open this URL to sign in with Grok / SuperGrok:\n\n {url}\n"); + if let Err(e) = open_browser(&url) { + println!(" Browser did not open automatically: {e}"); + } + println!(" Waiting for callback on {redirect_uri} ..."); + println!( + " (If the browser cannot reach localhost, cancel and paste the redirect URL or code.)" + ); + + let state_for_thread = state.clone(); + let callback = tokio::task::spawn_blocking(move || { + wait_for_browser_callback_with_listener(listener, state_for_thread) + }) + .await + .context("joining OAuth callback task")?; + let code = match callback { + Ok(code) => code, + Err(e) => { + println!(" Callback failed: {e}"); + let input = plain_read_line( + " Paste the authorization code or full redirect URL (blank to cancel): ".into(), + ) + .await?; + let (code, got_state) = parse_authorization_input(&input); + if let Some(got_state) = got_state { + if got_state != state { + return Err(anyhow!("OAuth state mismatch")); + } + } + code.ok_or_else(|| anyhow!("missing authorization code"))? + } + }; + + exchange_authorization_code( + client, + &discovery.token_endpoint, + &code, + &verifier, + &redirect_uri, + ) + .await +} + +#[derive(Debug, Deserialize)] +struct DeviceStartResponse { + device_code: String, + user_code: String, + verification_uri: String, + #[serde(default)] + verification_uri_complete: Option, + #[serde(default)] + expires_in: Option, + #[serde(default)] + interval: Option, +} + +pub async fn login_device_code(client: &reqwest::Client) -> Result { + if let Some(imported) = maybe_import_grok_cli(client).await? { + return Ok(imported); + } + + let discovery = discover(client).await?; + let body = form_urlencoded(&[("client_id", CLIENT_ID), ("scope", SCOPE)]); + let resp = client + .post(&discovery.device_authorization_endpoint) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .body(body) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!( + "xAI device-code request failed ({status}): {}", + body.trim() + )); + } + let device: DeviceStartResponse = resp.json().await?; + let interval = device.interval.unwrap_or(5).max(1); + let expires_in = device.expires_in.unwrap_or(1800); + let open_url = device + .verification_uri_complete + .as_deref() + .unwrap_or(device.verification_uri.as_str()); + + println!(" Open: {open_url}"); + println!(" Code: {}", device.user_code); + if let Err(e) = open_browser(open_url) { + println!(" Browser did not open automatically: {e}"); + println!( + " Open {} and enter code {}", + device.verification_uri, device.user_code + ); + } + println!(" Waiting for approval ..."); + + let deadline = now_secs() + expires_in; + let mut sleep_secs = interval; + loop { + if now_secs() > deadline { + return Err(anyhow!("device-code login timed out")); + } + tokio::time::sleep(Duration::from_secs(sleep_secs)).await; + let body = form_urlencoded(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", &device.device_code), + ("client_id", CLIENT_ID), + ]); + let resp = client + .post(&discovery.token_endpoint) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .body(body) + .send() + .await?; + if resp.status().is_success() { + return read_token_response(resp, "device", None).await; + } + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let err = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string)) + .unwrap_or_default(); + match err.as_str() { + "authorization_pending" | "" if status.as_u16() == 400 || status.as_u16() == 403 => { + continue; + } + "slow_down" => { + sleep_secs = sleep_secs.saturating_add(5); + continue; + } + "expired_token" | "access_denied" => { + return Err(anyhow!( + "xAI device-code login failed ({err}): {}", + body.trim() + )); + } + _ => { + if body.contains("authorization_pending") { + continue; + } + return Err(anyhow!( + "xAI device-code polling failed ({status}): {}", + body.trim() + )); + } + } + } +} + +pub async fn login_and_save_browser(client: &reqwest::Client) -> Result { + let credential = login_browser(client).await?; + save_oauth(credential) +} + +pub async fn login_and_save_device_code(client: &reqwest::Client) -> Result { + let credential = login_device_code(client).await?; + save_oauth(credential) +} + +pub async fn access_token(client: &reqwest::Client) -> Result { + let store = AuthStore::load(); + let credential = store + .get_oauth(PROVIDER) + .cloned() + .ok_or_else(|| anyhow!("not logged in for grok; run `/login grok`"))?; + let credential = if credential.expires <= now_secs() + 60 { + if credential.refresh.is_empty() { + return Err(anyhow!( + "Grok OAuth token expired with no refresh token; run `/login grok`" + )); + } + let refreshed = refresh_oauth( + client, + &credential.refresh, + Some(&format!("{ISSUER}/oauth2/token")), + ) + .await?; + let mut store = AuthStore::load(); + store.set_oauth(PROVIDER, refreshed.clone()); + store.save()?; + refreshed + } else { + credential + }; + Ok(credential.access) +} + +pub fn grok_model_list() -> Vec { + GROK_MODEL_LIST.iter().map(|s| (*s).to_string()).collect() +} + +pub fn canonical_grok_model(model: &str) -> Option<&str> { + let trimmed = model.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_ascii_lowercase(); + for id in GROK_MODEL_LIST { + if id.eq_ignore_ascii_case(&lower) { + return Some(*id); + } + } + if let Some(bare) = lower.rsplit('/').next() { + for id in GROK_MODEL_LIST { + if id.eq_ignore_ascii_case(bare) { + return Some(*id); + } + } + } + Some(trimmed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authorization_url_has_xai_oauth_params() { + let discovery = Discovery { + authorization_endpoint: "https://auth.x.ai/oauth2/authorize".into(), + token_endpoint: "https://auth.x.ai/oauth2/token".into(), + device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code".into(), + }; + let url = authorization_url( + &discovery, + "http://127.0.0.1:56121/callback", + "challenge", + "state", + "nonce", + ); + assert!(url.starts_with("https://auth.x.ai/oauth2/authorize?")); + assert!(url.contains("client_id=b1a00492-073a-47ea-816f-4c329264a828")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("scope=")); + assert!(url.contains("nonce=nonce")); + } + + #[test] + fn parses_redirect_url_input() { + let (code, state) = + parse_authorization_input("http://127.0.0.1:56121/callback?code=abc%20123&state=st"); + assert_eq!(code.as_deref(), Some("abc 123")); + assert_eq!(state.as_deref(), Some("st")); + } + + #[test] + fn validates_xai_hosts_only() { + assert!(validate_xai_endpoint("https://auth.x.ai/oauth2/token").is_ok()); + assert!(validate_xai_endpoint("https://evil.example/oauth2/token").is_err()); + assert!(validate_xai_endpoint("http://auth.x.ai/oauth2/token").is_err()); + } + + #[test] + fn canonical_model_accepts_aliases() { + assert_eq!(canonical_grok_model("grok-4.5"), Some("grok-4.5")); + assert_eq!(canonical_grok_model("xai/grok-4.5"), Some("grok-4.5")); + assert_eq!(canonical_grok_model("future-model"), Some("future-model")); + assert_eq!(canonical_grok_model(""), None); + } + + #[test] + fn parse_expiry_accepts_rfc3339() { + let v = Value::String("2026-07-15T18:03:10.175Z".into()); + let secs = parse_expiry_ms(&v).unwrap(); + assert!(secs > 1_700_000_000); + } +} From e52fd1782b7467fea2878f36ee959a4bcbbe7d30 Mon Sep 17 00:00:00 2001 From: Nelo Puchades Date: Wed, 15 Jul 2026 21:08:11 +0200 Subject: [PATCH 2/6] feat: show model and endpoint in turn footer Append the active model id and backend base URL to the per-turn status footer so it is always clear which endpoint handled the request. --- src/session_turn.rs | 69 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/session_turn.rs b/src/session_turn.rs index d53f1c6..916ee52 100644 --- a/src/session_turn.rs +++ b/src/session_turn.rs @@ -186,6 +186,8 @@ fn format_footer( path_suffix: &str, scorecard_suffix: &str, fable_suffix: &str, + model: &str, + endpoint: &str, ) -> String { let mut parts = vec![ format!("{} in", format_tokens(input_tokens)), @@ -217,6 +219,12 @@ fn format_footer( if !fable_suffix.is_empty() { parts.push(fable_suffix.to_string()); } + if !model.is_empty() { + parts.push(model.to_string()); + } + if !endpoint.is_empty() { + parts.push(endpoint.trim_end_matches('/').to_string()); + } format!("{GRAY} {}{RESET}", parts.join(" · ")) } @@ -931,6 +939,8 @@ pub async fn run_user_turn(state: &mut AppState, opts: TurnOptions) -> Result Date: Wed, 15 Jul 2026 21:08:11 +0200 Subject: [PATCH 3/6] docs: document Grok OAuth backend Document /login grok, the grok backend, default model, and env overrides in the README and Unreleased changelog. --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 43 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d39dd01..e603677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`grok` backend — SuperGrok / X Premium+ OAuth.** New cloud backend that + authenticates with xAI subscription OAuth (Authorization Code + PKCE browser + login, plus RFC 8628 device-code for headless/SSH) via `/login grok`. Access + and refresh tokens are stored in the existing `0600` `auth.json` under the + `grok` provider, refreshed automatically, and never injected into environment + variables. Requests use OpenAI-compatible chat completions at + `https://api.x.ai/v1` (override with `XAI_BASE_URL` / `GROK_BASE_URL`). + Default model is `grok-4.5`. `/login grok` can import credentials from the + official Grok CLI (`~/.grok/auth.json`) when present. Setup wizard, `/backend`, + `/doctor`, and `/auth` list the new provider. With the stock system prompt, + Grok skips the local “small open-weight LLM” persona and only receives a + short tools/cwd line so it answers as Grok; set `systemPrompt: ""` for no + system message at all. + ## [1.2.0] - 2026-07-08 ### Added diff --git a/README.md b/README.md index 7d51a08..19a5d33 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ CI Rust Version - Backends + Backends Apple Silicon License MIT

@@ -146,6 +146,20 @@ This is intentionally separate from `/auth set openai`: `openai` uses an refreshable ChatGPT OAuth token in `auth.json` and talks to the Codex Responses backend. +### Path A3 — Grok / SuperGrok subscription login + +Use a SuperGrok or X Premium+ subscription the same way OpenCode / Hermes do — +browser or headless device-code OAuth, no `XAI_API_KEY`: + +```text +/login grok +/backend grok +``` + +Pick **1) Browser login** (opens the system browser + local callback) or +**2) Device-code login** for SSH/headless. Tokens land in `auth.json` under +`grok` and refresh automatically. Default model is `grok-4.5`. + ### Path B — Local model *Private, free, offline — runs entirely on your machine.* @@ -238,12 +252,14 @@ A handful of moves worth knowing right away: | `openrouter` | `https://openrouter.ai/api/v1` | Cloud A/B with `/compare`; access to frontier models and Fusion | | `openai` | `https://api.openai.com/v1` | Direct provider access with your own key | | `openai-codex` | `https://chatgpt.com/backend-api/codex/responses` | ChatGPT/Codex subscription OAuth via `/login openai-codex` | +| `grok` | `https://api.x.ai/v1` | SuperGrok / X Premium+ OAuth via `/login grok` (browser or device-code) | Switch at runtime with `/backend `. Endpoint overrides: `OLLAMA_BASE_URL`, `LM_STUDIO_BASE_URL`, `MLX_BASE_URL`, `LLAMACPP_BASE_URL`, -`OPENAI_BASE_URL`, `OPENAI_CODEX_BASE_URL`. API backends require an API key -(set via [`/auth`](#cost-and-credentials) or env var); `openai-codex` requires -`/login openai-codex`. +`OPENAI_BASE_URL`, `OPENAI_CODEX_BASE_URL`, `XAI_BASE_URL` / `GROK_BASE_URL`. +API backends require an API key (set via [`/auth`](#cost-and-credentials) or +env var); `openai-codex` requires `/login openai-codex`; `grok` requires +`/login grok`. ### Default model per backend @@ -260,6 +276,7 @@ or `modelOverride` in your config. | `openrouter` | `qwen/qwen-2.5-coder-32b-instruct` | | `openai` | `gpt-4o-mini` | | `openai-codex` | `gpt-5.5` | +| `grok` | `grok-4.5` | ### Recommend the right model for your box @@ -360,6 +377,8 @@ this exact call`. The session cache resets on `/new`. /auth manage API keys and OAuth credentials /login openai-codex sign in with ChatGPT/Codex subscription OAuth /logout openai-codex clear the stored ChatGPT/Codex login +/login grok sign in with SuperGrok / X Premium+ OAuth +/logout grok clear the stored Grok login /image attach an image to the next user turn /reasoning on|off toggle the streaming reasoning panel /verbose on|off show every tool call with its full args + result @@ -407,6 +426,8 @@ no change in behavior. /auth clear openai remove from the file (env stays for this session) /login openai-codex browser/device-code login with ChatGPT/Codex /logout openai-codex remove the stored OAuth credential +/login grok browser/device-code login with SuperGrok / X Premium+ +/logout grok remove the stored Grok OAuth credential ``` `openai-codex` is not an `OPENAI_API_KEY` replacement. It uses browser/device @@ -414,6 +435,13 @@ OAuth, stores `{access, refresh, expires, accountId}` in the same `auth.json`, refreshes the access token before use, and sends model traffic to the Codex Responses backend. +`grok` is not an `XAI_API_KEY` replacement either. It uses the same OAuth +shape as the official Grok CLI (browser PKCE on localhost, or RFC 8628 +device-code for SSH/headless), stores tokens under the `grok` key in +`auth.json`, refreshes automatically, and calls `https://api.x.ai/v1` +chat completions. If `~/.grok/auth.json` already has a Grok CLI login, +`/login grok` can import and refresh those credentials. + ### Per-turn and session cost When you're on a cloud backend with known pricing or provider-reported usage @@ -538,8 +566,8 @@ unattended local execution. To mix subscription and API usage, put subscription-backed models on tiers where Small Harness has a real login backend, such as `openai-codex` after -`/login openai-codex`, and keep usage-billed automation on `openai`, -`openrouter`, or local backends. For Claude/Fable subscriptions, track usage +`/login openai-codex` or `grok` after `/login grok`, and keep usage-billed +automation on `openai`, `openrouter`, or local backends. For Claude/Fable subscriptions, track usage with `/fable`; direct unattended execution should stay on an API-compatible backend unless you add an explicit Claude CLI adapter. @@ -894,7 +922,7 @@ Resolution order (later overrides earlier): ### Environment variables (the useful ones) ```bash -BACKEND=ollama # ollama|lm-studio|mlx|llamacpp|openrouter|openai|openai-codex +BACKEND=ollama # ollama|lm-studio|mlx|llamacpp|openrouter|openai|openai-codex|grok AGENT_MODEL=qwen2.5-coder:14b # overrides the backend default model OPENAI_API_KEY=sk-... # required for openai @@ -1084,6 +1112,7 @@ runtime. - **OpenRouter** — set `OPENROUTER_API_KEY` (or use `/auth set openrouter`). - **OpenAI** — set `OPENAI_API_KEY` (or use `/auth set openai`). Use `OPENAI_BASE_URL` for a compatible proxy. - **OpenAI Codex** — run `/login openai-codex`, then `/backend openai-codex`. +- **Grok** — run `/login grok` (browser or device-code), then `/backend grok`. Run `/doctor --deep` for a fuller capability probe (streaming, usage chunks, native tool calls, inline JSON fallback). Reports land under `.sessions/doctor/`. From de788257cd8e99358c0b12c03e0db00b066313cb Mon Sep 17 00:00:00 2001 From: Nelo Puchades Date: Wed, 15 Jul 2026 23:21:43 +0200 Subject: [PATCH 4/6] revert: drop custom stock system prompt for grok Grok now uses the same default system prompt as every other backend. --- CHANGELOG.md | 5 +--- src/config.rs | 68 ++++++++++++++++++++------------------------------- 2 files changed, 27 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e603677..0ff971a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `https://api.x.ai/v1` (override with `XAI_BASE_URL` / `GROK_BASE_URL`). Default model is `grok-4.5`. `/login grok` can import credentials from the official Grok CLI (`~/.grok/auth.json`) when present. Setup wizard, `/backend`, - `/doctor`, and `/auth` list the new provider. With the stock system prompt, - Grok skips the local “small open-weight LLM” persona and only receives a - short tools/cwd line so it answers as Grok; set `systemPrompt: ""` for no - system message at all. + `/doctor`, and `/auth` list the new provider. ## [1.2.0] - 2026-07-08 diff --git a/src/config.rs b/src/config.rs index e69ca30..9b3e8c5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -694,14 +694,6 @@ const SYSTEM_PROMPT: &str = concat!( "Current working directory: {cwd}", ); -const GROK_SYSTEM_PROMPT: &str = concat!( - "You are Grok, built by xAI, running inside Small Harness (a terminal coding harness).\n", - "Do not claim to be Cursor, Auto, Claude, GPT, or any other product.\n", - "Available tools: {tools}.\n", - "Use a tool only when the request needs filesystem or shell access; otherwise answer directly.\n", - "Current working directory: {cwd}", -); - impl Default for AgentConfig { fn default() -> Self { Self { @@ -872,12 +864,6 @@ impl AgentConfig { } pub fn system_prompt_template(&self) -> &str { - if self.system_prompt.is_empty() { - return ""; - } - if matches!(self.backend, BackendName::Grok) && self.system_prompt == SYSTEM_PROMPT { - return GROK_SYSTEM_PROMPT; - } self.system_prompt.as_str() } @@ -1325,46 +1311,44 @@ mod tests { use super::*; #[test] - fn grok_backend_uses_minimal_stock_system_prompt() { - let config = AgentConfig { - backend: BackendName::Grok, - ..Default::default() - }; - assert_eq!(config.system_prompt_template(), GROK_SYSTEM_PROMPT); - let rendered = config.render_system_prompt_for_tools(&["file_read".into()]); - assert!(!rendered.contains("small open-weight LLM")); - assert!(rendered.contains("You are Grok, built by xAI")); - assert!(rendered.contains("Do not claim to be Cursor")); - assert!(rendered.contains("file_read")); - assert!(rendered.contains("Current working directory:")); - } - - #[test] - fn non_grok_backends_keep_full_stock_system_prompt() { - let config = AgentConfig::default(); - assert_eq!(config.backend, BackendName::Ollama); - assert_eq!(config.system_prompt_template(), SYSTEM_PROMPT); - let rendered = config.render_system_prompt(); - assert!(rendered.contains("small open-weight LLM")); + fn all_backends_share_the_same_stock_system_prompt() { + for backend in [ + BackendName::Ollama, + BackendName::LmStudio, + BackendName::Mlx, + BackendName::LlamaCpp, + BackendName::Openrouter, + BackendName::OpenAi, + BackendName::OpenAiCodex, + BackendName::Grok, + ] { + let config = AgentConfig { + backend, + ..Default::default() + }; + assert_eq!(config.system_prompt_template(), SYSTEM_PROMPT); + let rendered = config.render_system_prompt_for_tools(&["file_read".into()]); + assert!(rendered.contains("small open-weight LLM")); + assert!(rendered.contains("file_read")); + assert!(rendered.contains("Current working directory:")); + } } #[test] - fn custom_system_prompt_wins_on_grok() { + fn custom_system_prompt_is_used_as_is() { let config = AgentConfig { - backend: BackendName::Grok, - system_prompt: "You are Grok in test mode.".into(), + system_prompt: "You are a custom assistant.".into(), ..Default::default() }; - assert_eq!(config.system_prompt_template(), "You are Grok in test mode."); + assert_eq!(config.system_prompt_template(), "You are a custom assistant."); assert!(config .render_system_prompt() - .starts_with("You are Grok in test mode.")); + .starts_with("You are a custom assistant.")); } #[test] - fn empty_system_prompt_sends_nothing_on_grok() { + fn empty_system_prompt_sends_nothing() { let config = AgentConfig { - backend: BackendName::Grok, system_prompt: String::new(), ..Default::default() }; From 926cd20d49d229fb99e2a351b7919707e9e22e10 Mon Sep 17 00:00:00 2001 From: Nelo Puchades Date: Wed, 15 Jul 2026 23:51:17 +0200 Subject: [PATCH 5/6] style: apply rustfmt to config and session footer tests Keep cargo fmt --check clean after the Grok OAuth and footer work. --- src/config.rs | 5 ++++- src/session_turn.rs | 14 +------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/config.rs b/src/config.rs index 9b3e8c5..e28e0d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1340,7 +1340,10 @@ mod tests { system_prompt: "You are a custom assistant.".into(), ..Default::default() }; - assert_eq!(config.system_prompt_template(), "You are a custom assistant."); + assert_eq!( + config.system_prompt_template(), + "You are a custom assistant." + ); assert!(config .render_system_prompt() .starts_with("You are a custom assistant.")); diff --git a/src/session_turn.rs b/src/session_turn.rs index 916ee52..14ff79f 100644 --- a/src/session_turn.rs +++ b/src/session_turn.rs @@ -1172,19 +1172,7 @@ mod cost_tests { fn footer_has_no_doubled_or_leading_separators_when_parts_empty() { let metrics = TurnMetrics::default(); let footer = format_footer( - 1200, - 87, - None, - true, - 0.0, - false, - None, - &metrics, - "", - "", - "", - "", - "", + 1200, 87, None, true, 0.0, false, None, &metrics, "", "", "", "", "", ); // Only the two always-present parts (tokens in/out) should appear, // joined by exactly one " · ", with no trailing/leading separator. From ed9bade2905fb4b2d8fc3eefe8b83a39f563b530 Mon Sep 17 00:00:00 2001 From: Nelo Puchades Date: Sun, 19 Jul 2026 08:14:18 +0200 Subject: [PATCH 6/6] feat(grok): use static pi model catalog instead of live /models Mirror pi agent-ready xAI list (grok-4.5, grok-4.3, grok-build-0.1) so /model no longer hits the API and skips media/legacy variants. --- CHANGELOG.md | 8 +++-- README.md | 4 ++- src/commands/config_cmds.rs | 2 +- src/openai.rs | 29 ++--------------- src/xai_oauth.rs | 63 ++++++++++++++++++++++--------------- 5 files changed, 49 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9510b..c3d91d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `grok` provider, refreshed automatically, and never injected into environment variables. Requests use OpenAI-compatible chat completions at `https://api.x.ai/v1` (override with `XAI_BASE_URL` / `GROK_BASE_URL`). - Default model is `grok-4.5`. `/login grok` can import credentials from the - official Grok CLI (`~/.grok/auth.json`) when present. Setup wizard, `/backend`, - `/doctor`, and `/auth` list the new provider. + Default model is `grok-4.5`. Model selection uses a static agent-ready catalog + matching pi (`grok-4.5`, `grok-4.3`, `grok-build-0.1`) instead of live + `GET /models`. `/login grok` can import credentials from the official Grok CLI + (`~/.grok/auth.json`) when present. Setup wizard, `/backend`, `/doctor`, and + `/auth` list the new provider. ## [1.2.3] - 2026-07-16 diff --git a/README.md b/README.md index d2fb5c6..b7678f1 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,9 @@ browser or headless device-code OAuth, no `XAI_API_KEY`: Pick **1) Browser login** (opens the system browser + local callback) or **2) Device-code login** for SSH/headless. Tokens land in `auth.json` under -`grok` and refresh automatically. Default model is `grok-4.5`. +`grok` and refresh automatically. Default model is `grok-4.5`. `/model` exposes +the static agent-ready catalog (`grok-4.5`, `grok-4.3`, `grok-build-0.1`), same +as pi — it does not scrape xAI's full `/models` list. ### Path B — Local model diff --git a/src/commands/config_cmds.rs b/src/commands/config_cmds.rs index 03a5828..3c5d6cf 100644 --- a/src/commands/config_cmds.rs +++ b/src/commands/config_cmds.rs @@ -592,7 +592,7 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> { } else if matches!(state.config.backend, BackendName::Grok) { let Some(canonical) = crate::xai_oauth::canonical_grok_model(args) else { println!( - " {RED}✗{RESET} {DIM}empty model id. Try one of: {}{RESET}", + " {RED}✗{RESET} {DIM}{args} is not supported with Grok login. Try one of: {}{RESET}", crate::xai_oauth::grok_model_list().join(", ") ); return Ok(()); diff --git a/src/openai.rs b/src/openai.rs index 3c6b84d..80bc57e 100644 --- a/src/openai.rs +++ b/src/openai.rs @@ -220,32 +220,9 @@ pub async fn list_models( return Ok(crate::codex_responses::codex_model_list()); } if matches!(backend.name, BackendName::Grok) { - let mut ids = crate::xai_oauth::grok_model_list(); - if let Ok(token) = crate::xai_oauth::access_token(client).await { - let url = format!("{}/models", backend.base_url.trim_end_matches('/')); - if let Ok(resp) = client.get(url).bearer_auth(&token).send().await { - if resp.status().is_success() { - #[derive(Deserialize)] - struct ModelsResp { - data: Vec, - } - #[derive(Deserialize)] - struct Model { - id: String, - } - if let Ok(parsed) = resp.json::().await { - for model in parsed.data { - if !model.id.is_empty() && !ids.iter().any(|id| id == &model.id) { - ids.push(model.id); - } - } - } - } - } - } - ids.sort(); - ids.dedup(); - return Ok(ids); + // Static catalog (same shape as openai-codex): avoid GET /models on + // every `/model` open and only expose agent-ready Grok ids. + return Ok(crate::xai_oauth::grok_model_list()); } let url = format!("{}/models", backend.base_url.trim_end_matches('/')); let resp = client.get(url).bearer_auth(&backend.api_key).send().await?; diff --git a/src/xai_oauth.rs b/src/xai_oauth.rs index eec55c9..91f121e 100644 --- a/src/xai_oauth.rs +++ b/src/xai_oauth.rs @@ -21,17 +21,9 @@ const PREFERRED_REDIRECT_PORT: u16 = 56121; const REDIRECT_PATH: &str = "/callback"; const GROK_CLI_AUTH_SCOPE_KEY: &str = "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"; const GROK_CLI_LEGACY_SCOPE_KEY: &str = "https://accounts.x.ai/sign-in"; -pub const GROK_MODEL_LIST: &[&str] = &[ - "grok-4.5", - "grok-4.3", - "grok-4.20-0309-reasoning", - "grok-4.20-0309-non-reasoning", - "grok-4.20-multi-agent-0309", - "grok-build-0.1", - "grok-4", - "grok-3", - "grok-3-mini", -]; +/// Curated agent-ready Grok models, matching pi's built-in xAI catalog. +/// Kept static so `/model` does not hit `GET /models` on every open. +pub const GROK_MODEL_LIST: &[&str] = &["grok-4.5", "grok-4.3", "grok-build-0.1"]; fn now_secs() -> u64 { SystemTime::now() @@ -745,25 +737,30 @@ pub fn grok_model_list() -> Vec { GROK_MODEL_LIST.iter().map(|s| (*s).to_string()).collect() } -pub fn canonical_grok_model(model: &str) -> Option<&str> { +/// Canonical SuperGrok/xAI OAuth model ids from pi's current xAI catalog. +/// Accept a few shorthand / provider-prefixed aliases, but never send those +/// aliases over the wire. +pub fn canonical_grok_model(model: &str) -> Option<&'static str> { let trimmed = model.trim(); if trimmed.is_empty() { return None; } let lower = trimmed.to_ascii_lowercase(); - for id in GROK_MODEL_LIST { - if id.eq_ignore_ascii_case(&lower) { - return Some(*id); - } - } - if let Some(bare) = lower.rsplit('/').next() { - for id in GROK_MODEL_LIST { - if id.eq_ignore_ascii_case(bare) { - return Some(*id); - } - } + let bare = lower + .rsplit_once('/') + .map(|(_, id)| id) + .unwrap_or(lower.as_str()); + match bare { + "grok-4.5" | "grok-4.5-latest" | "4.5" => Some("grok-4.5"), + "grok-4.3" | "grok-4.3-latest" | "grok-latest" | "4.3" => Some("grok-4.3"), + "grok-build-0.1" + | "grok-build-latest" + | "grok-code-fast-1" + | "grok-code-fast" + | "grok-code-fast-1-0825" + | "build-0.1" => Some("grok-build-0.1"), + _ => None, } - Some(trimmed) } #[cfg(test)] @@ -807,11 +804,25 @@ mod tests { } #[test] - fn canonical_model_accepts_aliases() { + fn canonical_model_accepts_pi_catalog_and_aliases() { assert_eq!(canonical_grok_model("grok-4.5"), Some("grok-4.5")); assert_eq!(canonical_grok_model("xai/grok-4.5"), Some("grok-4.5")); - assert_eq!(canonical_grok_model("future-model"), Some("future-model")); + assert_eq!(canonical_grok_model("4.3"), Some("grok-4.3")); + assert_eq!( + canonical_grok_model("grok-code-fast-1"), + Some("grok-build-0.1") + ); + assert_eq!(canonical_grok_model("grok-4.20-0309-reasoning"), None); + assert_eq!(canonical_grok_model("future-model"), None); assert_eq!(canonical_grok_model(""), None); + assert_eq!( + grok_model_list(), + vec![ + "grok-4.5".to_string(), + "grok-4.3".to_string(), + "grok-build-0.1".to_string(), + ] + ); } #[test]