From 7f8ff57a4a81da3663a51cbc9ad24ddaa9094ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Brandtz=C3=A6g?= Date: Thu, 2 Jul 2026 21:46:13 +0200 Subject: [PATCH] Type openrouter_model() as Option, derive native-only from it openrouter_model() returned a fake slug ("native-only (no OpenRouter)") for antigravity, which has no OpenRouter route - a stringly-typed lie the return type couldn't express, kept honest only by a guard convention at every call site. Return Option<&'static str> (None for antigravity) so the compiler forces callers to rule out native-only agents; the guaranteed-Some sites use .expect() with the invariant that lets them, and codex_exec_command takes the Option directly (None = no -m, native codex). Make openrouter_model() the single source: is_native_only() now derives from it (no model slug => native-only) and supports_openrouter() from that, so the predicates and the .expect() guards can no longer drift apart. --- src/agents.rs | 52 ++++++++++++++++++++++++++++++--------------------- src/main.rs | 7 ++++--- src/setup.rs | 7 ++++--- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/agents.rs b/src/agents.rs index 39ea8fb..41f13c9 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -85,19 +85,22 @@ impl Agent { } } - /// The OpenRouter model slug this agent runs on when it has no usable - /// native login (or is forced there). Single source for the env/args and - /// for the provenance shown to the caller. - pub fn openrouter_model(&self) -> &'static str { + /// The OpenRouter model slug this agent runs on when it has no usable native + /// login (or is forced there), or None for a native-only agent that has no + /// OpenRouter route at all. Single source for the env/args and the provenance + /// shown to the caller; the None forces callers to rule out native-only + /// agents rather than emitting a fake slug. + pub fn openrouter_model(&self) -> Option<&'static str> { match self { - Agent::Claude => "anthropic/claude-sonnet-4.6", - Agent::Codex => "openai/gpt-5", - Agent::Qwen => "qwen/qwen3-coder", - Agent::Vibe => "mistralai/mistral-medium-3.1", - // Antigravity is native-only, so this is never used for it. - Agent::Antigravity => "native-only (no OpenRouter)", + Agent::Claude => Some("anthropic/claude-sonnet-4.6"), + Agent::Codex => Some("openai/gpt-5"), + Agent::Qwen => Some("qwen/qwen3-coder"), + Agent::Vibe => Some("mistralai/mistral-medium-3.1"), + // Antigravity is native-only: no OpenRouter model. None (not a fake + // slug) so the type forces callers to have already ruled this out. + Agent::Antigravity => None, // Grok's OpenRouter leg drives its own model through codex's harness. - Agent::Grok => "x-ai/grok-build-0.1", + Agent::Grok => Some("x-ai/grok-build-0.1"), } } @@ -213,7 +216,7 @@ impl Agent { 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())) + codex_exec_command(repo, if openrouter { self.openrouter_model() } else { None }) } else { let mut cmd = runner.base_command(); cmd.args(self.args()); @@ -245,14 +248,15 @@ impl Agent { plan } - /// A native-only agent is a closed-source CLI bound to its vendor's own - /// 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". + /// A native-only agent has no OpenRouter route at all - only Antigravity + /// (Google). Derived from `openrouter_model()` so "has an OpenRouter route" + /// has ONE source: an agent with no model slug is native-only, and every + /// capability and guard below follows from it - they can't disagree. (Grok + /// is NOT native-only: its own CLI can't reach OpenRouter, but its model + /// x-ai/grok-build-0.1 is there and its OpenRouter leg borrows codex's + /// harness - see `openrouter_runner`/`command`.) pub fn is_native_only(&self) -> bool { - matches!(self, Agent::Antigravity) + self.openrouter_model().is_none() } /// Can this agent reach OpenRouter in principle? Most can: Claude via the @@ -351,7 +355,10 @@ impl Agent { Agent::Claude => vec![ ("ANTHROPIC_BASE_URL", "https://openrouter.ai/api".into()), ("ANTHROPIC_AUTH_TOKEN", key.to_string()), - ("ANTHROPIC_MODEL", self.openrouter_model().into()), + ( + "ANTHROPIC_MODEL", + self.openrouter_model().expect("claude has an OpenRouter model").into(), + ), ("MAX_THINKING_TOKENS", "0".into()), ], // Grok's OpenRouter leg runs through codex, which reads the same key. @@ -364,7 +371,10 @@ impl Agent { Agent::Qwen => vec![ ("OPENAI_API_KEY", key.to_string()), ("OPENAI_BASE_URL", "https://openrouter.ai/api/v1".into()), - ("OPENAI_MODEL", self.openrouter_model().into()), + ( + "OPENAI_MODEL", + self.openrouter_model().expect("qwen has an OpenRouter model").into(), + ), ], // Vibe reads its provider/model from the scratch VIBE_HOME and the // key from OPENROUTER_API_KEY (named in that config). VIBE_HOME is diff --git a/src/main.rs b/src/main.rs index 7100888..32069ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,7 +195,8 @@ fn run(args: RunArgs) -> Result<()> { /// printed to stdout and, with --out, a file. fn report_section(r: &Report) -> String { let provenance = if r.used_openrouter { - format!("via OpenRouter: {}", r.agent.openrouter_model()) + let model = r.agent.openrouter_model().expect("ran on OpenRouter, so has a model"); + format!("via OpenRouter: {model}") } else { "native login".to_string() }; @@ -275,7 +276,7 @@ fn run_notes(reports: &[Report], selected: &[Agent], settings: &settings::Settin if r.fell_back { notes.push(format!( "- {name}: native login failed, so it ran on OpenRouter ({}). To restore the native login, {}. To skip the wasted retry on later runs, add `--skip-native {name}`.", - r.agent.openrouter_model(), + r.agent.openrouter_model().expect("fell back to OpenRouter, so has a model"), r.agent.native_fix_hint(), )); } @@ -555,7 +556,7 @@ fn start_vibe_home(selected: &[Agent]) -> Option { if !(selected.contains(&Agent::Vibe) && openrouter::key().is_some()) { return None; } - match vibe::Home::create(Agent::Vibe.openrouter_model()) { + match vibe::Home::create(Agent::Vibe.openrouter_model().expect("vibe has an OpenRouter model")) { Ok(home) => Some(home), Err(e) => { eprintln!("postmortemthis: could not prepare vibe home ({e}); the vibe leg will fail"); diff --git a/src/setup.rs b/src/setup.rs index 1032cc9..0e20e9c 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -178,8 +178,8 @@ fn poke(agent: Agent, has_key: bool) -> String { if agent.authed() { return format!("{} - native login will be used", agent.auth_hint()); } - if has_key && agent.supports_openrouter() { - return format!("no login - will run on OpenRouter ({})", agent.openrouter_model()); + if has_key && let Some(model) = agent.openrouter_model() { + return format!("no login - will run on OpenRouter ({model})"); } if agent.has_native_login() { "not logged in - won't run until you log in".into() @@ -196,7 +196,8 @@ fn effective(agent: Agent, settings: &Settings, has_key: bool) -> String { // Forced OpenRouter only takes effect with a usable route; plan_run falls // back to native otherwise, so the summary must say so, not overstate it. Mode::Openrouter if has_key && agent.supports_openrouter() => { - format!("forced OpenRouter ({})", agent.openrouter_model()) + let model = agent.openrouter_model().expect("supports_openrouter, so has a model"); + format!("forced OpenRouter ({model})") } Mode::Openrouter => format!("forced OpenRouter (inactive, no key) - {}", poke(agent, has_key)), Mode::Auto => poke(agent, has_key),