From cbdfaa3f34a3371b37240535db2afd295b2c08ae Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 22 Aug 2026 10:58:55 -0700 Subject: [PATCH] feat(smith): add OpenRouter as a first-class provider and route target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `openrouter:` model prefix in smith (explicit only — no bare-name sniff: vendor/model slash ids collide with Ollama namespaces and an aggregator is a distinct billing path, spec 0028) - OpenRouter mode on the OpenAI chat client: usage-cost accounting (Usage.usd now carries the generation's real USD cost from the wire) and app attribution headers - built-in route target (spec 0179): OPENROUTER_API_KEY alone makes `openrouter` a route target defaulting to openrouter/auto - auto-detect ladder rung (last), title-gen rung, /configure and /model completion entries, 128K context-window floor - spec 0208 records the decision --- crates/adapter-smith/src/agent.rs | 51 +++++++++++--- crates/adapter-smith/src/context.rs | 5 ++ crates/adapter-smith/src/lib.rs | 4 +- crates/adapter-smith/src/provider/openai.rs | 39 ++++++++++- crates/adapter-smith/src/provider/routing.rs | 49 ++++++++++++- crates/adapter-smith/src/title_mode.rs | 9 +++ crates/cli/src/app/configure.rs | 3 + crates/daemon/src/availability.rs | 16 ++++- crates/daemon/src/config.rs | 42 +++++++++++- crates/daemon/src/router.rs | 6 +- crates/protocol/src/slash.rs | 6 ++ ...nrouter-is-an-explicit-aggregator-route.md | 68 +++++++++++++++++++ 12 files changed, 277 insertions(+), 21 deletions(-) create mode 100644 specs/0208-openrouter-is-an-explicit-aggregator-route.md diff --git a/crates/adapter-smith/src/agent.rs b/crates/adapter-smith/src/agent.rs index 6e8a9f3f..ebda9bc1 100644 --- a/crates/adapter-smith/src/agent.rs +++ b/crates/adapter-smith/src/agent.rs @@ -72,6 +72,10 @@ const GROK_BASE_URL: &str = "https://api.x.ai/v1"; /// DeepSeek's OpenAI-compatible surface. Served by the same /// `provider::openai` client as Grok — the wire format is chat completions. const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; +/// OpenRouter's aggregator surface — also OpenAI-compatible chat +/// completions, served by the same client in its OpenRouter mode (usage +/// cost accounting + attribution headers). +const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; fn record_approval_history( history: &mut VecDeque, @@ -1594,6 +1598,7 @@ impl ResolvedModel { provider::routing::Provider::Ollama => "ollama", provider::routing::Provider::Grok => "grok", provider::routing::Provider::DeepSeek => "deepseek", + provider::routing::Provider::OpenRouter => "openrouter", provider::routing::Provider::GrokOauth => "grok-oauth", provider::routing::Provider::CodexOauth => "codex-oauth", provider::routing::Provider::ClaudeOauth => "claude-oauth", @@ -1629,7 +1634,8 @@ impl ResolvedModel { /// 5. GEMINI_API_KEY (or GOOGLE_API_KEY) set → `gemini-2.5-pro`. /// 6. META_API_KEY (or MODEL_API_KEY) set → `muse-spark-1.1`. /// 7. DEEPSEEK_API_KEY set → `deepseek-v4-pro`. -/// 8. none of the above → an error (spec 0069). Earlier versions fell +/// 8. OPENROUTER_API_KEY set → `openrouter/auto`. +/// 9. none of the above → an error (spec 0069). Earlier versions fell /// through to `ollama:llama3.1` here unconditionally, so a zero-config /// machine with no Ollama server running got a session that looked /// healthy and then died mid-turn with a raw transport error instead @@ -1671,16 +1677,22 @@ fn default_auto_detect_spec() -> Result { if std::env::var("META_API_KEY").is_ok() || std::env::var("MODEL_API_KEY").is_ok() { return Ok("meta:muse-spark-1.1".to_string()); } - // Last rung: a machine whose only credential is DeepSeek's still gets a - // working session instead of the curated error. Ordered after the others - // so no machine that already resolved changes provider (spec 0071). + // Late rungs: a machine whose only credential is DeepSeek's or + // OpenRouter's still gets a working session instead of the curated + // error. Ordered after the others so no machine that already resolved + // changes provider (spec 0071). if std::env::var("DEEPSEEK_API_KEY").is_ok() { return Ok("deepseek:deepseek-v4-pro".to_string()); } + if std::env::var("OPENROUTER_API_KEY").is_ok() { + // `openrouter/auto` is OpenRouter's model-routing meta-id — the one + // id every OpenRouter account can serve. + return Ok("openrouter:openrouter/auto".to_string()); + } anyhow::bail!( "no auto-detected smith credential (ANTHROPIC_API_KEY, OPENAI_API_KEY, or \ GEMINI_API_KEY/GOOGLE_API_KEY, or META_API_KEY/MODEL_API_KEY, or \ - DEEPSEEK_API_KEY) and no CONSTRUCT_SMITH_MODEL pin set" + DEEPSEEK_API_KEY, or OPENROUTER_API_KEY) and no CONSTRUCT_SMITH_MODEL pin set" ) } @@ -1715,6 +1727,10 @@ pub fn resolve_model_from_spec(spec_str: &str) -> Result { Some(DEEPSEEK_BASE_URL.to_string()), deepseek_api_key()?, )?), + provider::routing::Provider::OpenRouter => Box::new(provider::openai::OpenAi::openrouter( + Some(OPENROUTER_BASE_URL.to_string()), + openrouter_api_key()?, + )?), provider::routing::Provider::GrokOauth => Box::new(provider::openai::OpenAi::with_config( Some(GROK_BASE_URL.to_string()), grok_oauth_token()?, @@ -1774,6 +1790,7 @@ fn build_profile_model( "ollama" => provider::routing::Provider::Ollama, "grok" => provider::routing::Provider::Grok, "deepseek" => provider::routing::Provider::DeepSeek, + "openrouter" => provider::routing::Provider::OpenRouter, "codex-oauth" | "claude-oauth" | "claude-code-oauth" | "grok-oauth" | "kimi-oauth" => anyhow::bail!( "profile `{name}`: provider `{}` is OAuth-backed and has no \ configurable endpoint — use the `{}:` model prefix directly", @@ -1782,7 +1799,7 @@ fn build_profile_model( ), other => anyhow::bail!( "profile `{name}`: unknown provider `{other}` \ - (expected openai | anthropic | gemini | meta | ollama | grok | deepseek)" + (expected openai | anthropic | gemini | meta | ollama | grok | deepseek | openrouter)" ), }; @@ -1826,6 +1843,10 @@ fn build_profile_model( base_url.or_else(|| Some(DEEPSEEK_BASE_URL.to_string())), profile_api_key(profile, name, &["DEEPSEEK_API_KEY"])?, )?), + provider::routing::Provider::OpenRouter => Box::new(provider::openai::OpenAi::openrouter( + base_url.or_else(|| Some(OPENROUTER_BASE_URL.to_string())), + profile_api_key(profile, name, &["OPENROUTER_API_KEY"])?, + )?), // codex-oauth / claude-oauth / grok-oauth rejected above. _ => unreachable!("oauth providers rejected above"), }; @@ -1877,6 +1898,11 @@ fn deepseek_api_key() -> Result { .map_err(|_| anyhow::anyhow!("deepseek provider requires DEEPSEEK_API_KEY")) } +fn openrouter_api_key() -> Result { + std::env::var("OPENROUTER_API_KEY") + .map_err(|_| anyhow::anyhow!("openrouter provider requires OPENROUTER_API_KEY")) +} + fn grok_auth_path() -> Result { if let Ok(home) = std::env::var("GROK_HOME") { if !home.trim().is_empty() { @@ -2337,6 +2363,7 @@ mod tests { "META_API_KEY", "MODEL_API_KEY", "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", ]; let saved: Vec> = vars.iter().map(|v| env::var(v).ok()).collect(); for v in vars { @@ -2366,16 +2393,19 @@ mod tests { "META_API_KEY", "MODEL_API_KEY", "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", ]; let saved: Vec> = vars.iter().map(|v| env::var(v).ok()).collect(); for v in vars { env::remove_var(v); } - // DeepSeek is the last rung: it resolves when it is the only key, and - // yields to every other direct-API credential. + // OpenRouter is the last rung: it resolves when it is the only key, + // and yields to every direct vendor credential (including DeepSeek). + env::set_var("OPENROUTER_API_KEY", "x"); + let openrouter_only = default_auto_detect_spec().expect("openrouter"); env::set_var("DEEPSEEK_API_KEY", "x"); - let deepseek_only = default_auto_detect_spec().expect("deepseek"); + let deepseek_over_openrouter = default_auto_detect_spec().expect("deepseek"); env::set_var("MODEL_API_KEY", "x"); let meta_only = default_auto_detect_spec().expect("meta"); env::set_var("GEMINI_API_KEY", "x"); @@ -2391,7 +2421,8 @@ mod tests { None => env::remove_var(v), } } - assert_eq!(deepseek_only, "deepseek:deepseek-v4-pro"); + assert_eq!(openrouter_only, "openrouter:openrouter/auto"); + assert_eq!(deepseek_over_openrouter, "deepseek:deepseek-v4-pro"); assert_eq!(meta_only, "meta:muse-spark-1.1"); assert_eq!(gemini_over_meta, "gemini:gemini-2.5-pro"); assert_eq!(openai_over_gemini, "openai:gpt-5"); diff --git a/crates/adapter-smith/src/context.rs b/crates/adapter-smith/src/context.rs index aa604b1e..0237b4da 100644 --- a/crates/adapter-smith/src/context.rs +++ b/crates/adapter-smith/src/context.rs @@ -44,6 +44,11 @@ pub fn context_window_tokens(provider: &str, model: &str) -> usize { // session at 8K and compact almost immediately on a model that can // hold the whole conversation. ("deepseek", _) => 1_000_000, + // OpenRouter serves heterogeneous models, so no single number is + // right. 128K is a floor most serious hosted models clear; the + // overflow learn-and-retry path tightens it when a routed model + // enforces less. The 8K default would compact almost immediately. + ("openrouter", _) => 128_000, // ChatGPT-subscription Codex backend. Same gpt-5* family, // same advertised context window as the platform API — the // billing pipe is what differs, not the model. Starting diff --git a/crates/adapter-smith/src/lib.rs b/crates/adapter-smith/src/lib.rs index 402bdb77..1b2853de 100644 --- a/crates/adapter-smith/src/lib.rs +++ b/crates/adapter-smith/src/lib.rs @@ -148,6 +148,8 @@ fn model_startup_error_message(params: &SessionStartParams, error: &str) -> Stri ); } else if lower.contains("deepseek_api_key") { msg.push_str("\n\nAction: set `DEEPSEEK_API_KEY` or switch smith to another model."); + } else if lower.contains("openrouter_api_key") { + msg.push_str("\n\nAction: set `OPENROUTER_API_KEY` or switch smith to another model."); } else if lower.contains("meta_api_key") || lower.contains("model_api_key") { msg.push_str( "\n\nAction: set `META_API_KEY` or `MODEL_API_KEY`, or switch smith to another model.", @@ -164,7 +166,7 @@ fn model_startup_error_message(params: &SessionStartParams, error: &str) -> Stri msg.push_str( "\n\nsmith needs one of: `CONSTRUCT_SMITH_MODEL`, `ANTHROPIC_API_KEY`, \ `OPENAI_API_KEY`, `GEMINI_API_KEY`, `META_API_KEY`/`MODEL_API_KEY`, `GROK_API_KEY`/`XAI_API_KEY`, \ - `DEEPSEEK_API_KEY`, a valid Grok OAuth login, or a local Ollama. Run `/configure` in the construct TUI \ + `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, a valid Grok OAuth login, or a local Ollama. Run `/configure` in the construct TUI \ (or `M-x configure`) to check status and pick one.", ); msg diff --git a/crates/adapter-smith/src/provider/openai.rs b/crates/adapter-smith/src/provider/openai.rs index 386934eb..65114961 100644 --- a/crates/adapter-smith/src/provider/openai.rs +++ b/crates/adapter-smith/src/provider/openai.rs @@ -19,6 +19,10 @@ pub struct OpenAi { client: reqwest::Client, base_url: String, api_key: String, + /// OpenRouter mode: send its `usage: {include: true}` accounting knob + /// (the response's final usage chunk then carries the generation's real + /// USD cost) and the attribution headers its ranking honors. + openrouter: bool, } impl OpenAi { @@ -42,8 +46,19 @@ impl OpenAi { .context("build reqwest client")?, base_url, api_key, + openrouter: false, }) } + + /// Build for an OpenRouter endpoint: same wire format, plus the + /// OpenRouter-only request extras (usage-cost accounting, attribution + /// headers). Kept a flag rather than a separate provider impl because + /// everything on the stream is plain chat completions. + pub fn openrouter(base_url: Option, api_key: String) -> Result { + let mut p = Self::with_config(base_url, api_key)?; + p.openrouter = true; + Ok(p) + } } fn role_str(r: Role) -> &'static str { @@ -151,13 +166,27 @@ impl LlmProvider for OpenAi { if !tools.is_empty() { body["tools"] = Value::Array(tools_to_openai(tools)); } + if self.openrouter { + // OpenRouter's usage accounting: the final usage chunk then + // carries `cost` — the generation's actual USD price — so + // `Usage.usd` is exact for any routed model, no price table. + body["usage"] = json!({ "include": true }); + } let url = format!("{}/chat/completions", self.base_url); - let resp = self + let mut req = self .client .post(&url) .bearer_auth(&self.api_key) - .json(&body) + .json(&body); + if self.openrouter { + // Optional app attribution; OpenRouter uses these for its app + // rankings. Harmless plain headers, never credentials. + req = req + .header("HTTP-Referer", "https://github.com/construct-worlds/construct") + .header("X-Title", "Construct"); + } + let resp = req .send() .await .context("openai POST /chat/completions")?; @@ -208,6 +237,12 @@ impl LlmProvider for OpenAi { .pointer("/prompt_tokens_details/cached_tokens") .and_then(|n| n.as_u64()) .unwrap_or(usage.cached_tokens); + // OpenRouter (with `usage: {include: true}`) reports the + // generation's actual cost in USD. Absent everywhere else. + usage.usd = u + .get("cost") + .and_then(|n| n.as_f64()) + .unwrap_or(usage.usd); } let choice = match chunk .get("choices") diff --git a/crates/adapter-smith/src/provider/routing.rs b/crates/adapter-smith/src/provider/routing.rs index 02d5a487..ea825da2 100644 --- a/crates/adapter-smith/src/provider/routing.rs +++ b/crates/adapter-smith/src/provider/routing.rs @@ -22,6 +22,13 @@ //! Claude Code login credentials and calls the Anthropic API directly with the //! subscription OAuth token (see spec 0125). It is distinct from `anthropic:`, //! which uses `ANTHROPIC_API_KEY`. +//! +//! `openrouter:` is the OpenRouter aggregator (`openrouter.ai/api/v1`, +//! OpenAI-compatible chat completions, `OPENROUTER_API_KEY`). It has no +//! bare-name fallback on purpose: OpenRouter ids are `vendor/model` paths, +//! and slashes also appear in Ollama names (`hf.co/user/model`), so a bare +//! slash id is ambiguous — and routing through an aggregator is a distinct +//! billing path, which stays an explicit act (spec 0028). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Provider { @@ -36,6 +43,10 @@ pub enum Provider { /// DeepSeek platform API surface. OpenAI-compatible chat completions at /// `api.deepseek.com`, billed against a DeepSeek API key. DeepSeek, + /// OpenRouter aggregator. OpenAI-compatible chat completions at + /// `openrouter.ai`, billed against an OpenRouter API key; model ids are + /// `vendor/model` paths (`stealth/ox-alpha`, `openrouter/auto`). + OpenRouter, /// OAuth-backed Grok access path. GrokOauth, /// OAuth-backed Codex backend; reads `~/.codex/auth.json`, bills @@ -102,6 +113,12 @@ pub fn parse_model_spec(s: &str) -> Result { model: rest.to_string(), }); } + if let Some(rest) = s.strip_prefix("openrouter:") { + return Ok(ModelSpec { + provider: Provider::OpenRouter, + model: rest.to_string(), + }); + } if let Some(rest) = s.strip_prefix("grok-oauth:") { return Ok(ModelSpec { provider: Provider::GrokOauth, @@ -141,6 +158,7 @@ pub fn parse_model_spec(s: &str) -> Result { | "ollama" | "grok" | "deepseek" + | "openrouter" | "grok-oauth" | "codex-oauth" | "claude-oauth" @@ -150,7 +168,7 @@ pub fn parse_model_spec(s: &str) -> Result { { return Err(format!( "unknown provider prefix `{prefix}:` (expected one of \ - openai:, anthropic:, gemini:, meta:, ollama:, grok:, deepseek:, grok-oauth:, codex-oauth:, claude-oauth:, kimi-oauth:)" + openai:, anthropic:, gemini:, meta:, ollama:, grok:, deepseek:, openrouter:, grok-oauth:, codex-oauth:, claude-oauth:, kimi-oauth:)" )); } } @@ -353,6 +371,35 @@ mod tests { assert_eq!(parse("openai:deepseek-v4-pro").provider, Provider::OpenAI); } + #[test] + fn openrouter_prefix_is_recognized() { + let s = parse("openrouter:stealth/ox-alpha"); + assert_eq!(s.provider, Provider::OpenRouter); + assert_eq!(s.model, "stealth/ox-alpha"); + + let s = parse("openrouter:openrouter/auto"); + assert_eq!(s.provider, Provider::OpenRouter); + assert_eq!(s.model, "openrouter/auto"); + } + + /// OpenRouter variant suffixes (`:free`, `:nitro`) ride after the model + /// id. Only the first colon is the provider separator; the rest of the + /// spec is the model verbatim. + #[test] + fn openrouter_variant_suffix_stays_in_the_model() { + let s = parse("openrouter:deepseek/deepseek-chat:free"); + assert_eq!(s.provider, Provider::OpenRouter); + assert_eq!(s.model, "deepseek/deepseek-chat:free"); + } + + /// No bare-name sniff for OpenRouter: `vendor/model` slash ids also + /// occur in Ollama names (`hf.co/user/model`), and an aggregator is a + /// distinct billing path — selecting it stays explicit (spec 0028). + #[test] + fn bare_slash_id_does_not_route_to_openrouter() { + assert_eq!(parse("stealth/ox-alpha").provider, Provider::Ollama); + } + #[test] fn claude_oauth_prefixes_are_recognized() { let s = parse("claude-oauth:sonnet"); diff --git a/crates/adapter-smith/src/title_mode.rs b/crates/adapter-smith/src/title_mode.rs index 55f723a2..80deee0b 100644 --- a/crates/adapter-smith/src/title_mode.rs +++ b/crates/adapter-smith/src/title_mode.rs @@ -59,6 +59,10 @@ pub(crate) fn pick_default_spec_str() -> Result { // Flash is the cheap tier — titles never need the pro model. return Ok("deepseek:deepseek-v4-flash".to_string()); } + if std::env::var("OPENROUTER_API_KEY").is_ok() { + // The auto-router picks per prompt; a title prompt is tiny. + return Ok("openrouter:openrouter/auto".to_string()); + } Err(anyhow!( "no auto-detected smith credential and no CONSTRUCT_SMITH_MODEL pin set; skipping auto-title" )) @@ -82,6 +86,11 @@ pub(crate) fn provider_for(p: Provider) -> Result> { std::env::var("DEEPSEEK_API_KEY") .map_err(|_| anyhow!("deepseek requires DEEPSEEK_API_KEY"))?, )?), + Provider::OpenRouter => Box::new(provider::openai::OpenAi::openrouter( + Some("https://openrouter.ai/api/v1".to_string()), + std::env::var("OPENROUTER_API_KEY") + .map_err(|_| anyhow!("openrouter requires OPENROUTER_API_KEY"))?, + )?), // Title generation always uses one of the key providers above; the // user never picks OAuth providers for title-gen since the // selection comes from `pick_default_spec_str` which only diff --git a/crates/cli/src/app/configure.rs b/crates/cli/src/app/configure.rs index 49b4b390..b3ddf19b 100644 --- a/crates/cli/src/app/configure.rs +++ b/crates/cli/src/app/configure.rs @@ -125,6 +125,9 @@ pub fn smith_method_guidance(id: &str) -> &'static str { "deepseek_api_key" => { "export DEEPSEEK_API_KEY in the shell that starts the daemon, then restart the daemon" } + "openrouter_api_key" => { + "export OPENROUTER_API_KEY in the shell that starts the daemon, then restart the daemon" + } "claude_subscription" => { "run `claude` and log in with your Claude subscription first (creates \ ~/.claude/.credentials.json), as the user the daemon runs as, then restart the daemon" diff --git a/crates/daemon/src/availability.rs b/crates/daemon/src/availability.rs index e6f84d09..4c2ff46a 100644 --- a/crates/daemon/src/availability.rs +++ b/crates/daemon/src/availability.rs @@ -456,6 +456,13 @@ pub async fn smith_auth_methods( "deepseek-v4-pro", &["DEEPSEEK_API_KEY"], ); + let openrouter_key = env_key_method( + "openrouter_api_key", + "OpenRouter API key", + "openrouter", + "openrouter/auto", + &["OPENROUTER_API_KEY"], + ); let claude_sub_present = claude_oauth_credentials_present(cache).await; let claude_sub = SmithAuthMethod { id: "claude_subscription", @@ -534,7 +541,8 @@ pub async fn smith_auth_methods( || openai.available || gemini.available || meta.available - || deepseek_key.available; + || deepseek_key.available + || openrouter_key.available; let auto = SmithAuthMethod { id: "auto", label: "Auto-detect", @@ -542,7 +550,8 @@ pub async fn smith_auth_methods( default_model: "", available: auto_available, detail: if auto_available { - "auto-detects the first set API key: Anthropic → OpenAI → Gemini → Meta → DeepSeek" + "auto-detects the first set API key: Anthropic → OpenAI → Gemini → Meta → \ + DeepSeek → OpenRouter" .to_string() } else { "no auto-detected API key set (subscriptions and Ollama must be picked explicitly)" @@ -550,7 +559,8 @@ pub async fn smith_auth_methods( }, }; vec![ - anthropic, openai, gemini, meta, grok_key, deepseek_key, claude_sub, codex_sub, grok_sub, + anthropic, openai, gemini, meta, grok_key, deepseek_key, openrouter_key, claude_sub, + codex_sub, grok_sub, kimi_sub, ollama, auto, ] diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index 61b05454..2a28d650 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -232,6 +232,7 @@ enabled = true # META_API_KEY / MODEL_API_KEY -> meta (muse-spark-1.1) # GROK_API_KEY / XAI_API_KEY -> grok (grok-4.6) # DEEPSEEK_API_KEY -> deepseek (deepseek-v4-pro) +# OPENROUTER_API_KEY -> openrouter (openrouter/auto) # # The key must be in the DAEMON's environment (or [daemon.env] below), and a # restart picks it up. Declare a profile with one of those names only to @@ -255,6 +256,12 @@ enabled = true # api_key_env = "DEEPSEEK_API_KEY" # model = "deepseek-v4-pro" +# OpenRouter pinned to one routed model instead of the auto-router. Any +# `vendor/model` id on openrouter.ai works, including stealth previews: +# [smith.models.ox] +# provider = "openrouter" +# model = "stealth/ox-alpha" # key comes from OPENROUTER_API_KEY + # OpenAI-compatible example (Groq): # [smith.models.groq] # provider = "openai" @@ -671,6 +678,15 @@ pub const DEEPSEEK_API_KEY_ENV: &str = "DEEPSEEK_API_KEY"; /// [`SmithConfig::route_profiles`]). pub const DEEPSEEK_ROUTE_NAME: &str = "deepseek"; +/// OpenRouter's OpenAI-compatible aggregator endpoint and key env var, +/// shared by the built-in target and the profile defaults for the same +/// reason as DeepSeek's. +pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; +pub const OPENROUTER_API_KEY_ENV: &str = "OPENROUTER_API_KEY"; +/// Route name the built-in OpenRouter target claims; a user-declared +/// `[smith.models.openrouter]` profile takes it back. +pub const OPENROUTER_ROUTE_NAME: &str = "openrouter"; + /// One built-in API-key route target (spec 0179): a provider with a single /// well-known public endpoint, which becomes a route target the moment its /// key is present in the daemon's environment — no config block required. @@ -745,6 +761,15 @@ pub const BUILTIN_TARGETS: &[BuiltinTarget] = &[ key_envs: &[DEEPSEEK_API_KEY_ENV], default_model: "deepseek-v4-pro", }, + BuiltinTarget { + route: OPENROUTER_ROUTE_NAME, + provider: OPENROUTER_ROUTE_NAME, + base_url: OPENROUTER_BASE_URL, + key_envs: &[OPENROUTER_API_KEY_ENV], + // OpenRouter's model-routing meta-id: the one id every account can + // serve; specific `vendor/model` ids come from the user. + default_model: "openrouter/auto", + }, ]; /// `[smith]` — only the `models` table is read by the daemon. Smith parses @@ -815,7 +840,8 @@ fn env_var_present(name: &str) -> bool { #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct ModelProfile { /// Wire protocol: `openai` | `openai-responses` | `azure-openai` | - /// `anthropic` | `gemini` | `meta` | `ollama` | `grok`. + /// `anthropic` | `gemini` | `meta` | `ollama` | `grok` | `deepseek` | + /// `openrouter`. pub provider: String, #[serde(default)] pub base_url: Option, @@ -840,6 +866,7 @@ impl ModelProfile { "anthropic" => "https://api.anthropic.com/v1", "grok" => "https://api.x.ai/v1", "deepseek" => DEEPSEEK_BASE_URL, + "openrouter" => OPENROUTER_BASE_URL, "gemini" | "google" => "https://generativelanguage.googleapis.com/v1beta", "meta" => "https://api.meta.ai/v1", "ollama" => "http://localhost:11434", @@ -859,6 +886,7 @@ impl ModelProfile { "meta" => &["META_API_KEY", "MODEL_API_KEY"], "grok" => &["GROK_API_KEY", "XAI_API_KEY"], "deepseek" => &[DEEPSEEK_API_KEY_ENV], + "openrouter" => &[OPENROUTER_API_KEY_ENV], _ => &[], } } @@ -1573,6 +1601,18 @@ mod tests { assert_eq!(profile.default_key_envs(), &[DEEPSEEK_API_KEY_ENV]); } + /// An `openrouter` provider resolves to the aggregator endpoint and its + /// key env without either being declared. + #[test] + fn openrouter_profile_defaults_to_its_public_endpoint_and_key() { + let profile: ModelProfile = toml::from_str(r#"provider = "openrouter""#).expect("parse"); + assert_eq!( + profile.resolved_base_url().as_deref(), + Some(OPENROUTER_BASE_URL) + ); + assert_eq!(profile.default_key_envs(), &[OPENROUTER_API_KEY_ENV]); + } + /// The key alone makes DeepSeek a route target — no config block (spec 0179). #[test] fn deepseek_is_a_builtin_route_target_when_its_key_is_set() { diff --git a/crates/daemon/src/router.rs b/crates/daemon/src/router.rs index ae8f39c5..06b334c2 100644 --- a/crates/daemon/src/router.rs +++ b/crates/daemon/src/router.rs @@ -114,9 +114,9 @@ pub fn provider_dialect(provider: &str) -> Option { match provider.to_ascii_lowercase().as_str() { "anthropic" => Some(Dialect::AnthropicMessages), "gemini" | "google" => Some(Dialect::GoogleGemini), - // Grok and DeepSeek are served by smith's OpenAI client and speak the - // same wire format. - "openai" | "grok" | "deepseek" => Some(Dialect::OpenAiChat), + // Grok, DeepSeek, and OpenRouter are served by smith's OpenAI client + // and speak the same wire format. + "openai" | "grok" | "deepseek" | "openrouter" => Some(Dialect::OpenAiChat), // Azure's current v1 API uses Responses on the wire; its adapter // difference is the `api-key` header, not a separate JSON dialect. // Meta serves Muse Spark over the same Responses surface — smith's diff --git a/crates/protocol/src/slash.rs b/crates/protocol/src/slash.rs index 36722034..d4e13927 100644 --- a/crates/protocol/src/slash.rs +++ b/crates/protocol/src/slash.rs @@ -431,6 +431,12 @@ pub const MODEL_COMPLETIONS: &[&str] = &[ // DeepSeek platform API path. "deepseek:deepseek-v4-pro", "deepseek:deepseek-v4-flash", + // OpenRouter aggregator path. `openrouter/auto` is the stable + // meta-router id; any `vendor/model` id on openrouter.ai works, and + // stealth ids (like ox-alpha) rotate — typing them out still completes + // the prefix. + "openrouter:openrouter/auto", + "openrouter:stealth/ox-alpha", // Local Ollama examples. "ollama:llama3.1", "ollama:qwen3.8", diff --git a/specs/0208-openrouter-is-an-explicit-aggregator-route.md b/specs/0208-openrouter-is-an-explicit-aggregator-route.md new file mode 100644 index 00000000..da8be2c9 --- /dev/null +++ b/specs/0208-openrouter-is-an-explicit-aggregator-route.md @@ -0,0 +1,68 @@ +# 0208-openrouter-is-an-explicit-aggregator-route + +Status: accepted +Date: 2026-08-22 +Area: harness +Scope: OpenRouter is a first-class smith provider and built-in route target, selected only by explicit prefix, with cost taken from the wire. + +## Decision + +OpenRouter is supported as a thin variant of the OpenAI chat-completions +provider: its own provider prefix, its own key env var, and a built-in +route target (spec 0179) at its single public endpoint. Three rules hold: + +1. **Explicit selection only.** There is no bare-name sniff for OpenRouter + model ids. A `vendor/model` slash id typed without a prefix must not + route to OpenRouter. +2. **Cost comes from the wire, not a price table.** Requests to OpenRouter + ask for its usage accounting, and the per-generation USD cost reported + in the response is what Construct records. No per-model price list is + maintained for routed models. +3. **The default model is the aggregator's own meta-router id** + (`openrouter/auto`) — the only id every OpenRouter account can serve. + Specific routed models are always a user choice. + +## Reason + +OpenRouter serves new and stealth models before they have official SDKs or +dedicated adapters, so an aggregator route makes any such model reachable +the day it appears, with no per-model code. But an aggregator is a distinct +billing path, and its slash-shaped ids collide with other namespaces +(local runtimes also use `host/user/model` names) — so selecting it stays +an explicit act, consistent with the rule that switching the endpoint or +billing path is never inferred (spec 0028). The models behind it are +heterogeneous and change without notice, which is why cost must be read +from each response rather than curated, and why no specific routed model +can be a default. + +## Consequences + +- Reasoning-effort support for OpenRouter targets is advertised only after + the underlying model is measured to grade it (spec 0160's model-aware + rule); until then no effort scale is offered. +- Context-window budgeting uses a conservative floor plus the runtime + overflow-learning path, since no single window is true across routed + models. +- Curated model lists may include the stable meta-router id and, as + examples, currently-live routed ids; routed ids (stealth ones + especially) rotate, and staleness there must degrade to "typed ids still + work", never to a broken route. + +## Non-Goals + +- Per-model metadata sync from OpenRouter's catalog (context windows, + pricing) into Construct's own tables. The catalog may later inform the + provider's runtime context-window hook, but is not a source for curated + lists. +- OpenRouter-specific routing features (provider ordering, fallback + chains, nitro/free variants) beyond passing the user's model id through + verbatim. + +## Examples + +- A user with only an OpenRouter key gets a working session on the + meta-router by default, and can pin any routed id explicitly. +- A stealth model id typed with the OpenRouter prefix works the day the + model appears, and its exact cost shows up in the session's usage. +- The same id typed bare falls through to the local-runtime fallback, not + to OpenRouter.