From a2deb318a6bab7c5404a0a6ed99c1301005ad714 Mon Sep 17 00:00:00 2001 From: Andres Sierra <102034926+AndreSierraM@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:09:28 -0500 Subject: [PATCH 1/3] feat(provider): add OpenRouter as an API-key provider riding the Codex CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter connects through the existing bundled codex binary pointed at OpenRouter's OpenAI-compatible endpoint with a Houston-managed API key — no parallel CLI or subscription flow. Engine - New `CodexBackend` on the `ProviderAdapter` trait: a provider that rides `codex exec` against a custom endpoint describes its slug / base_url / env_key / wire_api / default_model. The codex command builder turns it into `-c model_provider` overrides; the runner injects the API-key env var. So every codex dispatch site (session_dispatch, session_io, provider_oneshot, cli_process) treats "openai" | "openrouter" identically and the next OpenAI-compatible provider is one adapter file — no dispatch edits. - `wire_api = "responses"` (the bundled codex dropped "chat", codex#7782). - Adapter + HTTP-status error classifier (401/402/404/429/5xx/network → shared ProviderError taxonomy, fully unit-tested). - Generic key storage at `/providers//.env` (atomic, 0600); set/strip via engine-core; route `/providers/openrouter/credentials`. - Title / compaction / generate helpers wired for openrouter. Frontend - `providers.ts` entry (loginKind: apiKey) with a curated set of cheap, capable open-source models (DeepSeek V3 default, Llama 3.3 70B, Qwen2.5 72B, Mistral Nemo). Picker is data-driven, so it appears automatically. - Generic `ApiKeyConnectDialog` (replaces the gemini-specific dialog in the picker + settings) + generic `setProviderApiKey` client method. - Official OpenRouter logo, en/es/pt strings. Docs: agent-manifest, provider-errors, engine-protocol, auth. Co-Authored-By: Claude Opus 4.8 --- .../shell/api-key-connect-dialog.tsx | 162 +++++++++ app/src/components/shell/provider-logos.tsx | 10 + app/src/components/shell/provider-picker.tsx | 11 +- .../components/shell/provider-settings.tsx | 7 +- app/src/lib/providers.ts | 46 +++ app/src/lib/tauri.ts | 11 + app/src/locales/en/providers.json | 13 + app/src/locales/es/providers.json | 13 + app/src/locales/pt/providers.json | 13 + .../houston-engine-core/src/provider/mod.rs | 20 ++ .../src/provider/openrouter_credentials.rs | 168 +++++++++ .../src/sessions/compaction.rs | 1 + .../src/sessions/generate_instructions.rs | 5 + .../src/sessions/provider_oneshot.rs | 30 +- .../src/sessions/summarize.rs | 5 + .../src/routes/providers.rs | 24 ++ .../houston-engine-server/tests/providers.rs | 41 +++ .../src/cli_process.rs | 2 +- .../src/codex_command.rs | 82 ++++- .../src/codex_runner.rs | 10 + engine/houston-terminal-manager/src/lib.rs | 3 + .../src/provider/mod.rs | 122 +++++++ .../src/provider/openrouter.rs | 108 ++++++ .../src/provider/openrouter_classify.rs | 338 ++++++++++++++++++ .../src/provider/openrouter_credentials.rs | 71 ++++ .../src/provider_env.rs | 198 ++++++++++ .../src/session_dispatch.rs | 2 +- .../src/session_io.rs | 3 +- .../src/test_env_lock.rs | 17 + knowledge-base/agent-manifest.md | 23 +- knowledge-base/auth.md | 32 ++ knowledge-base/engine-protocol.md | 3 +- knowledge-base/provider-errors.md | 1 + ui/engine-client/src/client.ts | 11 + 34 files changed, 1575 insertions(+), 31 deletions(-) create mode 100644 app/src/components/shell/api-key-connect-dialog.tsx create mode 100644 engine/houston-engine-core/src/provider/openrouter_credentials.rs create mode 100644 engine/houston-terminal-manager/src/provider/openrouter.rs create mode 100644 engine/houston-terminal-manager/src/provider/openrouter_classify.rs create mode 100644 engine/houston-terminal-manager/src/provider/openrouter_credentials.rs create mode 100644 engine/houston-terminal-manager/src/provider_env.rs create mode 100644 engine/houston-terminal-manager/src/test_env_lock.rs diff --git a/app/src/components/shell/api-key-connect-dialog.tsx b/app/src/components/shell/api-key-connect-dialog.tsx new file mode 100644 index 000000000..9c6219aef --- /dev/null +++ b/app/src/components/shell/api-key-connect-dialog.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ExternalLink, Eye, EyeOff } from "lucide-react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Spinner, +} from "@houston-ai/core"; +import type { ProviderInfo } from "../../lib/providers"; +import { tauriProvider, tauriSystem } from "../../lib/tauri"; +import { useUIStore } from "../../stores/ui"; +import { analytics } from "../../lib/analytics"; + +/** + * Connect dialog for API-key-only providers (OpenRouter, …) — the ones with + * `loginKind: "apiKey"` and no CLI/OAuth sign-in. The user pastes a key; it's + * persisted by the engine (`tauriProvider.setProviderApiKey`) to that + * provider's credential store and injected into the CLI subprocess at spawn. + * + * Provider-driven (copy, console URL, env-var hint all come from + * `ProviderInfo`), so a new API-key provider needs no new dialog — just a + * `PROVIDERS` entry. Gemini keeps its own dialog because it leads with OAuth. + */ +export function ApiKeyConnectDialog(props: { + provider: ProviderInfo | null; + onOpenChange: (open: boolean) => void; + onSaved: (providerId: string) => void; +}) { + const { t } = useTranslation("providers"); + const addToast = useUIStore((s) => s.addToast); + + const [apiKey, setApiKey] = useState(""); + const [revealed, setRevealed] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const provider = props.provider; + const open = provider !== null; + const trimmed = apiKey.trim(); + const canSave = trimmed.length >= 10 && !saving; + + const reset = () => { + setApiKey(""); + setRevealed(false); + setError(null); + setSaving(false); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) reset(); + props.onOpenChange(next); + }; + + const handleOpenConsole = async () => { + if (!provider?.apiKeyConsoleUrl) return; + try { + await tauriSystem.openUrl(provider.apiKeyConsoleUrl); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addToast({ + title: t("apiKeyConnect.openConsoleFailed", { name: provider.name }), + description: msg, + variant: "error", + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!provider || !canSave) return; + setError(null); + setSaving(true); + try { + await tauriProvider.setProviderApiKey(provider.id, trimmed); + analytics.track("provider_configured", { provider: provider.id }); + const id = provider.id; + reset(); + props.onSaved(id); + props.onOpenChange(false); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addToast({ + title: t("apiKeyConnect.saveFailed", { name: provider.name }), + description: msg, + variant: "error", + }); + } finally { + setSaving(false); + } + }; + + return ( + + + + {t("apiKeyConnect.title", { name: provider?.name ?? "" })} + + {t("apiKeyConnect.description", { name: provider?.name ?? "" })} + + +
+ {provider?.apiKeyConsoleUrl && ( + + )} +
+ setApiKey(ev.target.value)} + placeholder={t("apiKeyConnect.placeholder")} + className="flex-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-[12px] font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + disabled={saving} + /> + +
+ {provider?.apiKeyEnvVar && ( +

+ {t("apiKeyConnect.envHint", { envVar: provider.apiKeyEnvVar })} +

+ )} + {error && ( +

+ {error} +

+ )} + +
+
+
+ ); +} diff --git a/app/src/components/shell/provider-logos.tsx b/app/src/components/shell/provider-logos.tsx index a9af16419..265807aa4 100644 --- a/app/src/components/shell/provider-logos.tsx +++ b/app/src/components/shell/provider-logos.tsx @@ -32,6 +32,14 @@ export function GeminiLogo({ className = "h-5 w-5" }: { className?: string } = { ); } +export function OpenRouterLogo({ className = "h-5 w-5" }: { className?: string } = {}) { + return ( + + + + ); +} + export function DeepSeekLogo() { return ( @@ -62,6 +70,8 @@ export function ProviderGlyph({ providerId }: { providerId: string }) { return ; case "openai": return ; + case "openrouter": + return ; case "gemini": return ; case "deepseek": diff --git a/app/src/components/shell/provider-picker.tsx b/app/src/components/shell/provider-picker.tsx index ae6f78f29..17c03f09f 100644 --- a/app/src/components/shell/provider-picker.tsx +++ b/app/src/components/shell/provider-picker.tsx @@ -12,7 +12,7 @@ import { useUIStore } from "../../stores/ui"; import { analytics } from "../../lib/analytics"; import { subscribeHoustonEvents } from "../../lib/events"; import { osIsTauri } from "../../lib/os-bridge"; -import { GeminiConnectDialog } from "./gemini-connect-dialog"; +import { ApiKeyConnectDialog } from "./api-key-connect-dialog"; import { ProviderLoginDialog } from "./provider-login-dialog"; import { ProviderCard, ComingSoonCard } from "./provider-cards"; @@ -272,7 +272,7 @@ export function ProviderPicker({ onSelect }: Props) { }} /> - { if (!open) setApiKeyDialogFor(null); @@ -285,13 +285,6 @@ export function ProviderPicker({ onSelect }: Props) { setPendingId(providerId); loadStatuses(); }} - onLoginStarted={(providerId) => { - // OAuth path: gemini-cli is now driving the browser flow. - // Arm the picker's status poll so the card flips to - // Connected the moment gemini-cli writes its credential - // files, same as the API-key save path above. - setPendingId(providerId); - }} /> - { if (!open) setApiKeyDialogFor(null); @@ -346,9 +346,6 @@ export function ProviderSettings() { setPendingId(providerId); loadStatuses(); }} - onLoginStarted={(providerId) => { - setPendingId(providerId); - }} /> /providers/openrouter/.env` and injected as + // OPENROUTER_API_KEY into the codex subprocess (engine handles the rest). + loginKind: "apiKey", + apiKeyConsoleUrl: "https://openrouter.ai/keys", + apiKeyEnvVar: "OPENROUTER_API_KEY", + // Curated cheap + capable OPEN-SOURCE slugs. Context windows are estimates + // (the indicator self-corrects upward from observed usage). Effort is + // intentionally omitted: these are non-reasoning chat models, so the picker + // hides the effort row for all of them. + models: [ + { + id: "deepseek/deepseek-chat", + label: "DeepSeek V3", + description: "Best value. Strong general + coding ability, very cheap.", + contextWindow: 64_000, + }, + { + id: "meta-llama/llama-3.3-70b-instruct", + label: "Llama 3.3 70B", + description: "Solid open flagship from Meta. Cheap and reliable.", + contextWindow: 128_000, + }, + { + id: "qwen/qwen-2.5-72b-instruct", + label: "Qwen2.5 72B", + description: "Strong multilingual + coding model from Alibaba.", + contextWindow: 32_768, + }, + { + id: "mistralai/mistral-nemo", + label: "Mistral Nemo", + description: "Lightweight and very cheap for simple tasks.", + contextWindow: 128_000, + }, + ], + defaultModel: "deepseek/deepseek-chat", + }, ] as const; /** Find a provider by id. */ diff --git a/app/src/lib/tauri.ts b/app/src/lib/tauri.ts index e5c60b82e..cfa2bafdb 100644 --- a/app/src/lib/tauri.ts +++ b/app/src/lib/tauri.ts @@ -898,6 +898,17 @@ export const tauriProvider = { */ setGeminiApiKey: (apiKey: string) => call("set_gemini_api_key", () => getEngine().setGeminiApiKey(apiKey)), + + /** + * Persist an API key for an API-key provider (OpenRouter, …) to its engine + * credential store. Generic over the provider id. Never log `apiKey` — it's + * a SECRET. Errors surface via `call`'s rejection path for the caller to + * toast with `errorMessage(err)`. + */ + setProviderApiKey: (providerId: string, apiKey: string) => + call("set_provider_api_key", () => + getEngine().setProviderApiKey(providerId, apiKey), + ), }; // ─── System (OS-native helpers, preserved for back-compat) ──────────── diff --git a/app/src/locales/en/providers.json b/app/src/locales/en/providers.json index 3bceb93bd..a36347c46 100644 --- a/app/src/locales/en/providers.json +++ b/app/src/locales/en/providers.json @@ -94,5 +94,18 @@ "saving": "Saving...", "saveFailed": "Couldn't save your {{name}} key", "cancel": "Cancel" + }, + "apiKeyConnect": { + "title": "Connect {{name}}", + "description": "Paste your {{name}} API key. It's saved on this device and used to run your agents.", + "openConsole": "Get an API key", + "openConsoleFailed": "Couldn't open {{name}}", + "placeholder": "Paste your API key here", + "show": "Show key", + "hide": "Hide key", + "envHint": "Saved as {{envVar}} on this device.", + "saveKey": "Save and connect", + "saving": "Saving...", + "saveFailed": "Couldn't save your {{name}} key" } } diff --git a/app/src/locales/es/providers.json b/app/src/locales/es/providers.json index 029719788..bd775dc87 100644 --- a/app/src/locales/es/providers.json +++ b/app/src/locales/es/providers.json @@ -94,5 +94,18 @@ "saving": "Guardando...", "saveFailed": "No se pudo guardar tu llave de {{name}}", "cancel": "Cancelar" + }, + "apiKeyConnect": { + "title": "Conecta {{name}}", + "description": "Pega tu llave de API de {{name}}. Se guarda en este dispositivo y se usa para ejecutar tus agentes.", + "openConsole": "Obtener una llave de API", + "openConsoleFailed": "No se pudo abrir {{name}}", + "placeholder": "Pega aquí tu llave de API", + "show": "Mostrar la llave", + "hide": "Ocultar la llave", + "envHint": "Se guarda como {{envVar}} en este dispositivo.", + "saveKey": "Guardar y conectar", + "saving": "Guardando...", + "saveFailed": "No se pudo guardar tu llave de {{name}}" } } diff --git a/app/src/locales/pt/providers.json b/app/src/locales/pt/providers.json index d1baeb1ea..2c0dfab41 100644 --- a/app/src/locales/pt/providers.json +++ b/app/src/locales/pt/providers.json @@ -94,5 +94,18 @@ "saving": "Salvando...", "saveFailed": "Não foi possível salvar sua chave do {{name}}", "cancel": "Cancelar" + }, + "apiKeyConnect": { + "title": "Conectar {{name}}", + "description": "Cole sua chave de API da {{name}}. Ela fica salva neste dispositivo e é usada para executar seus agentes.", + "openConsole": "Obter uma chave de API", + "openConsoleFailed": "Não foi possível abrir {{name}}", + "placeholder": "Cole aqui sua chave de API", + "show": "Mostrar a chave", + "hide": "Ocultar a chave", + "envHint": "Salva como {{envVar}} neste dispositivo.", + "saveKey": "Salvar e conectar", + "saving": "Salvando...", + "saveFailed": "Não foi possível salvar sua chave da {{name}}" } } diff --git a/engine/houston-engine-core/src/provider/mod.rs b/engine/houston-engine-core/src/provider/mod.rs index 2d869ffb4..0abb1faf6 100644 --- a/engine/houston-engine-core/src/provider/mod.rs +++ b/engine/houston-engine-core/src/provider/mod.rs @@ -14,10 +14,12 @@ mod gemini_credentials; mod gemini_disconnect; mod gemini_login; mod login_relay; +mod openrouter_credentials; pub use gemini_credentials::set_gemini_api_key; pub use gemini_disconnect::disconnect_gemini; pub use login_relay::{cancel_login, submit_login_code}; +pub use openrouter_credentials::set_openrouter_api_key; use crate::error::{CoreError, CoreResult}; use houston_engine_protocol::ErrorCode; @@ -121,6 +123,18 @@ pub async fn launch_login( return gemini_login::launch_login(path).await; } + // OpenRouter (and any future API-key provider) has no CLI login flow — + // the user pastes a key through `/providers/openrouter/credentials`. The + // picker routes API-key providers to the connect dialog and never calls + // this, but a direct caller gets a clear error instead of a confusing + // "no login args" failure deeper down. + if provider.codex_backend().is_some() { + return Err(CoreError::BadRequest(format!( + "{} uses an API key, not a sign-in flow. Save your API key in settings.", + provider.id() + ))); + } + let ProviderCliCommand { cli_name, path, @@ -291,6 +305,12 @@ pub async fn launch_logout(provider: Provider) -> CoreResult<()> { return disconnect_gemini().await; } + // OpenRouter has no CLI logout — "disconnect" means deleting the stored + // key so the next status read shows "Unauthenticated". + if provider.id() == "openrouter" { + return openrouter_credentials::strip_openrouter_api_key_storage().await; + } + let ProviderCliCommand { cli_name, path, diff --git a/engine/houston-engine-core/src/provider/openrouter_credentials.rs b/engine/houston-engine-core/src/provider/openrouter_credentials.rs new file mode 100644 index 000000000..5c5ee634b --- /dev/null +++ b/engine/houston-engine-core/src/provider/openrouter_credentials.rs @@ -0,0 +1,168 @@ +//! Persist / clear the Houston-managed OpenRouter API key at +//! `/providers/openrouter/.env`. +//! +//! The Codex runner injects this key as `OPENROUTER_API_KEY` into the spawned +//! `codex` process (see `houston_terminal_manager::provider::codex_backend_env`) +//! so the `model_providers.openrouter` config can authenticate against +//! `https://openrouter.ai/api/v1`. +//! +//! Path resolution + the `.env` line-merge live in +//! `houston_terminal_manager::provider_env` (the runner reads the same file), +//! so the engine writes and the runner reads exactly the same place. Safety +//! mirrors `gemini_credentials`: secret never logged, atomic stage+rename, +//! mode `0600` on Unix. + +use crate::error::{CoreError, CoreResult}; +use houston_terminal_manager::provider_env::{ + apply_owner_only_perms, canonical_env_path, is_env_var_line, merge_env_contents, tmp_path_for, +}; +use tokio::io::AsyncWriteExt; + +const ENV_VAR: &str = "OPENROUTER_API_KEY"; +const PROVIDER: &str = "openrouter"; + +/// Validate the pasted key, then persist it atomically with owner-only perms. +pub async fn set_openrouter_api_key(api_key: &str) -> CoreResult<()> { + let trimmed = validate_key(api_key)?; + let env_path = canonical_env_path(PROVIDER); + let parent = env_path + .parent() + .ok_or_else(|| CoreError::Internal("openrouter env path has no parent directory".into()))?; + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| CoreError::Internal(format!("failed to create {}: {e}", parent.display())))?; + let existing = match tokio::fs::read_to_string(&env_path).await { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => { + return Err(CoreError::Internal(format!( + "failed to read {}: {e}", + env_path.display() + ))) + } + }; + let updated = merge_env_contents(&existing, ENV_VAR, trimmed); + write_atomic(&env_path, updated.as_bytes()).await?; + tracing::info!( + "[openrouter-creds] wrote {} (key length={} chars)", + env_path.display(), + trimmed.len() + ); + Ok(()) +} + +/// Remove the stored key on disconnect: drop the `OPENROUTER_API_KEY=` line, +/// deleting the file if nothing else remains. Idempotent (no-op when absent). +pub async fn strip_openrouter_api_key_storage() -> CoreResult<()> { + let env_path = canonical_env_path(PROVIDER); + let existing = match tokio::fs::read_to_string(&env_path).await { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(CoreError::Internal(format!( + "failed to read {}: {e}", + env_path.display() + ))) + } + }; + let stripped: String = existing + .split_inclusive('\n') + .filter(|line| !is_env_var_line(line, ENV_VAR)) + .collect(); + if stripped == existing { + return Ok(()); + } + if stripped.trim().is_empty() { + tokio::fs::remove_file(&env_path).await.map_err(|e| { + CoreError::Internal(format!("failed to remove {}: {e}", env_path.display())) + })?; + return Ok(()); + } + write_atomic(&env_path, stripped.as_bytes()).await +} + +fn validate_key(api_key: &str) -> CoreResult<&str> { + let trimmed = api_key.trim(); + if trimmed.is_empty() { + return Err(CoreError::BadRequest("API key cannot be empty".into())); + } + if trimmed.len() < 16 || trimmed.len() > 512 { + return Err(CoreError::BadRequest( + "API key length looks wrong. Paste the full OpenRouter key (starts with sk-or-).".into(), + )); + } + if trimmed.chars().any(|c| c.is_whitespace()) { + return Err(CoreError::BadRequest( + "API key cannot contain whitespace. Paste only the key value.".into(), + )); + } + if trimmed.contains('"') || trimmed.contains('\'') { + return Err(CoreError::BadRequest( + "API key cannot contain quote characters. Paste the raw key value.".into(), + )); + } + Ok(trimmed) +} + +/// Stage to `.env.tmp` + rename (atomic on the same filesystem), chmod 0600. +async fn write_atomic(final_path: &std::path::Path, bytes: &[u8]) -> CoreResult<()> { + let tmp_path = tmp_path_for(final_path); + { + let mut f = tokio::fs::File::create(&tmp_path).await.map_err(|e| { + CoreError::Internal(format!("failed to open {} for writing: {e}", tmp_path.display())) + })?; + f.write_all(bytes) + .await + .map_err(|e| CoreError::Internal(format!("failed to write {}: {e}", tmp_path.display())))?; + f.sync_all() + .await + .map_err(|e| CoreError::Internal(format!("failed to fsync {}: {e}", tmp_path.display())))?; + } + apply_owner_only_perms(&tmp_path).map_err(|e| { + CoreError::Internal(format!("failed to chmod 0600 on {}: {e}", tmp_path.display())) + })?; + tokio::fs::rename(&tmp_path, final_path).await.map_err(|e| { + CoreError::Internal(format!( + "failed to rename {} to {}: {e}", + tmp_path.display(), + final_path.display() + )) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_rejects_empty_and_whitespace() { + assert!(matches!(validate_key(""), Err(CoreError::BadRequest(_)))); + assert!(matches!(validate_key(" "), Err(CoreError::BadRequest(_)))); + assert!(matches!( + validate_key("sk-or v1 spaced key here"), + Err(CoreError::BadRequest(_)) + )); + } + + #[test] + fn validate_rejects_too_short_or_long() { + assert!(matches!(validate_key("abc"), Err(CoreError::BadRequest(_)))); + let huge = "a".repeat(600); + assert!(matches!(validate_key(&huge), Err(CoreError::BadRequest(_)))); + } + + #[test] + fn validate_rejects_quotes() { + assert!(matches!( + validate_key("sk-or-v1-\"quoted-key-value\""), + Err(CoreError::BadRequest(_)) + )); + } + + #[test] + fn validate_accepts_well_formed_key_and_trims() { + let key = " sk-or-v1-0123456789abcdef0123456789 "; + assert_eq!(validate_key(key).unwrap(), "sk-or-v1-0123456789abcdef0123456789"); + } +} diff --git a/engine/houston-engine-core/src/sessions/compaction.rs b/engine/houston-engine-core/src/sessions/compaction.rs index 5684a3d94..43ac27a4b 100644 --- a/engine/houston-engine-core/src/sessions/compaction.rs +++ b/engine/houston-engine-core/src/sessions/compaction.rs @@ -42,6 +42,7 @@ fn fallback_summary_model(provider: Provider) -> Option<&'static str> { match provider.id() { "anthropic" => Some("haiku"), "openai" => Some("gpt-5.5-mini"), + "openrouter" => Some("deepseek/deepseek-chat"), "gemini" => Some("gemini-3.1-flash-lite"), _ => None, } diff --git a/engine/houston-engine-core/src/sessions/generate_instructions.rs b/engine/houston-engine-core/src/sessions/generate_instructions.rs index 7981884a2..9a3f9afdf 100644 --- a/engine/houston-engine-core/src/sessions/generate_instructions.rs +++ b/engine/houston-engine-core/src/sessions/generate_instructions.rs @@ -26,6 +26,10 @@ const CODEX_GEN_MODEL: &str = "gpt-5.5"; /// retries → "exhausted capacity" → ~4-minute hang). Flash-Lite produces /// a usable CLAUDE.md in well under the 60s GENERATE_TIMEOUT. const GEMINI_GEN_MODEL: &str = "gemini-3.1-flash-lite"; +/// OpenRouter generation model. Uses the same cheap open-source default the +/// OpenRouter adapter falls back to (`deepseek/deepseek-chat`) so +/// Create-with-AI produces a solid CLAUDE.md within the timeout. +const OPENROUTER_GEN_MODEL: &str = "deepseek/deepseek-chat"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -91,6 +95,7 @@ fn default_gen_model<'a>(provider: Provider, model_override: Option<&'a str>) -> let default = match provider.id() { "anthropic" => CLAUDE_GEN_MODEL, "openai" => CODEX_GEN_MODEL, + "openrouter" => OPENROUTER_GEN_MODEL, "gemini" => GEMINI_GEN_MODEL, _ => return None, }; diff --git a/engine/houston-engine-core/src/sessions/provider_oneshot.rs b/engine/houston-engine-core/src/sessions/provider_oneshot.rs index fff697063..cbf8a737c 100644 --- a/engine/houston-engine-core/src/sessions/provider_oneshot.rs +++ b/engine/houston-engine-core/src/sessions/provider_oneshot.rs @@ -35,7 +35,8 @@ pub async fn run_provider_oneshot( ) -> Result { match provider.id() { "anthropic" => run_claude(prompt, model, time_limit).await, - "openai" => run_codex(prompt, model, time_limit).await, + // OpenRouter rides codex with a custom model-provider config. + "openai" | "openrouter" => run_codex(provider, prompt, model, time_limit).await, "gemini" => run_gemini(prompt, model, time_limit).await, unknown => Err(format!( "no one-shot invocation wired up for provider {unknown:?}" @@ -58,7 +59,12 @@ async fn run_claude(prompt: &str, model: &str, time_limit: Duration) -> Result Result { +async fn run_codex( + provider: Provider, + prompt: &str, + model: &str, + time_limit: Duration, +) -> Result { // Prefer the bundled codex (pinned in `cli-deps.json`) so one-shot // generation can't get sabotaged by a stale `nvm`/`brew` codex on the // user's PATH that doesn't recognize the model we picked. @@ -66,6 +72,13 @@ async fn run_codex(prompt: &str, model: &str, time_limit: Duration) -> Result.env_key` config references. + if let Some((key, value)) = + houston_terminal_manager::provider::codex_backend_env(provider) + { + cmd.env(key, value); + } cmd.arg("exec") .arg("--json") .arg("--dangerously-bypass-approvals-and-sandbox") @@ -75,10 +88,15 @@ async fn run_codex(prompt: &str, model: &str, time_limit: Duration) -> Result(provider: Provider, model_override: Option<&'a str>) let default = match provider.id() { "anthropic" => CLAUDE_TITLE_MODEL, "openai" => CODEX_TITLE_MODEL, + "openrouter" => OPENROUTER_TITLE_MODEL, "gemini" => GEMINI_TITLE_MODEL, _ => return None, }; diff --git a/engine/houston-engine-server/src/routes/providers.rs b/engine/houston-engine-server/src/routes/providers.rs index 40f806a0d..a96e47c6b 100644 --- a/engine/houston-engine-server/src/routes/providers.rs +++ b/engine/houston-engine-server/src/routes/providers.rs @@ -56,6 +56,14 @@ pub fn router() -> Router> { "/providers/gemini/credentials", post(gemini_set_credentials), ) + // OpenRouter-only: persist the API key the user pasted in the connect + // dialog to `/providers/openrouter/.env`. Houston injects + // it as `OPENROUTER_API_KEY` into the codex subprocess at spawn time + // (OpenRouter rides the Codex CLI against its OpenAI-compatible API). + .route( + "/providers/openrouter/credentials", + post(openrouter_set_credentials), + ) } async fn status( @@ -140,3 +148,19 @@ async fn gemini_set_credentials( Ok(()) } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpenRouterCredentials { + /// Raw API key the user pasted. Validated + persisted by + /// `houston_engine_core::provider::set_openrouter_api_key`. NEVER logged. + api_key: String, +} + +async fn openrouter_set_credentials( + State(_st): State>, + Json(body): Json, +) -> Result<(), ApiError> { + provider::set_openrouter_api_key(&body.api_key).await?; + Ok(()) +} + diff --git a/engine/houston-engine-server/tests/providers.rs b/engine/houston-engine-server/tests/providers.rs index 97e1fe4c4..e3069f978 100644 --- a/engine/houston-engine-server/tests/providers.rs +++ b/engine/houston-engine-server/tests/providers.rs @@ -94,6 +94,47 @@ async fn status_returns_shape_for_gemini() { )); } +#[tokio::test] +async fn status_returns_shape_for_openrouter() { + // OpenRouter rides the bundled codex binary; assert wire shape only. + let (addr, tok) = spawn().await; + let body: serde_json::Value = reqwest::Client::new() + .get(format!("http://{addr}/v1/providers/openrouter/status")) + .bearer_auth(&tok) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(body["provider"], "openrouter"); + assert_eq!(body["cliName"], "codex"); + assert!(body["cliInstalled"].is_boolean()); + assert!(matches!( + body["authState"].as_str(), + Some("authenticated" | "unauthenticated" | "unknown") + )); + assert!(matches!( + body["installSource"].as_str(), + Some("bundled" | "managed" | "path" | "missing") + )); +} + +#[tokio::test] +async fn openrouter_credentials_rejects_empty_and_malformed_key() { + let (addr, tok) = spawn().await; + for bad in ["", "abc", "sk or v1 with spaces in it here"] { + let res = reqwest::Client::new() + .post(format!("http://{addr}/v1/providers/openrouter/credentials")) + .bearer_auth(&tok) + .json(&serde_json::json!({ "apiKey": bad })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400, "key {bad:?} should be rejected"); + } +} + #[tokio::test] async fn gemini_credentials_rejects_empty_key() { let (addr, tok) = spawn().await; diff --git a/engine/houston-terminal-manager/src/cli_process.rs b/engine/houston-terminal-manager/src/cli_process.rs index 24eefeda4..c95764956 100644 --- a/engine/houston-terminal-manager/src/cli_process.rs +++ b/engine/houston-terminal-manager/src/cli_process.rs @@ -196,7 +196,7 @@ fn handle_failed_exit( // typed `SessionResumeMissing` variant DOES fire from the // line-by-line classifier in `read_stderr_lines`, but that surface // is an information panel; the retry routing belongs here. - if provider.id() == "openai" + if matches!(provider.id(), "openai" | "openrouter") && stderr_lines .iter() .any(|line| codex_command::is_missing_rollout_error(line)) diff --git a/engine/houston-terminal-manager/src/codex_command.rs b/engine/houston-terminal-manager/src/codex_command.rs index ec0d50dba..57ff7d15f 100644 --- a/engine/houston-terminal-manager/src/codex_command.rs +++ b/engine/houston-terminal-manager/src/codex_command.rs @@ -1,6 +1,9 @@ use std::ffi::OsString; use std::path::Path; +use crate::provider::CodexBackend; +use crate::Provider; + /// Build `codex exec` args with exec-level flags before the optional /// `resume` subcommand. Older Codex CLIs reject global flags placed after /// `resume `. @@ -11,7 +14,14 @@ use std::path::Path; /// (os error 206) for agents with large accumulated context. It now lives /// in a profile file (`prompt_scratch::codex_profile`) selected here by /// name via `-p`. +/// +/// `provider` lets a [`CodexBackend`] provider (OpenRouter, …) ride this +/// same builder: when `provider.codex_backend()` is `Some`, the +/// model-provider `-c` overrides are emitted and its `default_model` fills in +/// when the agent hasn't picked one. Native codex (OpenAI) returns `None`, so +/// not a single argv byte changes for it. pub(crate) fn build_args( + provider: Provider, resume_session_id: Option<&str>, working_dir: Option<&Path>, model: Option<&str>, @@ -42,6 +52,17 @@ pub(crate) fn build_args( args.push(OsString::from(format!("model_reasoning_effort=\"{e}\""))); } + // Custom OpenAI-compatible backend (OpenRouter, …): point codex at the + // provider's endpoint via `-c` overrides (highest precedence). Empty for + // native codex. + let backend = provider.codex_backend(); + if let Some(ref b) = backend { + args.extend(codex_backend_overrides(b)); + } + + // The agent's pick wins; otherwise fall back to the backend's default so + // a freshly connected OpenRouter agent can chat without choosing a model. + let model = model.or(backend.and_then(|b| b.default_model)); if let Some(m) = model { args.push(OsString::from("--model")); args.push(OsString::from(m)); @@ -61,6 +82,17 @@ pub(crate) fn build_args( args } +/// `-c model_provider` / `model_providers..*` overrides describing a +/// custom OpenAI-compatible endpoint. The API key is NOT here — it rides as +/// the `env_key` environment variable, injected by the runner via +/// [`crate::provider::codex_backend_env`]. +fn codex_backend_overrides(b: &CodexBackend) -> Vec { + b.config_overrides() + .into_iter() + .flat_map(|value| [OsString::from("-c"), OsString::from(value)]) + .collect() +} + pub(crate) fn is_missing_rollout_error(line: &str) -> bool { let lower = line.to_lowercase(); lower.contains("thread/resume") @@ -72,6 +104,15 @@ pub(crate) fn is_missing_rollout_error(line: &str) -> bool { mod tests { use super::*; use std::path::PathBuf; + use std::str::FromStr; + + fn openai() -> Provider { + Provider::from_str("openai").unwrap() + } + + fn openrouter() -> Provider { + Provider::from_str("openrouter").unwrap() + } fn strings(args: Vec) -> Vec { args.into_iter() @@ -83,6 +124,7 @@ mod tests { fn resume_args_keep_exec_flags_before_subcommand() { let dir = PathBuf::from("/tmp/work"); let args = strings(build_args( + openai(), Some("019dd59b-5e8c-7f63-a8c6-18fb825874ad"), Some(&dir), Some("gpt-5.5"), @@ -109,6 +151,7 @@ mod tests { #[test] fn argv_length_is_independent_of_prompt_size() { let args = strings(build_args( + openai(), None, None, Some("gpt-5.5"), @@ -128,7 +171,7 @@ mod tests { #[test] fn fresh_args_read_prompt_from_stdin() { - let args = strings(build_args(None, None, None, None, None)); + let args = strings(build_args(openai(), None, None, None, None, None)); assert_eq!(args.last().map(String::as_str), Some("-")); assert!(!args.iter().any(|arg| arg == "resume")); @@ -136,7 +179,7 @@ mod tests { #[test] fn effort_emits_model_reasoning_effort_override() { - let args = strings(build_args(None, None, None, Some("medium"), None)); + let args = strings(build_args(openai(), None, None, None, Some("medium"), None)); let pos = args .iter() .position(|arg| arg == "model_reasoning_effort=\"medium\"") @@ -145,6 +188,41 @@ mod tests { assert_eq!(args[pos - 1], "-c"); } + #[test] + fn native_codex_emits_no_model_provider_override() { + let args = strings(build_args(openai(), None, None, Some("gpt-5.5"), None, None)); + assert!( + !args.iter().any(|a| a.starts_with("model_provider")), + "native codex must not carry a model_provider override: {args:?}" + ); + } + + #[test] + fn openrouter_emits_process_local_provider_overrides() { + let args = strings(build_args(openrouter(), None, None, Some("x-ai/grok-2"), None, None)); + for expected in [ + r#"model_provider="openrouter""#, + r#"model_providers.openrouter.base_url="https://openrouter.ai/api/v1""#, + r#"model_providers.openrouter.env_key="OPENROUTER_API_KEY""#, + r#"model_providers.openrouter.wire_api="responses""#, + ] { + assert!( + args.iter().any(|a| a == expected), + "missing override {expected:?} in {args:?}" + ); + } + } + + #[test] + fn openrouter_without_model_uses_backend_default() { + let args = strings(build_args(openrouter(), None, None, None, None, None)); + let pos = args + .iter() + .position(|arg| arg == "--model") + .expect("--model should be present"); + assert_eq!(args[pos + 1], "deepseek/deepseek-chat"); + } + #[test] fn detects_codex_missing_rollout_error() { assert!(is_missing_rollout_error( diff --git a/engine/houston-terminal-manager/src/codex_runner.rs b/engine/houston-terminal-manager/src/codex_runner.rs index cd599e44d..7386acef0 100644 --- a/engine/houston-terminal-manager/src/codex_runner.rs +++ b/engine/houston-terminal-manager/src/codex_runner.rs @@ -63,6 +63,7 @@ pub(crate) async fn spawn_codex( }; let mut cmd = build_codex_command( + provider, resume_session_id.as_deref(), working_dir.as_deref(), model.as_deref(), @@ -75,6 +76,7 @@ pub(crate) async fn spawn_codex( tracing::warn!("[houston:session] codex resume rollout missing; retrying with fresh thread"); let _ = tx.send(SessionUpdate::ResumeInvalid); let mut fresh_cmd = build_codex_command( + provider, None, working_dir.as_deref(), model.as_deref(), @@ -96,6 +98,7 @@ fn fresh_retry_prompt<'a>(prompt: &'a str, resume_fallback_prompt: Option<&'a st } fn build_codex_command( + provider: Provider, resume_session_id: Option<&str>, working_dir: Option<&std::path::Path>, model: Option<&str>, @@ -114,7 +117,14 @@ fn build_codex_command( .unwrap_or_else(|| std::path::PathBuf::from("codex")); let mut cmd = Command::new(&bin); cmd.env("PATH", super::claude_path::shell_path()); + // Custom OpenAI-compatible backends (OpenRouter, …) authenticate via an + // env var named by `model_providers..env_key`; inject it here. + // Native codex (OpenAI) returns `None` and is untouched. + if let Some((key, value)) = crate::provider::codex_backend_env(provider) { + cmd.env(key, value); + } cmd.args(codex_command::build_args( + provider, resume_session_id, working_dir, model, diff --git a/engine/houston-terminal-manager/src/lib.rs b/engine/houston-terminal-manager/src/lib.rs index ae525a8ae..afb51febb 100644 --- a/engine/houston-terminal-manager/src/lib.rs +++ b/engine/houston-terminal-manager/src/lib.rs @@ -23,6 +23,7 @@ mod prompt_scratch; pub mod parser; pub mod provider; pub mod provider_auth; +pub mod provider_env; mod provider_error; pub mod provider_error_kind; mod session_dispatch; @@ -30,6 +31,8 @@ pub mod session_io; pub mod session_pump; mod session_update; mod stderr_filter; +#[cfg(test)] +mod test_env_lock; pub mod types; // Re-export key types for convenience. diff --git a/engine/houston-terminal-manager/src/provider/mod.rs b/engine/houston-terminal-manager/src/provider/mod.rs index 025fe287d..b5e22c75f 100644 --- a/engine/houston-terminal-manager/src/provider/mod.rs +++ b/engine/houston-terminal-manager/src/provider/mod.rs @@ -27,6 +27,9 @@ mod gemini; mod openai; mod openai_classify; pub(crate) mod openai_login; +mod openrouter; +mod openrouter_classify; +pub(crate) mod openrouter_credentials; mod resolve; pub(crate) use anthropic_classify::detect_malformed_provider_json; @@ -67,6 +70,53 @@ pub struct LoginFailureHint { pub message: String, } +/// Describes a provider that rides the Codex CLI against a custom +/// OpenAI-compatible endpoint (OpenRouter today; any OpenAI-compatible +/// endpoint tomorrow). [`ProviderAdapter::codex_backend`] returns `None` +/// for native Codex (OpenAI/ChatGPT) and `Some` for these. +/// +/// The codex command builder turns it into `-c model_provider` / +/// `model_providers..*` overrides; the runner injects `env_key` (from +/// `/providers//.env`, or a shell override) into the +/// spawned process. Because every codex dispatch site reads this off the +/// adapter, adding the next OpenAI-compatible provider is one more adapter +/// file — no dispatch-site edits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CodexBackend { + /// Provider id; also the `model_providers.` table name and the + /// `/providers//.env` directory. + pub slug: &'static str, + /// Human label codex shows for the provider (`model_providers..name`). + pub display_name: &'static str, + /// OpenAI-compatible base URL (`model_providers..base_url`). + pub base_url: &'static str, + /// Env var codex reads the API key from (`model_providers..env_key`). + pub env_key: &'static str, + /// Codex wire protocol: `"chat"` (Chat Completions) or `"responses"`. + pub wire_api: &'static str, + /// Model used when the agent hasn't picked one. `None` = let codex decide. + pub default_model: Option<&'static str>, +} + +impl CodexBackend { + /// The `key=value` strings codex needs to route to this endpoint, ready + /// to pass each as the value of a `-c` flag (highest config precedence, + /// so they win over any stale `~/.codex/config.toml`). The API key is NOT + /// here — it rides the `env_key` environment variable (see + /// [`codex_backend_env`]). Shared by `codex_command::build_args` (chat + /// sessions) and `sessions::provider_oneshot` (titles / compaction) so + /// both spawn codex with identical provider config. + pub fn config_overrides(&self) -> Vec { + vec![ + format!(r#"model_provider="{}""#, self.slug), + format!(r#"model_providers.{}.name="{}""#, self.slug, self.display_name), + format!(r#"model_providers.{}.base_url="{}""#, self.slug, self.base_url), + format!(r#"model_providers.{}.env_key="{}""#, self.slug, self.env_key), + format!(r#"model_providers.{}.wire_api="{}""#, self.slug, self.wire_api), + ] + } +} + /// One AI provider's CLI integration. Every method is intended to be /// cheap to call repeatedly — the registry hands out shared `&'static` /// references and there is no per-call setup. @@ -92,6 +142,14 @@ pub trait ProviderAdapter: Send + Sync + 'static { /// on PATH, or missing) and the absolute path to spawn. fn resolve(&self) -> (InstallSource, Option); + /// Codex backend description for providers that ride `codex exec` + /// against a custom OpenAI-compatible endpoint (OpenRouter, …). `None` + /// (the default) means native Codex or a non-codex provider. See + /// [`CodexBackend`]. + fn codex_backend(&self) -> Option { + None + } + /// Probe whether the user is currently authenticated with this /// provider's CLI. Receives the resolved CLI path. fn probe_auth<'a>(&'a self, cli_path: &'a Path) -> ProbeFuture<'a>; @@ -228,6 +286,7 @@ pub trait ProviderAdapter: Send + Sync + 'static { const REGISTRY: &[&dyn ProviderAdapter] = &[ &anthropic::ANTHROPIC, &openai::OPENAI, + &openrouter::OPENROUTER, &gemini::GEMINI, ]; @@ -261,6 +320,26 @@ pub fn default_provider() -> Provider { Provider(DEFAULT_PROVIDER) } +/// The `(env_key, value)` to inject into a codex subprocess for a +/// [`CodexBackend`] provider, or `None` for native codex / no key available. +/// +/// A shell value for the backend's `env_key` wins (lets power users override +/// without re-saving); otherwise the Houston-stored key at +/// `/providers//.env` is used. Generic over every +/// codex-backend provider — both `codex_runner` (sessions) and +/// `provider_oneshot` (titles/compaction) inject through this one function. +pub fn codex_backend_env(provider: Provider) -> Option<(&'static str, String)> { + let backend = provider.codex_backend()?; + if let Ok(value) = std::env::var(backend.env_key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some((backend.env_key, trimmed.to_string())); + } + } + crate::provider_env::read_stored_api_key(backend.slug, backend.env_key) + .map(|value| (backend.env_key, value)) +} + // ----------------------------------------------------------------------- // Provider — Copy newtype over &'static dyn ProviderAdapter // ----------------------------------------------------------------------- @@ -288,6 +367,13 @@ impl Provider { self.0.resolve() } + /// Codex backend description, if this provider rides `codex exec` + /// against a custom OpenAI-compatible endpoint. See + /// [`ProviderAdapter::codex_backend`]. + pub fn codex_backend(self) -> Option { + self.0.codex_backend() + } + /// Probe authentication state for this provider's CLI. pub async fn probe_auth(self, cli_path: &Path) -> ProviderAuthState { self.0.probe_auth(cli_path).await @@ -450,6 +536,42 @@ mod tests { assert!(Provider::from_str("nonexistent-provider").is_err()); } + #[test] + fn parse_registers_openrouter() { + assert_eq!(Provider::from_str("openrouter").unwrap().id(), "openrouter"); + } + + #[test] + fn codex_backend_present_for_openrouter_absent_for_openai() { + let or = Provider::from_str("openrouter").unwrap(); + let backend = or.codex_backend().expect("openrouter has a codex backend"); + assert_eq!(backend.slug, "openrouter"); + assert_eq!(backend.base_url, "https://openrouter.ai/api/v1"); + assert_eq!(backend.env_key, "OPENROUTER_API_KEY"); + assert_eq!(backend.wire_api, "responses"); + + let openai = Provider::from_str("openai").unwrap(); + assert!(openai.codex_backend().is_none()); + assert!(codex_backend_env(openai).is_none()); + } + + #[test] + fn codex_backend_env_prefers_shell_override() { + let _guard = crate::test_env_lock::lock_env_test(); + let or = Provider::from_str("openrouter").unwrap(); + let prior = std::env::var_os("OPENROUTER_API_KEY"); + std::env::set_var("OPENROUTER_API_KEY", "sk-or-v1-shell"); + + let (key, value) = codex_backend_env(or).expect("shell key resolves"); + assert_eq!(key, "OPENROUTER_API_KEY"); + assert_eq!(value, "sk-or-v1-shell"); + + match prior { + Some(v) => std::env::set_var("OPENROUTER_API_KEY", v), + None => std::env::remove_var("OPENROUTER_API_KEY"), + } + } + #[test] fn display_renders_id() { let p = Provider::from_str("anthropic").unwrap(); diff --git a/engine/houston-terminal-manager/src/provider/openrouter.rs b/engine/houston-terminal-manager/src/provider/openrouter.rs new file mode 100644 index 000000000..8b95fa754 --- /dev/null +++ b/engine/houston-terminal-manager/src/provider/openrouter.rs @@ -0,0 +1,108 @@ +//! OpenRouter adapter — the Codex CLI pointed at OpenRouter's +//! OpenAI-compatible endpoint with a Houston-managed API key. +//! +//! OpenRouter ships no CLI of its own; it rides `codex exec` exactly like +//! the OpenAI provider, differing only in the `model_providers.openrouter` +//! config overrides and the `OPENROUTER_API_KEY` env var the runner injects. +//! That difference is fully described by [`CodexBackend`], so every codex +//! dispatch site (`session_dispatch`, `session_io`, `provider_oneshot`, +//! `cli_process`) treats OpenRouter as "codex" without an OpenRouter-specific +//! branch — and the next OpenAI-compatible provider is one more adapter file. + +use super::openrouter_classify; +use super::openrouter_credentials; +use super::resolve::{which_on_path, InstallSource}; +use super::{CodexBackend, ProbeFuture, ProviderAdapter}; +use crate::provider_auth::ProviderAuthState; +use crate::provider_error_kind::ProviderError; +use std::path::{Path, PathBuf}; + +pub(super) struct OpenRouterAdapter; + +pub(super) static OPENROUTER: OpenRouterAdapter = OpenRouterAdapter; + +/// Model used when an agent on OpenRouter hasn't picked one. A cheap, strong +/// open-source slug so a fresh connect can chat immediately at low cost. +pub(super) const OPENROUTER_DEFAULT_MODEL: &str = "deepseek/deepseek-chat"; + +impl ProviderAdapter for OpenRouterAdapter { + fn id(&self) -> &'static str { + "openrouter" + } + + fn cli_name(&self) -> &'static str { + "codex" + } + + fn resolve(&self) -> (InstallSource, Option) { + // Same binary as OpenAI: the bundled codex, or one on PATH. + if let Some(path) = houston_cli_bundle::bundled_codex_path() { + return (InstallSource::Bundled, Some(path)); + } + if let Some(path) = which_on_path("codex") { + return (InstallSource::Path, Some(path)); + } + (InstallSource::Missing, None) + } + + fn codex_backend(&self) -> Option { + Some(CodexBackend { + slug: "openrouter", + display_name: "OpenRouter", + base_url: "https://openrouter.ai/api/v1", + env_key: openrouter_credentials::ENV_VAR, + // Must be "responses": the bundled codex removed support for + // `wire_api = "chat"` (codex#7782 — config load now errors on it), + // so it speaks only the OpenAI Responses API, which OpenRouter + // serves at `/api/v1/responses`. + wire_api: "responses", + default_model: Some(OPENROUTER_DEFAULT_MODEL), + }) + } + + fn probe_auth<'a>(&'a self, _cli_path: &'a Path) -> ProbeFuture<'a> { + Box::pin(async move { + // Status reflects the Houston-stored key only. A shell + // `OPENROUTER_API_KEY` can run a local session but isn't + // Houston-managed, so treating it as "connected" would show + // Sign out for a key the user never saved here. + if openrouter_credentials::openrouter_stored_api_key_configured() { + ProviderAuthState::Authenticated + } else { + ProviderAuthState::Unauthenticated + } + }) + } + + fn login_args(&self) -> Option<&'static [&'static str]> { + // No CLI login: auth is a pasted API key (handled by the + // `/providers/openrouter/credentials` route, surfaced as the + // API-key connect dialog in the picker). + None + } + + fn logout_args(&self) -> Option<&'static [&'static str]> { + None + } + + fn effort_levels(&self) -> &'static [&'static str] { + // Codex `model_reasoning_effort` range, minus the Claude-only `max`. + &["low", "medium", "high", "xhigh"] + } + + fn default_effort(&self) -> Option<&'static str> { + Some("medium") + } + + fn classify_stderr(&self, line: &str) -> Option { + openrouter_classify::classify_stderr(line) + } + + fn classify_result_error( + &self, + error_type: &str, + error_message: &str, + ) -> Option { + openrouter_classify::classify_result_error(error_type, error_message) + } +} diff --git a/engine/houston-terminal-manager/src/provider/openrouter_classify.rs b/engine/houston-terminal-manager/src/provider/openrouter_classify.rs new file mode 100644 index 000000000..e367876ac --- /dev/null +++ b/engine/houston-terminal-manager/src/provider/openrouter_classify.rs @@ -0,0 +1,338 @@ +//! OpenRouter stderr / Codex `turn.failed` message classifier. +//! +//! OpenRouter routes through the Codex CLI with a custom +//! `model_providers.openrouter` config (see [`super::CodexBackend`]). Error +//! payloads mirror HTTP status phrasing (`unexpected status 401 …`), so the +//! classifier keys off status codes and OpenRouter-specific wording, mapping +//! each to the shared [`ProviderError`] taxonomy the frontend already +//! renders. See `knowledge-base/provider-errors.md`. + +use crate::auth_error::is_auth_error; +use crate::codex_command; +use crate::provider_error_kind::{ + truncate_excerpt, AuthFailureCause, ModelUnavailableReason, ProviderError, QuotaScope, +}; + +const PROVIDER: &str = "openrouter"; + +pub(crate) fn classify_stderr(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_lowercase(); + + if codex_command::is_missing_rollout_error(trimmed) { + let session_id = + extract_thread_id_from_rollout_error(trimmed).unwrap_or_else(|| "unknown".to_string()); + return Some(ProviderError::SessionResumeMissing { + provider: PROVIDER.into(), + session_id, + }); + } + + if missing_openrouter_api_key(&lower) { + return Some(ProviderError::Unauthenticated { + provider: PROVIDER.into(), + cause: AuthFailureCause::NoCredentials, + message: truncate_excerpt(trimmed), + }); + } + + if is_auth_error(trimmed) || lower.contains("unexpected status 401") { + let cause = if lower.contains("invalid") && lower.contains("api key") { + AuthFailureCause::InvalidApiKey + } else if missing_openrouter_api_key(&lower) { + AuthFailureCause::NoCredentials + } else { + AuthFailureCause::Unknown + }; + return Some(ProviderError::Unauthenticated { + provider: PROVIDER.into(), + cause, + message: truncate_excerpt(trimmed), + }); + } + + if lower.contains("unexpected status 404") + || parse_http_status(trimmed) == Some(404) + || lower.contains("model not found") + || lower.contains("model_not_found") + || lower.contains("no endpoints found") + { + let model = extract_quoted_model(trimmed).unwrap_or_else(|| "this model".into()); + return Some(ProviderError::ModelUnavailable { + provider: PROVIDER.into(), + model, + reason: ModelUnavailableReason::Unknown, + suggested_fallback: None, + message: truncate_excerpt(trimmed), + }); + } + + if lower.contains("unexpected status 402") + || lower.contains("402 payment") + || lower.contains("insufficient credits") + || lower.contains("insufficient balance") + { + return Some(ProviderError::QuotaExhausted { + provider: PROVIDER.into(), + model: None, + scope: QuotaScope::Unknown, + // Open-ended credit exhaustion: no reset time, topping up is the + // path. The frontend's QuotaExhaustedCard supplies OpenRouter's + // billing URL per-provider (provider-error-cards/shared.tsx). + resets_at: None, + message: truncate_excerpt(trimmed), + }); + } + + if lower.contains("429") || lower.contains("rate_limit") || lower.contains("rate limit") { + return Some(ProviderError::RateLimited { + provider: PROVIDER.into(), + model: None, + retry_after_seconds: parse_retry_after_seconds(trimmed), + message: truncate_excerpt(trimmed), + }); + } + + if lower.contains("unexpected status 503") || parse_http_status(trimmed) == Some(503) { + return Some(ProviderError::ProviderInternal { + provider: PROVIDER.into(), + http_status: Some(503), + message: truncate_excerpt(trimmed), + }); + } + + if let Some(status) = parse_http_5xx(trimmed) { + return Some(ProviderError::ProviderInternal { + provider: PROVIDER.into(), + http_status: Some(status), + message: truncate_excerpt(trimmed), + }); + } + + if lower.contains("econnrefused") + || lower.contains("econnreset") + || lower.contains("enotfound") + || lower.contains("etimedout") + || lower.contains("connection refused") + { + return Some(ProviderError::NetworkUnreachable { + provider: PROVIDER.into(), + message: truncate_excerpt(trimmed), + }); + } + + None +} + +pub(crate) fn classify_result_error( + _error_type: &str, + _error_message: &str, +) -> Option { + // Codex's NDJSON `result.error` events for OpenRouter carry the same + // HTTP-status phrasing the stderr path classifies; `codex_parser` runs + // the message back through `classify_stderr`, so there is nothing + // OpenRouter-specific to add here. + None +} + +fn extract_quoted_model(line: &str) -> Option { + let first = line.find('\'')?; + let rest = &line[first + 1..]; + let end = rest.find('\'')?; + let model = rest[..end].trim(); + if model.is_empty() { + None + } else { + Some(model.to_string()) + } +} + +fn missing_openrouter_api_key(lower: &str) -> bool { + lower.contains("openrouter_api_key") + || (lower.contains("openrouter") && lower.contains("api key") && lower.contains("missing")) + || (lower.contains("environment variable") && lower.contains("openrouter_api_key")) +} + +fn extract_thread_id_from_rollout_error(line: &str) -> Option { + const MARKER: &str = "thread id "; + let lower = line.to_lowercase(); + let idx = lower.find(MARKER)?; + let tail = line[idx + MARKER.len()..].trim(); + let id: String = tail + .chars() + .take_while(|c| c.is_ascii_hexdigit() || *c == '-') + .collect(); + if id.is_empty() { + None + } else { + Some(id) + } +} + +fn parse_retry_after_seconds(line: &str) -> Option { + let lower = line.to_lowercase(); + for marker in ["retry-after:", "retry after", "retry_after"] { + if let Some(idx) = lower.find(marker) { + let tail = &lower[idx + marker.len()..]; + let mut digits = String::new(); + for c in tail.chars() { + if c.is_ascii_digit() { + digits.push(c); + } else if !digits.is_empty() { + break; + } + } + if let Ok(n) = digits.parse::() { + return Some(n); + } + } + } + None +} + +fn parse_http_status(line: &str) -> Option { + for token in line.split(|c: char| !c.is_ascii_digit()) { + if token.len() == 3 { + if let Ok(n) = token.parse::() { + if (400..600).contains(&n) { + return Some(n); + } + } + } + } + None +} + +fn parse_http_5xx(line: &str) -> Option { + parse_http_status(line).filter(|s| (500..600).contains(s)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_401_maps_to_unauthenticated_invalid_key() { + let line = "unexpected status 401 Unauthorized: Invalid API key"; + match classify_stderr(line).unwrap() { + ProviderError::Unauthenticated { + provider, + cause: AuthFailureCause::InvalidApiKey, + .. + } => assert_eq!(provider, "openrouter"), + other => panic!("expected Unauthenticated InvalidApiKey, got {other:?}"), + } + } + + #[test] + fn missing_env_key_maps_to_no_credentials() { + let line = "environment variable OPENROUTER_API_KEY is not set"; + match classify_stderr(line).unwrap() { + ProviderError::Unauthenticated { + provider, + cause: AuthFailureCause::NoCredentials, + .. + } => assert_eq!(provider, "openrouter"), + other => panic!("expected Unauthenticated NoCredentials, got {other:?}"), + } + } + + #[test] + fn missing_rollout_classified_as_session_resume_missing() { + let line = "Error: thread/resume: thread/resume failed: no rollout found for thread id 1088f5a4-c484-44d4-b594-585b74a8f859"; + match classify_stderr(line).unwrap() { + ProviderError::SessionResumeMissing { session_id, .. } => { + assert_eq!(session_id, "1088f5a4-c484-44d4-b594-585b74a8f859") + } + other => panic!("expected SessionResumeMissing, got {other:?}"), + } + } + + #[test] + fn status_404_maps_to_model_unavailable() { + let line = "unexpected status 404 Not Found: model 'openai/gpt-nonesuch' not found"; + match classify_stderr(line).unwrap() { + ProviderError::ModelUnavailable { + provider, model, .. + } => { + assert_eq!(provider, "openrouter"); + assert_eq!(model, "openai/gpt-nonesuch"); + } + other => panic!("expected ModelUnavailable, got {other:?}"), + } + } + + #[test] + fn status_402_maps_to_quota_exhausted() { + let line = "unexpected status 402 Payment Required: insufficient credits"; + match classify_stderr(line).unwrap() { + ProviderError::QuotaExhausted { + provider, scope, .. + } => { + assert_eq!(provider, "openrouter"); + assert_eq!(scope, QuotaScope::Unknown); + } + other => panic!("expected QuotaExhausted, got {other:?}"), + } + } + + #[test] + fn status_429_maps_to_rate_limited_with_retry_after() { + let line = "429 rate_limit_exceeded retry-after: 30"; + match classify_stderr(line).unwrap() { + ProviderError::RateLimited { + provider, + retry_after_seconds: Some(30), + .. + } => assert_eq!(provider, "openrouter"), + other => panic!("expected RateLimited, got {other:?}"), + } + } + + #[test] + fn rate_limit_without_retry_after() { + match classify_stderr("429 rate_limit_exceeded").unwrap() { + ProviderError::RateLimited { + retry_after_seconds: None, + .. + } => {} + other => panic!("expected RateLimited without retry-after, got {other:?}"), + } + } + + #[test] + fn status_503_and_502_map_to_provider_internal() { + match classify_stderr("unexpected status 503 Service Unavailable").unwrap() { + ProviderError::ProviderInternal { + http_status: Some(503), + .. + } => {} + other => panic!("expected ProviderInternal 503, got {other:?}"), + } + match classify_stderr("unexpected status 502 Bad Gateway").unwrap() { + ProviderError::ProviderInternal { + http_status: Some(502), + .. + } => {} + other => panic!("expected ProviderInternal 502, got {other:?}"), + } + } + + #[test] + fn network_unreachable_for_econnrefused() { + match classify_stderr("FetchError: request to openrouter.ai failed, reason: ECONNREFUSED") + .unwrap() + { + ProviderError::NetworkUnreachable { .. } => {} + other => panic!("expected NetworkUnreachable, got {other:?}"), + } + } + + #[test] + fn unrelated_log_returns_none() { + assert!(classify_stderr("Reading prompt from stdin").is_none()); + } +} diff --git a/engine/houston-terminal-manager/src/provider/openrouter_credentials.rs b/engine/houston-terminal-manager/src/provider/openrouter_credentials.rs new file mode 100644 index 000000000..01a8f6824 --- /dev/null +++ b/engine/houston-terminal-manager/src/provider/openrouter_credentials.rs @@ -0,0 +1,71 @@ +//! Auth-status helper for the OpenRouter adapter's `probe_auth`. +//! +//! The key itself is persisted by `houston-engine-core` +//! (`provider::set_openrouter_api_key`) to +//! `/providers/openrouter/.env`, and injected into the spawned +//! `codex` process by the generic [`super::codex_backend_env`] helper (which +//! also honors a shell `OPENROUTER_API_KEY` override). This module only +//! answers the narrower "is a Houston-managed key on disk?" question the +//! picker card needs. + +use crate::provider_env::read_stored_api_key; + +pub(crate) const ENV_VAR: &str = "OPENROUTER_API_KEY"; +const PROVIDER: &str = "openrouter"; + +/// True when a Houston-managed key exists on disk. A key present only in the +/// shell environment can run a local session but is NOT Houston-managed, so +/// the picker must still show "Connect" (never "Sign out") until the user +/// saves one through the connect dialog. +pub(crate) fn openrouter_stored_api_key_configured() -> bool { + read_stored_api_key(PROVIDER, ENV_VAR).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_env_lock::lock_env_test; + use std::fs; + use tempfile::TempDir; + + #[test] + fn not_configured_without_a_stored_file() { + let _guard = lock_env_test(); + let tmp = TempDir::new().unwrap(); + let prior_home = std::env::var_os("HOUSTON_HOME"); + let prior_key = std::env::var_os(ENV_VAR); + std::env::set_var("HOUSTON_HOME", tmp.path()); + // A shell-only key must NOT count as Houston-configured. + std::env::set_var(ENV_VAR, "sk-or-v1-shell-only"); + + assert!(!openrouter_stored_api_key_configured()); + + match prior_home { + Some(v) => std::env::set_var("HOUSTON_HOME", v), + None => std::env::remove_var("HOUSTON_HOME"), + } + match prior_key { + Some(v) => std::env::set_var(ENV_VAR, v), + None => std::env::remove_var(ENV_VAR), + } + } + + #[test] + fn configured_once_a_file_is_written() { + let _guard = lock_env_test(); + let tmp = TempDir::new().unwrap(); + let prior_home = std::env::var_os("HOUSTON_HOME"); + std::env::set_var("HOUSTON_HOME", tmp.path()); + + let path = crate::provider_env::canonical_env_path(PROVIDER); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "OPENROUTER_API_KEY=sk-or-v1-stored\n").unwrap(); + + assert!(openrouter_stored_api_key_configured()); + + match prior_home { + Some(v) => std::env::set_var("HOUSTON_HOME", v), + None => std::env::remove_var("HOUSTON_HOME"), + } + } +} diff --git a/engine/houston-terminal-manager/src/provider_env.rs b/engine/houston-terminal-manager/src/provider_env.rs new file mode 100644 index 000000000..15e250ede --- /dev/null +++ b/engine/houston-terminal-manager/src/provider_env.rs @@ -0,0 +1,198 @@ +//! Houston-managed provider API keys under +//! `/providers//.env`. +//! +//! Used by providers that ride a CLI against a custom endpoint and need an +//! API key injected as an environment variable at spawn time (e.g. +//! OpenRouter through the Codex CLI — see `provider::CodexBackend`). The +//! credential WRITE side lives in `houston-engine-core` +//! (`provider_env_store`), which reuses the merge/perms helpers here; this +//! module owns the canonical path resolution and the synchronous read the +//! runner needs on the spawn hot path. +//! +//! Storage shape mirrors the rest of `~/.houston/**`: `HOUSTON_HOME` wins, +//! otherwise `~/.dev-houston` in debug builds and `~/.houston` in release — +//! the exact resolution `houston-db` uses for the data root, so a key +//! written by the engine and read by the runner always land on the same +//! file regardless of build profile. + +use std::path::{Path, PathBuf}; + +/// Houston data root. `HOUSTON_HOME` overrides; otherwise debug builds use +/// `~/.dev-houston` and release builds `~/.houston`. Kept in sync with +/// `houston_db::houston_dir` (terminal-manager does not depend on the db +/// crate, so the resolution is replicated rather than imported). +fn houston_data_root() -> PathBuf { + if let Ok(override_path) = std::env::var("HOUSTON_HOME") { + return PathBuf::from(override_path); + } + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let subdir = if cfg!(debug_assertions) { + ".dev-houston" + } else { + ".houston" + }; + home.join(subdir) +} + +/// Canonical credential file for a provider id (`openrouter`, …): +/// `/providers//.env`. +pub fn canonical_env_path(provider: &str) -> PathBuf { + houston_data_root() + .join("providers") + .join(provider) + .join(".env") +} + +/// Read a non-empty `KEY=value` for `env_var` from the provider's stored +/// `.env`. Returns `None` when the file or the key is absent/empty. +pub fn read_stored_api_key(provider: &str, env_var: &str) -> Option { + read_api_key_from_file(&canonical_env_path(provider), env_var) +} + +pub fn read_api_key_from_file(path: &Path, env_var: &str) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + extract_env_value(&contents, env_var) +} + +/// Extract the value of `env_var` from `.env` contents, tolerating an +/// `export ` prefix and surrounding quotes. Returns `None` if absent/empty. +pub fn extract_env_value(existing: &str, env_var: &str) -> Option { + for line in existing.split_inclusive('\n') { + if !is_env_var_line(line, env_var) { + continue; + } + let trimmed = line.trim_start(); + let body = trimmed.strip_prefix("export ").unwrap_or(trimmed); + let rest = body.strip_prefix(&format!("{env_var}="))?; + let cleaned = rest + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim() + .to_string(); + if !cleaned.is_empty() { + return Some(cleaned); + } + } + None +} + +/// Replace the `env_var=` line in `.env` contents if present, otherwise +/// append it. Preserves every other line (other env vars, comments) so a +/// user's hand-edited `.env` is never clobbered. +pub fn merge_env_contents(existing: &str, env_var: &str, new_value: &str) -> String { + let mut out = String::with_capacity(existing.len() + new_value.len() + 32); + let mut replaced = false; + let trailing_newline = existing.is_empty() || existing.ends_with('\n'); + for line in existing.split_inclusive('\n') { + if is_env_var_line(line, env_var) { + out.push_str(&format!("{env_var}={new_value}")); + if line.ends_with('\n') { + out.push('\n'); + } + replaced = true; + } else { + out.push_str(line); + } + } + if !replaced { + if !out.is_empty() && !trailing_newline { + out.push('\n'); + } + out.push_str(&format!("{env_var}={new_value}\n")); + } + out +} + +/// True when `line` assigns `env_var` (with or without an `export ` prefix). +pub fn is_env_var_line(line: &str, env_var: &str) -> bool { + let trimmed = line.trim_start(); + let body = trimmed.strip_prefix("export ").unwrap_or(trimmed); + body.starts_with(&format!("{env_var}=")) +} + +/// Sibling `.tmp` path used to stage an atomic write. +pub fn tmp_path_for(final_path: &Path) -> PathBuf { + let mut name = final_path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + name.push(".tmp"); + final_path + .parent() + .map(|p| p.join(&name)) + .unwrap_or_else(|| PathBuf::from(&name)) +} + +#[cfg(unix)] +pub fn apply_owner_only_perms(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +pub fn apply_owner_only_perms(_path: &Path) -> std::io::Result<()> { + // Windows ACLs already restrict %USERPROFILE% to the current user. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn merge_appends_to_empty_file() { + let out = merge_env_contents("", "OPENROUTER_API_KEY", "sk-or-test"); + assert_eq!(out, "OPENROUTER_API_KEY=sk-or-test\n"); + } + + #[test] + fn merge_replaces_existing_key_line() { + let existing = "OTHER=hello\nOPENROUTER_API_KEY=old\n"; + let out = merge_env_contents(existing, "OPENROUTER_API_KEY", "sk-or-new"); + assert_eq!(out, "OTHER=hello\nOPENROUTER_API_KEY=sk-or-new\n"); + } + + #[test] + fn merge_preserves_unrelated_lines_and_appends() { + let existing = "# my keys\nGEMINI_API_KEY=abc\n"; + let out = merge_env_contents(existing, "OPENROUTER_API_KEY", "sk-or-new"); + assert_eq!(out, "# my keys\nGEMINI_API_KEY=abc\nOPENROUTER_API_KEY=sk-or-new\n"); + } + + #[test] + fn extract_tolerates_export_and_quotes() { + assert_eq!( + extract_env_value("export OPENROUTER_API_KEY=\"sk-or-x\"\n", "OPENROUTER_API_KEY") + .as_deref(), + Some("sk-or-x") + ); + assert_eq!(extract_env_value("OPENROUTER_API_KEY=\n", "OPENROUTER_API_KEY"), None); + } + + #[test] + fn read_api_key_from_file_reads_value() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(".env"); + fs::write(&path, "OTHER=x\nOPENROUTER_API_KEY=sk-or-v1-fromfile\n").unwrap(); + assert_eq!( + read_api_key_from_file(&path, "OPENROUTER_API_KEY").as_deref(), + Some("sk-or-v1-fromfile") + ); + } + + #[test] + fn read_api_key_from_missing_file_is_none() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("nope.env"); + assert_eq!(read_api_key_from_file(&path, "OPENROUTER_API_KEY"), None); + } + + #[test] + fn canonical_env_path_is_under_providers() { + let path = canonical_env_path("openrouter"); + assert!(path.ends_with("providers/openrouter/.env")); + } +} diff --git a/engine/houston-terminal-manager/src/session_dispatch.rs b/engine/houston-terminal-manager/src/session_dispatch.rs index daa20145e..32b38662a 100644 --- a/engine/houston-terminal-manager/src/session_dispatch.rs +++ b/engine/houston-terminal-manager/src/session_dispatch.rs @@ -62,7 +62,7 @@ pub(crate) async fn dispatch( ) .await; } - "openai" => { + "openai" | "openrouter" => { spawn_codex( tx, provider, diff --git a/engine/houston-terminal-manager/src/session_io.rs b/engine/houston-terminal-manager/src/session_io.rs index f1b327781..475a19ea9 100644 --- a/engine/houston-terminal-manager/src/session_io.rs +++ b/engine/houston-terminal-manager/src/session_io.rs @@ -119,7 +119,8 @@ pub async fn read_stdout_events( // than on the adapter trait. Adding a provider = one new arm. match provider.id() { "anthropic" => read_claude_stdout(stdout, tx).await, - "openai" => read_codex_stdout(stdout, tx).await, + // OpenRouter rides the Codex CLI, so it emits the same NDJSON stream. + "openai" | "openrouter" => read_codex_stdout(stdout, tx).await, "gemini" => { read_gemini_stdout(stdout, tx).await; StdoutReadReport::default() diff --git a/engine/houston-terminal-manager/src/test_env_lock.rs b/engine/houston-terminal-manager/src/test_env_lock.rs new file mode 100644 index 000000000..416a15c96 --- /dev/null +++ b/engine/houston-terminal-manager/src/test_env_lock.rs @@ -0,0 +1,17 @@ +//! Serializes tests that mutate process-global environment variables +//! (`HOME`, `HOUSTON_HOME`, provider API-key vars). The test runner runs +//! tests in parallel threads of one process, so two tests touching the same +//! env var race; holding this lock for the duration of such a test makes +//! them run one at a time. + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +/// Acquire the global env-test lock. A poisoned lock (a previous test +/// panicked while holding it) is recovered rather than propagated — the +/// guard only serializes access, it guards no invariant of its own. +pub(crate) fn lock_env_test() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} diff --git a/knowledge-base/agent-manifest.md b/knowledge-base/agent-manifest.md index 194b240f6..da0a07fff 100644 --- a/knowledge-base/agent-manifest.md +++ b/knowledge-base/agent-manifest.md @@ -221,18 +221,31 @@ adapter, see `knowledge-base/architecture.md`). |---|---|---|---|---| | `anthropic` (alias `claude`) | `claude` (runtime download) | `claude-sonnet-4-6` | `claude-opus-4-8` | OAuth via `claude auth login --claudeai` | | `openai` (alias `codex`) | `codex` (bundled) | `gpt-5` | `gpt-5-codex` | OAuth via `codex login` | +| `openrouter` | `codex` (bundled, reused) | `deepseek/deepseek-chat` | (curated open-source slugs) | API key, no CLI login (see `knowledge-base/auth.md`) | | `gemini` (alias `google`) | `gemini` (bundled, macOS only) | `gemini-2.5-flash` | `gemini-2.5-pro` | API key, no CLI login (see `knowledge-base/auth.md`) | Notes: -- Gemini has no `gemini login`. The picker short-circuits on +- **OpenRouter rides the Codex CLI.** Its adapter returns + `ProviderAdapter::codex_backend() -> Some(CodexBackend)`; the codex command + builder turns that into `-c model_provider="openrouter"` + + `model_providers.openrouter.{base_url,env_key,wire_api="responses"}` overrides + (`wire_api` MUST be `"responses"` — the bundled codex dropped `"chat"`, codex#7782), and + the runner injects `OPENROUTER_API_KEY` into the codex process. Every codex + dispatch site (`session_dispatch`, `session_io`, `provider_oneshot`, + `cli_process`) treats `"openai" | "openrouter"` identically — no + OpenRouter-specific runner/parser. The next OpenAI-compatible provider is one + more adapter file returning a `CodexBackend`. +- Gemini + OpenRouter have no CLI login. The picker short-circuits on `loginKind === "apiKey"` and opens the Connect-API-Key dialog (`app/src/components/shell/api-key-connect-dialog.tsx`). Calling - `/v1/providers/gemini/login` directly returns `BadRequest`. + `/v1/providers//login` directly returns `BadRequest`. - Gemini is macOS-only in v1; Windows users see it as unavailable until the phase-2 fork-build lands (see `knowledge-base/cli-bundling.md`). -- Adding a fourth provider = one new adapter file + one registry entry + - three dispatch arms (runner, parser, summarizer). See "Engine boundary" - in `CLAUDE.md`. +- Adding a CLI provider = one new adapter file + one registry entry + + three dispatch arms (runner, parser, summarizer). An OpenAI-compatible + API-key provider = one adapter file returning `CodexBackend` (no dispatch + edits — they already match every codex backend). See "Engine boundary" in + `CLAUDE.md`. ### Switching provider mid-conversation diff --git a/knowledge-base/auth.md b/knowledge-base/auth.md index c2821a95e..c97e39c6f 100644 --- a/knowledge-base/auth.md +++ b/knowledge-base/auth.md @@ -246,6 +246,38 @@ user does not have to fiddle with shell rc files. The engine route is parent dir ensure). When the in-flight upgrade lands, the dialog gains a paste input + Save button and the restart-Houston step disappears. +## OpenRouter (API key, rides the Codex CLI) + +OpenRouter has no CLI of its own — it reuses the bundled `codex` binary +pointed at OpenRouter's OpenAI-compatible endpoint. Auth is a pasted API key, +not OAuth. + +- **Storage**: `set_openrouter_api_key` + (`engine/houston-engine-core/src/provider/openrouter_credentials.rs`) writes + `OPENROUTER_API_KEY` to `/providers/openrouter/.env` (atomic, + mode 0600). Path resolution + the `.env` line-merge live in + `houston_terminal_manager::provider_env` so the runner reads the same file. +- **Injection**: at spawn, `codex_backend_env` + (`terminal-manager/src/provider/mod.rs`) reads the key (a shell + `OPENROUTER_API_KEY` wins, else the stored file) and the runner sets it as + the codex process env var named by `model_providers.openrouter.env_key`. +- **probe_auth** returns `authenticated` only when a Houston-stored key exists + (a shell-only key can run a session but isn't Houston-managed, so the picker + still shows Connect). Synchronous file read, no spawn — same path as + `GET /v1/providers/openrouter/status`. +- **login/logout**: `login_args` is `None`, so `launch_login("openrouter")` + returns `BadRequest` (API-key providers use the credentials route). + `launch_logout("openrouter")` deletes the stored key + (`strip_openrouter_api_key_storage`). +- **Connect flow**: same Connect-API-Key dialog as Gemini + (`app/src/components/shell/api-key-connect-dialog.tsx`), driven by + `providers.ts` (`apiKeyConsoleUrl: https://openrouter.ai/keys`, + `apiKeyEnvVar: OPENROUTER_API_KEY`). TS client: generic + `setProviderApiKey("openrouter", key)`. + +See `knowledge-base/agent-manifest.md` for the `CodexBackend` mechanism and the +codex `-c model_provider` overrides. + ### HOME isolation for spawned gemini sessions Gemini-cli loads `/.gemini/GEMINI.md` as global memory on every diff --git a/knowledge-base/engine-protocol.md b/knowledge-base/engine-protocol.md index b6610d61f..725dc28e6 100644 --- a/knowledge-base/engine-protocol.md +++ b/knowledge-base/engine-protocol.md @@ -237,7 +237,8 @@ cross-agent surfaces: `agent`, `routine_id`, and `worktree_path` when present. | POST | `/v1/providers/:name/login` | Launch CLI login. Returns `BAD_REQUEST` for providers without an OAuth flow (e.g. `gemini`); callers must use the credentials route instead. Surfaces the OAuth URL via the `ProviderLoginUrl` WS event and the outcome via `ProviderLoginComplete`. Optional `?deviceAuth=true` selects the provider's headless device-code flow (OpenAI/codex `--device-auth`) for remote clients that can't receive the CLI's `localhost` OAuth callback; ignored by providers without a device variant (Claude keeps its paste-back code), omitted by the co-located desktop app. | | POST | `/v1/providers/:name/login/code` | Relay the OAuth verification code the user pasted (paste-back flow, e.g. Claude on a remote/headless engine). Body: `{ code }`. Written to the CLI's stdin. Not used by codex's device-code flow, which self-completes after the user enters the `ProviderLoginUrl.user_code` on the provider's page. | | POST | `/v1/providers/:name/login/cancel` | Abort an in-flight sign-in: kills the CLI subprocess and frees the in-flight slot so a retry isn't rejected as "already pending". Idempotent (no-op when nothing pending). Emits a benign `ProviderLoginComplete` (`success: false`, `error: null`) so pending spinners clear without an error toast. Fixes the stuck-spinner-after-closing-browser case. | -| POST | `/v1/providers/gemini/credentials` | Write `GEMINI_API_KEY` to `~/.gemini/.env` (atomic, mode 0600). Body: `{ apiKey }`. Provider-specific because Gemini is the only provider with file-backed credentials today. | +| POST | `/v1/providers/gemini/credentials` | Write `GEMINI_API_KEY` to `~/.gemini/.env` (atomic, mode 0600). Body: `{ apiKey }`. | +| POST | `/v1/providers/openrouter/credentials` | Write `OPENROUTER_API_KEY` to `/providers/openrouter/.env` (atomic, mode 0600). Body: `{ apiKey }`. The codex runner injects it into the subprocess for OpenRouter's `model_providers.openrouter` config. TS client: generic `setProviderApiKey(id, key)` → `POST /providers//credentials`. | | GET | `/v1/agent-configs` | List installed agent definitions | **Composio (MCP integrations)** diff --git a/knowledge-base/provider-errors.md b/knowledge-base/provider-errors.md index 3893c79e4..c5da1af9b 100644 --- a/knowledge-base/provider-errors.md +++ b/knowledge-base/provider-errors.md @@ -250,6 +250,7 @@ login URL is always drained before its exit is observed — see `login_relay.rs` | Trait | `engine/houston-terminal-manager/src/provider/mod.rs` | | Anthropic | `engine/houston-terminal-manager/src/provider/anthropic_classify.rs` | | OpenAI | `engine/houston-terminal-manager/src/provider/openai_classify.rs` | +| OpenRouter | `engine/houston-terminal-manager/src/provider/openrouter_classify.rs` (HTTP status → taxonomy; rides codex_parser for result events) | | Codex login | `engine/houston-terminal-manager/src/provider/openai_login.rs` (login-flow diag) | | Gemini | `engine/houston-terminal-manager/src/provider/gemini/classify.rs` | | Stderr wire | `engine/houston-terminal-manager/src/session_io.rs::read_stderr_lines` | diff --git a/ui/engine-client/src/client.ts b/ui/engine-client/src/client.ts index 86951f34f..cd261bc94 100644 --- a/ui/engine-client/src/client.ts +++ b/ui/engine-client/src/client.ts @@ -725,6 +725,17 @@ export class HoustonClient { setGeminiApiKey(apiKey: string): Promise { return this.request("POST", "/providers/gemini/credentials", { apiKey }); } + + /** + * Persist an API key for an API-key provider (OpenRouter, …). The engine + * validates + writes it to that provider's credential store; the next + * `providerStatus(id)` poll flips the card to Connected. Generic over the + * provider id — each maps to `POST /providers//credentials`. (Gemini + * keeps its own dedicated method above because it also offers OAuth.) + */ + setProviderApiKey(providerId: string, apiKey: string): Promise { + return this.request("POST", `/providers/${providerId}/credentials`, { apiKey }); + } // "Sign in with Google" for Gemini goes through the standard // `providerLogin("gemini")` call — the engine detects the gemini id // and delegates to gemini-cli's own OAuth via the ACP `authenticate` From 7ba44216136f1824d0ed77f2401d40b19c8cf183 Mon Sep 17 00:00:00 2001 From: Andres Sierra <102034926+AndreSierraM@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:49:04 -0500 Subject: [PATCH 2/3] fix(provider): ignore codex internal stderr noise in OpenRouter classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter sessions complete fine, but codex prints two non-fatal diagnostics on stderr that were producing false provider-error cards: - `codex_models_manager` logs a failed catalog refresh (OpenRouter returns `{"data":[...]}`, codex expects `{"models":[...]}`) and dumps the entire multi-hundred-KB catalog body. That body almost always contains a bare "401" (pricing/ids), which `is_auth_error`'s substring check turned into a false "Reconnect to OpenRouter" Unauthenticated card on an otherwise successful turn. - `rmcp::` logs auth failures for MCP servers configured in the user's own `~/.codex/config.toml` — the user's MCP, not the OpenRouter turn. `classify_stderr` now early-returns `None` for these codex-internal lines. Co-Authored-By: Claude Opus 4.8 --- .../src/provider/openrouter_classify.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/engine/houston-terminal-manager/src/provider/openrouter_classify.rs b/engine/houston-terminal-manager/src/provider/openrouter_classify.rs index e367876ac..e2cbd4080 100644 --- a/engine/houston-terminal-manager/src/provider/openrouter_classify.rs +++ b/engine/houston-terminal-manager/src/provider/openrouter_classify.rs @@ -20,6 +20,25 @@ pub(crate) fn classify_stderr(line: &str) -> Option { if trimmed.is_empty() { return None; } + + // Codex emits internal diagnostics on stderr that are NOT model-API turn + // failures and must never become a session card: + // - `codex_models_manager` logs a failed model-catalog refresh (OpenRouter + // returns `{"data":[...]}`, codex wants `{"models":[...]}`) and dumps the + // whole multi-hundred-KB catalog body — which almost always contains a + // bare "401" / 5xx digit somewhere, tripping the substring checks below + // into a false Unauthenticated / ProviderInternal card. + // - `rmcp::` logs auth/transport failures for MCP servers the user has in + // their own `~/.codex/config.toml`; those are the user's MCP, not the + // OpenRouter turn. + // The turn itself still completes; these lines are pure noise here. + if trimmed.contains("codex_models_manager") + || trimmed.contains("rmcp::") + || trimmed.contains("failed to refresh available models") + { + return None; + } + let lower = trimmed.to_lowercase(); if codex_command::is_missing_rollout_error(trimmed) { @@ -335,4 +354,17 @@ mod tests { fn unrelated_log_returns_none() { assert!(classify_stderr("Reading prompt from stdin").is_none()); } + + #[test] + fn codex_internal_noise_never_classified() { + // The catalog-refresh error dumps a huge JSON body that contains a + // bare "401" — without the guard this would be a false Unauthenticated + // card even though the turn succeeds. + let catalog = r#"ERROR codex_models_manager::manager: failed to refresh available models: failed to decode models response: missing field `models`; body: {"data":[{"id":"x","pricing":{"prompt":"0.00000401"}}]} unexpected status 401"#; + assert!(classify_stderr(catalog).is_none()); + // MCP transport auth failures are the user's own configured MCP, not + // the OpenRouter turn. + let rmcp = r#"ERROR rmcp::transport::worker: worker quit with fatal: AuthRequired(www_authenticate_header: "Bearer error=\"invalid_token\"")"#; + assert!(classify_stderr(rmcp).is_none()); + } } From 324f468f597bb508131f8f029458e480bd8ce715 Mon Sep 17 00:00:00 2001 From: Andres Sierra <102034926+AndreSierraM@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:25:01 -0500 Subject: [PATCH 3/3] perf(provider): isolate codex home + drop effort for OpenRouter sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two speed/correctness improvements for OpenRouter (both ride the same codex backend mechanism, so they're surgical): - **Isolated CODEX_HOME.** OpenRouter sessions now run codex against `/codex-home` instead of the user's `~/.codex`, so codex never loads the user's personal `~/.codex/config.toml` (their own MCP servers + settings) into a Houston session. This removes a per-spawn MCP connection attempt (the user's MCP failing auth was leaking a stray reconnect card) and cut a real session's input from ~23k to ~13k tokens — the user's MCP tool-defs were being injected into context every turn. Native codex (OpenAI) keeps `~/.codex` (it needs the OAuth `auth.json` there). `codex_rollout` now searches both homes for token-usage, so the context gauge keeps working. - **No reasoning effort.** The curated OpenRouter models are non-reasoning chat models, so `model_reasoning_effort` is dead overhead; the adapter now reports no effort levels and the runner omits the flag. Skipped `:nitro` throughput routing on purpose — it routes to premium providers and would undercut the "cheap open-source" model curation. Co-Authored-By: Claude Opus 4.8 --- .../src/codex_rollout.rs | 29 ++++++++---- .../src/codex_runner.rs | 46 +++++++++++++++---- .../src/prompt_scratch.rs | 33 +++++++++++++ .../src/provider/openrouter.rs | 8 ++-- .../src/provider_env.rs | 2 +- knowledge-base/agent-manifest.md | 7 +++ 6 files changed, 103 insertions(+), 22 deletions(-) diff --git a/engine/houston-terminal-manager/src/codex_rollout.rs b/engine/houston-terminal-manager/src/codex_rollout.rs index c15b55d8d..69248ea56 100644 --- a/engine/houston-terminal-manager/src/codex_rollout.rs +++ b/engine/houston-terminal-manager/src/codex_rollout.rs @@ -36,8 +36,12 @@ pub async fn latest_usage(thread_id: &str) -> Option { } fn latest_usage_blocking(thread_id: &str) -> Option { - let sessions = codex_sessions_dir()?; - let path = newest_rollout_for_thread(&sessions, thread_id)?; + // Search every codex sessions tree Houston may have written to and take the + // newest rollout for this thread. Thread ids are unique, so scanning both + // the default home (native codex / OpenAI) and the isolated backend home + // (OpenRouter — see `prompt_scratch::backend_codex_home`) finds the right + // rollout without the caller having to know which provider ran the turn. + let path = newest_rollout_for_thread(&codex_sessions_dirs(), thread_id)?; let tail = read_rollout_tail(&path)?; parse_last_token_count(&tail) } @@ -62,11 +66,18 @@ fn read_rollout_tail(path: &Path) -> Option { Some(String::from_utf8_lossy(&buf).into_owned()) } -/// `$CODEX_HOME/sessions`, falling back to `~/.codex/sessions`. Houston spawns -/// codex without overriding `CODEX_HOME`, so codex uses whatever the engine -/// inherited (the user's env) or its default — mirror that here. -fn codex_sessions_dir() -> Option { - Some(crate::prompt_scratch::codex_home()?.join("sessions")) +/// Every codex `sessions/` tree Houston may have written rollouts to: +/// - the default home (`$CODEX_HOME` / `~/.codex`) — native codex (OpenAI), +/// which Houston spawns without overriding `CODEX_HOME`; +/// - the isolated backend home (`prompt_scratch::backend_codex_home`) — +/// OpenRouter, which Houston points at its own `CODEX_HOME`. +fn codex_sessions_dirs() -> Vec { + let mut dirs = Vec::with_capacity(2); + if let Some(home) = crate::prompt_scratch::codex_home() { + dirs.push(home.join("sessions")); + } + dirs.push(crate::prompt_scratch::backend_codex_home().join("sessions")); + dirs } /// Walk the sessions tree for the newest rollout belonging to `thread_id`. A @@ -80,10 +91,10 @@ fn codex_sessions_dir() -> Option { /// concurrent session's rollout. Equal mtimes (coarse-granularity filesystems) /// break deterministically by filename: the leading ISO8601 timestamp sorts /// lexically by recency, so the genuinely-newest turn always wins. -fn newest_rollout_for_thread(sessions_dir: &Path, thread_id: &str) -> Option { +fn newest_rollout_for_thread(sessions_dirs: &[PathBuf], thread_id: &str) -> Option { let suffix = format!("-{thread_id}.jsonl"); let mut best: Option<(SystemTime, String, PathBuf)> = None; - let mut stack = vec![sessions_dir.to_path_buf()]; + let mut stack: Vec = sessions_dirs.to_vec(); while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue; diff --git a/engine/houston-terminal-manager/src/codex_runner.rs b/engine/houston-terminal-manager/src/codex_runner.rs index 7386acef0..fedcb815f 100644 --- a/engine/houston-terminal-manager/src/codex_runner.rs +++ b/engine/houston-terminal-manager/src/codex_runner.rs @@ -45,25 +45,43 @@ pub(crate) async fn spawn_codex( } } + // Custom OpenAI-compatible backends (OpenRouter, …) run codex against an + // ISOLATED `CODEX_HOME` so codex never loads the user's personal + // `~/.codex/config.toml` (their own MCP servers, settings) into a Houston + // session. Native codex (OpenAI) keeps the user's `~/.codex` (it needs the + // OAuth `auth.json` there). `None` = default home. + let codex_home: Option = provider + .codex_backend() + .map(|_| prompt_scratch::backend_codex_home()); + // The system prompt travels as a codex profile file, never as argv (the // old `-c developer_instructions=…` token broke `CreateProcessW` on // Windows once the prompt outgrew the 32,767-char command-line limit). - // The profile value owns the file; it is deleted when this fn returns. + // The profile value owns the file; it is deleted when this fn returns. It + // is written under `codex_home` so the spawned codex (pointed there) finds + // it. let profile = match system_prompt.as_deref() { None => None, - Some(sp) => match prompt_scratch::codex_profile(sp) { - Ok(p) => Some(p), - Err(e) => { - let _ = tx.send(SessionUpdate::Status(SessionStatus::Error(format!( - "Failed to prepare codex instructions: {e}" - )))); - return; + Some(sp) => { + let written = match codex_home.as_deref() { + Some(home) => prompt_scratch::codex_profile_at(home, sp), + None => prompt_scratch::codex_profile(sp), + }; + match written { + Ok(p) => Some(p), + Err(e) => { + let _ = tx.send(SessionUpdate::Status(SessionStatus::Error(format!( + "Failed to prepare codex instructions: {e}" + )))); + return; + } } - }, + } }; let mut cmd = build_codex_command( provider, + codex_home.as_deref(), resume_session_id.as_deref(), working_dir.as_deref(), model.as_deref(), @@ -77,6 +95,7 @@ pub(crate) async fn spawn_codex( let _ = tx.send(SessionUpdate::ResumeInvalid); let mut fresh_cmd = build_codex_command( provider, + codex_home.as_deref(), None, working_dir.as_deref(), model.as_deref(), @@ -99,6 +118,7 @@ fn fresh_retry_prompt<'a>(prompt: &'a str, resume_fallback_prompt: Option<&'a st fn build_codex_command( provider: Provider, + codex_home: Option<&std::path::Path>, resume_session_id: Option<&str>, working_dir: Option<&std::path::Path>, model: Option<&str>, @@ -117,6 +137,14 @@ fn build_codex_command( .unwrap_or_else(|| std::path::PathBuf::from("codex")); let mut cmd = Command::new(&bin); cmd.env("PATH", super::claude_path::shell_path()); + // Isolated `CODEX_HOME` for backend providers (OpenRouter) so codex never + // reads the user's `~/.codex/config.toml`. `create_dir_all` is best-effort + // here; the profile write (`codex_profile_at`) already created it, and an + // unwritable home would surface as a spawn error the user sees. + if let Some(home) = codex_home { + let _ = std::fs::create_dir_all(home); + cmd.env("CODEX_HOME", home); + } // Custom OpenAI-compatible backends (OpenRouter, …) authenticate via an // env var named by `model_providers..env_key`; inject it here. // Native codex (OpenAI) returns `None` and is untouched. diff --git a/engine/houston-terminal-manager/src/prompt_scratch.rs b/engine/houston-terminal-manager/src/prompt_scratch.rs index 9d4a72984..7d02a7a2d 100644 --- a/engine/houston-terminal-manager/src/prompt_scratch.rs +++ b/engine/houston-terminal-manager/src/prompt_scratch.rs @@ -84,6 +84,25 @@ pub(crate) fn codex_profile(system_prompt: &str) -> Result codex_profile_in(&home, system_prompt) } +/// Isolated codex home for API-key backend providers (OpenRouter): a Houston +/// directory that does NOT contain the user's `~/.codex/config.toml`, so codex +/// never loads the user's personal MCP servers or settings into a Houston +/// session. Stable (not per-spawn) so resume + rollout token counts persist +/// across turns. Spawning here also cut a real session's input from ~23k to +/// ~13k tokens (the user's MCP tool-defs were no longer injected into context) +/// and removed the stray MCP auth noise. +pub(crate) fn backend_codex_home() -> PathBuf { + crate::provider_env::houston_data_root().join("codex-home") +} + +/// Like [`codex_profile`] but writes the profile under an explicit codex home +/// (used with [`backend_codex_home`] so the file lands where the spawned codex, +/// pointed at that `CODEX_HOME`, looks for it). +pub(crate) fn codex_profile_at(home: &Path, system_prompt: &str) -> Result { + sweep_once(); + codex_profile_in(home, system_prompt) +} + fn codex_profile_in(home: &Path, system_prompt: &str) -> Result { #[derive(Serialize)] struct ProfileBody<'a> { @@ -190,6 +209,20 @@ mod tests { const NASTY: &str = "Line \"one\"\nLine two with 'quotes', a backslash \\, emoji 🚀, tab\there\nand [toml] = trip-ups"; + #[test] + fn codex_profile_at_writes_under_given_home_and_drop_removes_it() { + // The backend (OpenRouter) path writes the profile under an isolated + // home so the spawned codex (CODEX_HOME pointed there) finds it. + let home = TempDir::new().unwrap(); + let profile = codex_profile_at(home.path(), "hi").unwrap(); + let path = home + .path() + .join(format!("{}{CODEX_PROFILE_SUFFIX}", profile.name())); + assert!(path.exists(), "profile must be written under the given home"); + drop(profile); + assert!(!path.exists(), "Drop must delete the profile"); + } + #[test] fn codex_profile_roundtrips_arbitrary_prompt_content() { let home = TempDir::new().unwrap(); diff --git a/engine/houston-terminal-manager/src/provider/openrouter.rs b/engine/houston-terminal-manager/src/provider/openrouter.rs index 8b95fa754..5073af7ab 100644 --- a/engine/houston-terminal-manager/src/provider/openrouter.rs +++ b/engine/houston-terminal-manager/src/provider/openrouter.rs @@ -86,12 +86,14 @@ impl ProviderAdapter for OpenRouterAdapter { } fn effort_levels(&self) -> &'static [&'static str] { - // Codex `model_reasoning_effort` range, minus the Claude-only `max`. - &["low", "medium", "high", "xhigh"] + // The curated OpenRouter models are non-reasoning chat models, so a + // `model_reasoning_effort` flag is pure overhead (and meaningless to + // them). Empty = the runner omits the flag entirely, which is faster. + &[] } fn default_effort(&self) -> Option<&'static str> { - Some("medium") + None } fn classify_stderr(&self, line: &str) -> Option { diff --git a/engine/houston-terminal-manager/src/provider_env.rs b/engine/houston-terminal-manager/src/provider_env.rs index 15e250ede..138f5ed82 100644 --- a/engine/houston-terminal-manager/src/provider_env.rs +++ b/engine/houston-terminal-manager/src/provider_env.rs @@ -21,7 +21,7 @@ use std::path::{Path, PathBuf}; /// `~/.dev-houston` and release builds `~/.houston`. Kept in sync with /// `houston_db::houston_dir` (terminal-manager does not depend on the db /// crate, so the resolution is replicated rather than imported). -fn houston_data_root() -> PathBuf { +pub(crate) fn houston_data_root() -> PathBuf { if let Ok(override_path) = std::env::var("HOUSTON_HOME") { return PathBuf::from(override_path); } diff --git a/knowledge-base/agent-manifest.md b/knowledge-base/agent-manifest.md index da0a07fff..6e2c85a0a 100644 --- a/knowledge-base/agent-manifest.md +++ b/knowledge-base/agent-manifest.md @@ -235,6 +235,13 @@ Notes: `cli_process`) treats `"openai" | "openrouter"` identically — no OpenRouter-specific runner/parser. The next OpenAI-compatible provider is one more adapter file returning a `CodexBackend`. +- **OpenRouter runs codex in an isolated `CODEX_HOME`** + (`prompt_scratch::backend_codex_home` → `/codex-home`) so codex + never loads the user's personal `~/.codex/config.toml` (their own MCP servers + + settings) into a Houston session. Native codex (OpenAI) keeps `~/.codex` + (it needs the OAuth `auth.json` there). `codex_rollout` searches both homes + for token-usage. Effort is omitted for OpenRouter (its curated models are + non-reasoning chat models, so `model_reasoning_effort` is dead overhead). - Gemini + OpenRouter have no CLI login. The picker short-circuits on `loginKind === "apiKey"` and opens the Connect-API-Key dialog (`app/src/components/shell/api-key-connect-dialog.tsx`). Calling