From dd2eb215bc780b3678290135b0733af5af8787c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Brandtz=C3=A6g?= Date: Thu, 2 Jul 2026 20:50:06 +0200 Subject: [PATCH] Run grok via OpenRouter by borrowing codex's harness Grok's own CLI can't be repointed at OpenRouter, but its model (x-ai/grok-build-0.1) is on OpenRouter - so grok stops being native-only: its OpenRouter leg runs through codex's read-only `exec` harness pointed at that model. A signed-in-but-out-of-credits grok now falls back to OpenRouter and succeeds on the user's key instead of hard-failing on xAI's 403. The "grok borrows codex" fact lives in one place, `openrouter_runner()` (grok -> codex, everyone else -> self); command(), reads_stdin(), and the capability checks all derive the executing CLI from it. codex's exec command is factored into `codex_exec_command(repo, or_model)`, shared by codex's own two legs and grok's OpenRouter leg. Because that leg spawns codex, not grok: - openrouter_reachable() (used for selection/forcing) requires the borrowed runner to be installable, so grok isn't planned onto a route that can't build; openrouter_capable() adds the run-time Vibe-home check on top - prewarm/update bootstrap the runner too (codex for grok), but only when a key makes the fallback reachable --- src/agents.rs | 151 ++++++++++++++++++++++++++++++++------------------ src/main.rs | 61 +++++++++++++------- src/runner.rs | 8 +-- 3 files changed, 141 insertions(+), 79 deletions(-) diff --git a/src/agents.rs b/src/agents.rs index 5317f20..39ea8fb 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -63,10 +63,12 @@ impl Agent { } } - /// Does this agent take the prompt on stdin? Most do; vibe and grok want it - /// as a command-line argument (the value of `-p`, appended by the runner). - pub fn reads_stdin(&self) -> bool { - !matches!(self, Agent::Vibe | Agent::Grok) + /// Does this agent take the prompt on stdin? It depends on the CLI that runs + /// the leg (grok borrows codex on OpenRouter). Vibe and grok's own CLI take + /// the prompt as `-p`'s value; every other harness reads stdin. + pub fn reads_stdin(&self, openrouter: bool) -> bool { + let runner = if openrouter { self.openrouter_runner() } else { *self }; + !matches!(runner, Agent::Vibe | Agent::Grok) } /// The tool name in gg's registry. Antigravity is pulled straight from its @@ -92,9 +94,10 @@ impl Agent { Agent::Codex => "openai/gpt-5", Agent::Qwen => "qwen/qwen3-coder", Agent::Vibe => "mistralai/mistral-medium-3.1", - // Antigravity and Grok are native-only (no OpenRouter route), so - // this is never used for provenance; kept honest in case it leaks. - Agent::Antigravity | Agent::Grok => "native-only (no OpenRouter)", + // Antigravity is native-only, so this is never used for it. + Agent::Antigravity => "native-only (no OpenRouter)", + // Grok's OpenRouter leg drives its own model through codex's harness. + Agent::Grok => "x-ai/grok-build-0.1", } } @@ -123,10 +126,11 @@ impl Agent { } } - /// Headless, read-only flags. The prompt itself is delivered on stdin: - /// it is large and multiline, and Windows .cmd shims reject - /// newline-containing arguments outright. - fn args(&self, openrouter: bool) -> Vec<&'static str> { + /// Headless, read-only flags for an agent run through its own CLI (i.e. not + /// codex, whose command is built in `codex_exec_command`). The prompt is + /// delivered on stdin where possible - it is large and multiline, and + /// Windows .cmd shims reject newline-containing arguments outright. + fn args(&self) -> Vec<&'static str> { match self { // -p: headless print mode. `dontAsk` keeps it read-only WITHOUT // diverting the review into a plan. Plan mode delivers the model's @@ -146,33 +150,11 @@ impl Agent { "--allowedTools", "Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*),Bash(git rev-parse:*)", ], - // `-`: read the prompt from stdin. For OpenRouter, the `-c` - // overrides defining the provider must sit right after `exec` - // (codex's built-in openai provider can't be repointed by env - // alone for the Responses wire API). - Agent::Codex => { - // --ignore-user-config: run a clean one-shot. The user's - // config.toml (MCP servers, custom tools, extra headers) is - // irrelevant to a read-only review and can inject malformed - // tool schemas that upstream providers reject. Auth still - // resolves from CODEX_HOME. - let mut a = vec!["exec", "--ignore-user-config"]; - if openrouter { - a.extend_from_slice(&CODEX_OPENROUTER_ARGS); - a.push("-m"); - a.push(self.openrouter_model()); - } - a.extend_from_slice(&[ - "--sandbox", - "read-only", - // Run in any directory, not just a git repo; the sandbox - // already enforces read-only, so the git-trust gate is - // redundant here and just blocks non-repo cwds. - "--skip-git-repo-check", - "-", - ]); - a - } + // Codex (and grok's OpenRouter leg, which borrows it) build their + // command in `codex_exec_command`; `command()` never routes codex + // through here. Panic loudly rather than silently launch codex's + // interactive TUI with no args if that ever changes. + Agent::Codex => unreachable!("codex builds its command in codex_exec_command"), // -p -: single-prompt headless mode reading the prompt from stdin // (the `-` operand), like the other stdin agents - so the large, // multiline review prompt is never passed as a CLI argument (which @@ -225,9 +207,19 @@ impl Agent { /// key; otherwise it runs on the user's own login. The caller pipes the /// prompt to stdin. pub fn command(&self, repo: &Path, openrouter: bool) -> Command { - let mut cmd = self.base_command(); - cmd.args(self.args(openrouter)); - cmd.current_dir(repo); + // The CLI that runs this leg: normally self, but on the OpenRouter leg an + // agent may borrow another's harness (grok -> codex). + let runner = if openrouter { self.openrouter_runner() } else { *self }; + let mut cmd = if runner == Agent::Codex { + // Codex's exec harness; the model is passed only on the OpenRouter + // leg (grok supplies its own model, codex supplies gpt-5). + codex_exec_command(repo, openrouter.then(|| self.openrouter_model())) + } else { + let mut cmd = runner.base_command(); + cmd.args(self.args()); + cmd.current_dir(repo); + cmd + }; if openrouter && let Some(key) = openrouter::key() { for (name, value) in self.openrouter_env(key) { cmd.env(name, value); @@ -254,11 +246,13 @@ impl Agent { } /// A native-only agent is a closed-source CLI bound to its vendor's own - /// backend with no endpoint to repoint at OpenRouter (Antigravity -> Google, - /// Grok -> xAI). The single source of truth for "has no OpenRouter route"; - /// every OpenRouter capability below derives from it. + /// backend with no way to reach OpenRouter at all - only Antigravity + /// (Google). Grok's own CLI also can't be repointed, but grok's *model* + /// (x-ai/grok-build-0.1) is on OpenRouter, so grok's OpenRouter leg borrows + /// codex's harness (see `command`) - hence grok is NOT native-only. The + /// single source of truth for "has no OpenRouter route". pub fn is_native_only(&self) -> bool { - matches!(self, Agent::Antigravity | Agent::Grok) + matches!(self, Agent::Antigravity) } /// Can this agent reach OpenRouter in principle? Most can: Claude via the @@ -288,14 +282,39 @@ impl Agent { } } - /// Can this agent reach OpenRouter *right now*? As `supports_openrouter`, - /// except Vibe additionally needs its scratch VIBE_HOME to be written - /// (main.rs prepares it before the fan-out). - fn openrouter_capable(&self) -> bool { + /// The agent whose CLI actually executes this agent's OpenRouter leg. + /// Normally itself; grok is the exception - its own CLI can't reach + /// OpenRouter, so grok's OpenRouter leg runs through codex's harness pointed + /// at grok's model (x-ai/grok-build-0.1). The single place that fact lives. + pub fn openrouter_runner(&self) -> Agent { match self { - Agent::Vibe => vibe::home().is_some(), - _ => self.supports_openrouter(), + Agent::Grok => Agent::Codex, + other => *other, + } + } + + /// Could this agent use OpenRouter in this environment? supports_openrouter() + /// plus, for an agent that borrows another's harness (grok -> codex), that + /// runner being installed/bootstrappable. Does NOT check per-run scratch + /// state (Vibe's VIBE_HOME), which is only ready after selection - so this is + /// the check for selection and planning. See openrouter_capable for run time. + pub fn openrouter_reachable(&self) -> bool { + if !self.supports_openrouter() { + return false; } + let runner = self.openrouter_runner(); + runner == *self || runner.via().is_some() + } + + /// Can this agent reach OpenRouter *right now* (at attempt time)? As + /// openrouter_reachable, but Vibe also needs its scratch VIBE_HOME written + /// (main.rs prepares it before the fan-out). + fn openrouter_capable(&self) -> bool { + self.openrouter_reachable() + && match self { + Agent::Vibe => vibe::home().is_some(), + _ => true, + } } /// Args that run this agent's interactive login, or None when it has none. @@ -335,10 +354,11 @@ impl Agent { ("ANTHROPIC_MODEL", self.openrouter_model().into()), ("MAX_THINKING_TOKENS", "0".into()), ], - Agent::Codex => vec![("OPENROUTER_API_KEY", key.to_string())], - // Antigravity and Grok have no OpenRouter route (native-login only), - // so they are never run with `openrouter` set - no env to inject. - Agent::Antigravity | Agent::Grok => vec![], + // Grok's OpenRouter leg runs through codex, which reads the same key. + Agent::Codex | Agent::Grok => vec![("OPENROUTER_API_KEY", key.to_string())], + // Antigravity has no OpenRouter route, so it is never run with + // `openrouter` set - no env to inject. + Agent::Antigravity => vec![], // Qwen Code speaks the OpenAI-compatible API directly, so it needs // no bridge: point its OpenAI client at OpenRouter on the key. Agent::Qwen => vec![ @@ -477,6 +497,27 @@ impl Agent { } } +/// Codex's read-only `exec` harness. `or_model = Some(model)` points it at +/// OpenRouter on that model (the `-c` provider overrides go right after `exec`, +/// which codex's built-in openai provider needs for the Responses wire API); +/// None runs codex on its own login. Grok's OpenRouter leg reuses this - the +/// grok CLI can't reach OpenRouter, but codex can drive grok's model there. +/// --ignore-user-config runs a clean one-shot: the user's config.toml (MCP +/// servers, custom tools) is irrelevant to a read-only review and can inject +/// malformed tool schemas that upstream providers reject. --skip-git-repo-check +/// lets it run outside a git repo; the sandbox already enforces read-only. +fn codex_exec_command(repo: &Path, or_model: Option<&str>) -> Command { + let mut cmd = Agent::Codex.base_command(); + cmd.arg("exec").arg("--ignore-user-config"); + if let Some(model) = or_model { + cmd.args(CODEX_OPENROUTER_ARGS); + cmd.arg("-m").arg(model); + } + cmd.args(["--sandbox", "read-only", "--skip-git-repo-check", "-"]); + cmd.current_dir(repo); + cmd +} + /// Claude Code on macOS stores OAuth credentials in the Keychain, not in /// ~/.claude. Querying item metadata (no -w) never prints the secret and /// does not prompt. diff --git a/src/main.rs b/src/main.rs index 8c0e348..7100888 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,13 +127,10 @@ fn run(args: RunArgs) -> Result<()> { if !args.no_update && let Some(gg) = gg::locate() { - // Scoped: update only the agents this run uses, in parallel - not the - // user's whole gg toolchain, and never postmortemthis itself. - let tools: Vec<&str> = selected - .iter() - .filter(|a| a.via() == Some(Via::Gg)) - .map(|a| a.gg_tool()) - .collect(); + // Scoped: update only the agents this run uses (plus any borrowed + // OpenRouter runner, e.g. codex for grok), in parallel - not the user's + // whole gg toolchain, and never postmortemthis itself. + let tools = gg_tools_for(&selected); if !tools.is_empty() { eprintln!("postmortemthis: updating {} ...", tools.join(", ")); let children: Vec<_> = tools @@ -292,7 +289,7 @@ fn run_notes(reports: &[Report], selected: &[Agent], settings: &settings::Settin // Agents that exist but were not run for lack of usable credentials. An // OpenRouter-capable agent is runnable whenever a key is present (whether or // not it was requested), so flag it only when there is no key. A native-only - // agent (antigravity, grok) can't use the key at all, so flag it whenever it + // agent (antigravity) can't use the key at all, so flag it whenever it // lacks its own login - even with a key set - and point it at that login. let has_key = openrouter::key().is_some(); for agent in agents::ALL { @@ -399,11 +396,14 @@ fn plan_run( // is actually reachable (a key is present and the agent supports it). // Otherwise leave the native login in play - forcing a route that can't run // would turn a working agent into a guaranteed empty-plan failure every run. - let or_reachable = openrouter::key().is_some(); + let has_key = openrouter::key().is_some(); for &agent in &selected { + // openrouter_reachable (not supports_openrouter) so a borrowed-harness + // agent (grok -> codex) isn't forced onto OpenRouter when its runner is + // missing - that would drop the working native leg for an empty plan. if settings.mode(agent) == settings::Mode::Openrouter - && or_reachable - && agent.supports_openrouter() + && has_key + && agent.openrouter_reachable() && !skip.contains(&agent) { skip.push(agent); @@ -463,20 +463,20 @@ fn select_agents(requested: &[String], settings: &settings::Settings) -> Result< // Auto-pick an available agent (native on PATH or gg-bootstrappable) // only if it can actually run: explicitly requested, its own login, or // an OpenRouter key it can actually use. The key check is gated on - // supports_openrouter() so a native-only agent (antigravity, grok) is + // supports_openrouter() so a native-only agent (antigravity) is // not auto-selected on key-presence alone only to fail with an empty // attempt plan. Native and gg share this gate - otherwise an - // installed-but-logged-out native-only agent (grok) would be selected + // installed-but-logged-out native-only agent would be selected // unconditionally and fail on every default run. --agents overrides. Some(_) if explicit || agent.authed() - || (openrouter::key().is_some() && agent.supports_openrouter()) => + || (openrouter::key().is_some() && agent.openrouter_reachable()) => { selected.push(agent) } Some(_) => { - // Native-only agents (antigravity, grok) have no OpenRouter + // Native-only agents (antigravity) have no OpenRouter // route, so --key can't help them - don't suggest it. let how = if agent.supports_openrouter() { "log in once, or pass --key" @@ -501,14 +501,35 @@ fn select_agents(requested: &[String], settings: &settings::Settings) -> Result< Ok(selected) } -/// One chained gg invocation prepares every needed tool in parallel before the -/// fan-out, so the per-agent timeout is spent running, not bootstrapping. -fn prewarm(selected: &[Agent], dir: &std::path::Path) { - let tools: Vec<&str> = selected +/// The gg tool names needed to run `selected`: each agent's own tool plus, for +/// an agent that borrows another's OpenRouter harness (grok -> codex), that +/// runner's tool - so the borrowed CLI is prewarmed too, not downloaded inside +/// the per-agent timeout on fallback. Deduped, gg-bootstrappable agents only. +fn gg_tools_for(selected: &[Agent]) -> Vec<&'static str> { + // Only prewarm a borrowed OpenRouter runner (codex for grok) when a key makes + // that leg reachable - otherwise it's a wasted download for a keyless run. + let borrow_runners = openrouter::key().is_some(); + let mut tools: Vec<&str> = selected .iter() .filter(|a| a.via() == Some(Via::Gg)) - .map(|a| a.gg_tool()) + .flat_map(|a| { + let mut v = vec![a.gg_tool()]; + let runner = a.openrouter_runner(); + if borrow_runners && runner != *a { + v.push(runner.gg_tool()); + } + v + }) .collect(); + tools.sort_unstable(); + tools.dedup(); + tools +} + +/// One chained gg invocation prepares every needed tool in parallel before the +/// fan-out, so the per-agent timeout is spent running, not bootstrapping. +fn prewarm(selected: &[Agent], dir: &std::path::Path) { + let tools = gg_tools_for(selected); let Some(gg) = gg::locate() else { return }; if tools.is_empty() { return; diff --git a/src/runner.rs b/src/runner.rs index 2053d4a..1fda11a 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -88,7 +88,7 @@ fn run_one(agent: Agent, prompt: &str, repo: &Path, timeout: Duration, skip_nati let started = Instant::now(); let plan = agent.attempt_plan(skip_native); if plan.is_empty() { - // A native-only agent (antigravity, grok) has no OpenRouter fallback, so + // A native-only agent (antigravity) has no OpenRouter fallback, so // the failure is purely "not logged in" - don't mention a key it can't use. let why = if agent.supports_openrouter() { "no usable login and no OpenRouter key" @@ -151,9 +151,9 @@ fn run_attempt( ) -> Report { let started = Instant::now(); let mut cmd = agent.command(repo, openrouter); - // Most agents read the prompt on stdin; the rest (vibe, grok) take it as a - // trailing argument and get no stdin. - let on_stdin = agent.reads_stdin(); + // Most read the prompt on stdin; vibe (always) and grok's native leg take it + // as a trailing argument and get no stdin. + let on_stdin = agent.reads_stdin(openrouter); if !on_stdin { cmd.arg(prompt); }