Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ 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`. 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

### Fixed
Expand Down
45 changes: 39 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
<p align="center">
<a href="https://github.com/GetSmallAI/SmallHarness/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/GetSmallAI/SmallHarness/actions/workflows/ci.yml/badge.svg"></a>
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.75%2B-dea584">
<img alt="Version" src="https://img.shields.io/badge/version-1.2.1-111827">
<img alt="Backends" src="https://img.shields.io/badge/backends-Ollama%20%7C%20LM%20Studio%20%7C%20MLX%20%7C%20llama.cpp%20%7C%20OpenRouter%20%7C%20OpenAI%20%7C%20Grok-2563eb">
<img alt="Version" src="https://img.shields.io/badge/version-1.2.3-111827">
<img alt="Backends" src="https://img.shields.io/badge/backends-Ollama%20%7C%20LM%20Studio%20%7C%20MLX%20%7C%20llama.cpp%20%7C%20OpenRouter%20%7C%20OpenAI-2563eb">
<img alt="Apple Silicon" src="https://img.shields.io/badge/Apple%20Silicon-optimized-111827">
Expand Down Expand Up @@ -146,6 +148,22 @@ 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`. `/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

*Private, free, offline — runs entirely on your machine.*
Expand Down Expand Up @@ -238,12 +256,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 <name>`. 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

Expand All @@ -260,6 +280,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

Expand Down Expand Up @@ -360,6 +381,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 <path> 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
Expand Down Expand Up @@ -407,13 +430,22 @@ 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
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
Expand Down Expand Up @@ -538,8 +570,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.

Expand Down Expand Up @@ -916,7 +948,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
Expand Down Expand Up @@ -1106,6 +1138,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/`.
Expand Down
6 changes: 3 additions & 3 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ use std::path::PathBuf;
/// Persistent on-disk credential store.
///
/// Legacy files used `{"<provider>": "<api-key>"}`. 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)]
Expand Down
56 changes: 55 additions & 1 deletion src/backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub enum BackendName {
Openrouter,
OpenAi,
OpenAiCodex,
Grok,
}

impl BackendName {
Expand All @@ -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<Self> {
Expand All @@ -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,
}
}
Expand All @@ -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
Expand All @@ -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)]
Expand Down Expand Up @@ -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(),
},
}
}

Expand All @@ -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 {
Expand All @@ -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()
}
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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]
Expand Down
Loading