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
@@ -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]