Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions crates/adapter-smith/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApprovalHistoryEntry>,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1671,16 +1677,22 @@ fn default_auto_detect_spec() -> Result<String> {
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"
)
}

Expand Down Expand Up @@ -1715,6 +1727,10 @@ pub fn resolve_model_from_spec(spec_str: &str) -> Result<ResolvedModel> {
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()?,
Expand Down Expand Up @@ -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",
Expand All @@ -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)"
),
};

Expand Down Expand Up @@ -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"),
};
Expand Down Expand Up @@ -1877,6 +1898,11 @@ fn deepseek_api_key() -> Result<String> {
.map_err(|_| anyhow::anyhow!("deepseek provider requires DEEPSEEK_API_KEY"))
}

fn openrouter_api_key() -> Result<String> {
std::env::var("OPENROUTER_API_KEY")
.map_err(|_| anyhow::anyhow!("openrouter provider requires OPENROUTER_API_KEY"))
}

fn grok_auth_path() -> Result<PathBuf> {
if let Ok(home) = std::env::var("GROK_HOME") {
if !home.trim().is_empty() {
Expand Down Expand Up @@ -2337,6 +2363,7 @@ mod tests {
"META_API_KEY",
"MODEL_API_KEY",
"DEEPSEEK_API_KEY",
"OPENROUTER_API_KEY",
];
let saved: Vec<Option<String>> = vars.iter().map(|v| env::var(v).ok()).collect();
for v in vars {
Expand Down Expand Up @@ -2366,16 +2393,19 @@ mod tests {
"META_API_KEY",
"MODEL_API_KEY",
"DEEPSEEK_API_KEY",
"OPENROUTER_API_KEY",
];
let saved: Vec<Option<String>> = 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");
Expand All @@ -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");
Expand Down
5 changes: 5 additions & 0 deletions crates/adapter-smith/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/adapter-smith/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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
Expand Down
39 changes: 37 additions & 2 deletions crates/adapter-smith/src/provider/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<String>, api_key: String) -> Result<Self> {
let mut p = Self::with_config(base_url, api_key)?;
p.openrouter = true;
Ok(p)
}
}

fn role_str(r: Role) -> &'static str {
Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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")
Expand Down
49 changes: 48 additions & 1 deletion crates/adapter-smith/src/provider/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -102,6 +113,12 @@ pub fn parse_model_spec(s: &str) -> Result<ModelSpec, String> {
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,
Expand Down Expand Up @@ -141,6 +158,7 @@ pub fn parse_model_spec(s: &str) -> Result<ModelSpec, String> {
| "ollama"
| "grok"
| "deepseek"
| "openrouter"
| "grok-oauth"
| "codex-oauth"
| "claude-oauth"
Expand All @@ -150,7 +168,7 @@ pub fn parse_model_spec(s: &str) -> Result<ModelSpec, String> {
{
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:)"
));
}
}
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 9 additions & 0 deletions crates/adapter-smith/src/title_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ pub(crate) fn pick_default_spec_str() -> Result<String> {
// 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"
))
Expand All @@ -82,6 +86,11 @@ pub(crate) fn provider_for(p: Provider) -> Result<Box<dyn LlmProvider>> {
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
Expand Down
3 changes: 3 additions & 0 deletions crates/cli/src/app/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading