diff --git a/Cargo.lock b/Cargo.lock index ad81109..5da80b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,6 +359,7 @@ dependencies = [ "rand 0.10.2", "ratatui", "reqwest", + "rpassword", "self-replace", "semver", "serde", @@ -2205,6 +2206,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3574,6 +3596,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index cbc4ed4..23467de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ owo-colors = "4" tar = "0.4" zip = { version = "8", default-features = false, features = ["deflate"] } libc = "0.2" +rpassword = "7" [target.'cfg(windows)'.dependencies] windows-sys = { version = "=0.61.2", features = [ diff --git a/src/cli.rs b/src/cli.rs index 8f7d754..fd835da 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -28,6 +28,51 @@ pub enum DaemonCommand { Uninstall, } +#[derive(Debug, Clone, Subcommand)] +pub enum ProviderCommand { + /// Add a custom API provider (e.g. OpenRouter) for launching Codex with a third-party model + #[command( + after_help = "The API key is read from a hidden prompt (or stdin with --api-key-stdin), never from the command line.\n\nExample:\n codex-switch provider add openrouter \\\n --base-url https://openrouter.ai/api/v1 \\\n --model openai/gpt-5.3-codex" + )] + Add { + /// Provider alias (codex-switch name) + alias: String, + /// API base URL, e.g. https://openrouter.ai/api/v1 + #[arg(long)] + base_url: String, + /// Default model id (for OpenRouter, the full slug incl. provider prefix) + #[arg(long)] + model: String, + /// Human-readable provider name (defaults to the alias) + #[arg(long)] + name: Option, + /// Environment variable Codex reads the key from (defaults to a codex-switch-owned name) + #[arg(long)] + env_key: Option, + /// Codex wire protocol (current Codex only supports "responses") + #[arg(long, default_value = "responses")] + wire_api: String, + /// Read the API key from stdin instead of an interactive hidden prompt + #[arg(long)] + api_key_stdin: bool, + }, + /// List saved custom providers + List, + /// Show one provider's details (API key redacted) + Show { + /// Provider alias + alias: String, + }, + /// Remove a custom provider and its stored key + Remove { + /// Provider alias + alias: String, + /// Skip confirmation prompt + #[arg(long, short)] + yes: bool, + }, +} + #[derive(Parser)] #[command( name = "codex-switch", @@ -171,6 +216,9 @@ pub enum Commands { Tui, /// Open the ~/.codex-switch directory in the system file manager Open, + /// Manage custom API providers (OpenRouter, etc.) for launching Codex with a third-party model + #[command(subcommand)] + Provider(ProviderCommand), /// Background daemon (Beta) for automatic account switching #[command(subcommand)] Daemon(DaemonCommand), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fe7f1d0..d0583a2 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ mod launch; mod login; mod misc; mod profile; +mod provider; mod render; mod update; @@ -11,5 +12,6 @@ pub(crate) use launch::launch_cmd; pub(crate) use login::login_cmd; pub(crate) use misc::{open_cmd, reset_card_cmd, warmup_cmd}; pub(crate) use profile::{delete_cmd, list_cmd, rename_cmd, use_cmd}; +pub(crate) use provider::provider_cmd; pub(crate) use render::confirm; pub(crate) use update::self_update_cmd; diff --git a/src/commands/provider.rs b/src/commands/provider.rs new file mode 100644 index 0000000..9ed8f8f --- /dev/null +++ b/src/commands/provider.rs @@ -0,0 +1,203 @@ +use std::io::{IsTerminal, Read}; + +use anyhow::{Context, Result}; + +use super::render::confirm_default_no; +use crate::cli::ProviderCommand; +use crate::output::{JsonOk, print_json, user_println}; +use crate::provider::{self, ProviderProfile}; + +pub(crate) fn provider_cmd(cmd: ProviderCommand, json: bool) -> Result<()> { + match cmd { + ProviderCommand::Add { + alias, + base_url, + model, + name, + env_key, + wire_api, + api_key_stdin, + } => add( + alias, + base_url, + model, + name, + env_key, + wire_api, + api_key_stdin, + json, + ), + ProviderCommand::List => list(json), + ProviderCommand::Show { alias } => show(&alias, json), + ProviderCommand::Remove { alias, yes } => remove(&alias, yes, json), + } +} + +#[allow(clippy::too_many_arguments)] +fn add( + alias: String, + base_url: String, + model: String, + name: Option, + env_key: Option, + wire_api: String, + api_key_stdin: bool, + json: bool, +) -> Result<()> { + crate::profile::validate_alias(&alias)?; + if provider::exists(&alias) { + anyhow::bail!("provider '{alias}' already exists"); + } + if crate::profile::list_profiles()?.iter().any(|p| p == &alias) { + anyhow::bail!("'{alias}' already names a ChatGPT profile; choose a different alias"); + } + + let api_key = read_api_key(&alias, api_key_stdin)?; + + let profile = ProviderProfile { + provider_id: provider::sanitize_provider_id(&alias), + name: name.unwrap_or_else(|| alias.clone()), + base_url, + env_key: env_key.unwrap_or_else(|| provider::derive_env_key(&alias)), + model, + wire_api, + api_key, + alias: alias.clone(), + }; + profile.validate()?; + provider::save(&profile)?; + + if json { + print_json(&JsonOk { + ok: true, + alias, + action: "provider-added".into(), + }); + } else { + user_println(&format!( + "Added provider '{}' ({}) -> {}", + profile.alias, profile.name, profile.base_url + )); + user_println(&format!( + " key stored; Codex reads it from ${} at launch", + profile.env_key + )); + } + Ok(()) +} + +/// Read the API key without exposing it on the command line: from stdin in +/// `--api-key-stdin` mode, otherwise from a hidden interactive prompt. Refuses +/// to run non-interactively without `--api-key-stdin` rather than echoing. +fn read_api_key(alias: &str, stdin_mode: bool) -> Result { + let key = if stdin_mode { + let mut raw = String::new(); + std::io::stdin() + .read_to_string(&mut raw) + .context("reading API key from stdin")?; + raw.trim().to_string() + } else if std::io::stdin().is_terminal() { + rpassword::prompt_password(format!("API key for '{alias}': ")) + .context("reading API key")? + .trim() + .to_string() + } else { + anyhow::bail!( + "no interactive terminal for a hidden prompt; pass the key on stdin with --api-key-stdin" + ); + }; + if key.is_empty() { + anyhow::bail!("API key cannot be empty"); + } + Ok(key) +} + +fn list(json: bool) -> Result<()> { + let aliases = provider::list_providers()?; + if json { + let items: Vec = aliases + .iter() + .filter_map(|alias| provider::load(alias).ok()) + .map(|p| { + serde_json::json!({ + "alias": p.alias, + "provider_id": p.provider_id, + "name": p.name, + "base_url": p.base_url, + "model": p.model, + "wire_api": p.wire_api, + "env_key": p.env_key, + "has_key": !p.api_key.is_empty(), + }) + }) + .collect(); + print_json(&serde_json::json!({ "providers": items })); + return Ok(()); + } + if aliases.is_empty() { + user_println("(no providers)"); + return Ok(()); + } + for alias in aliases { + match provider::load(&alias) { + Ok(p) => user_println(&format!( + "{} {} {} [{}]", + p.alias, p.name, p.model, p.base_url + )), + Err(e) => user_println(&format!("{alias} (error: {e})")), + } + } + Ok(()) +} + +fn show(alias: &str, json: bool) -> Result<()> { + let p = provider::load(alias)?; + if json { + print_json(&serde_json::json!({ + "alias": p.alias, + "provider_id": p.provider_id, + "name": p.name, + "base_url": p.base_url, + "model": p.model, + "wire_api": p.wire_api, + "env_key": p.env_key, + "key": p.redacted_key(), + })); + return Ok(()); + } + user_println(&format!("alias {}", p.alias)); + user_println(&format!("name {}", p.name)); + user_println(&format!("provider_id {}", p.provider_id)); + user_println(&format!("base_url {}", p.base_url)); + user_println(&format!("model {}", p.model)); + user_println(&format!("wire_api {}", p.wire_api)); + user_println(&format!("env_key {}", p.env_key)); + user_println(&format!("key {}", p.redacted_key())); + Ok(()) +} + +fn remove(alias: &str, yes: bool, json: bool) -> Result<()> { + if !provider::exists(alias) { + anyhow::bail!("provider '{alias}' not found"); + } + if !yes { + if json || !std::io::stdin().is_terminal() { + anyhow::bail!("confirmation required; rerun with --yes to remove provider '{alias}'"); + } + if !confirm_default_no(&format!("Remove provider '{alias}'? [y/N] ")) { + user_println("Removal cancelled."); + return Ok(()); + } + } + provider::remove(alias)?; + if json { + print_json(&JsonOk { + ok: true, + alias: alias.to_string(), + action: "provider-removed".into(), + }); + } else { + user_println(&format!("Removed provider '{alias}'")); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index e70286d..7e8c34f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,8 @@ mod login; mod output; pub mod profile; #[allow(dead_code)] +mod provider; +#[allow(dead_code)] mod signals; #[allow(dead_code)] mod tui; diff --git a/src/main.rs b/src/main.rs index d7851d4..27e35c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod logging; mod login; mod output; mod profile; +mod provider; mod signals; mod tui; mod update; @@ -281,6 +282,7 @@ async fn dispatch(cmd: Commands, json: bool) -> Result<()> { } => commands::launch_cmd(alias.as_deref(), args, json, consume_card).await?, Commands::Tui => tui::run_tui().await?, Commands::Open => commands::open_cmd()?, + Commands::Provider(sub) => commands::provider_cmd(sub, json)?, Commands::Daemon(sub) => daemon::dispatch(sub, json).await?, } diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 0000000..2660da1 --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,384 @@ +//! Custom API provider profiles. +//! +//! A provider profile lets `codex-switch launch` run Codex against a third-party +//! OpenAI-compatible endpoint (OpenRouter, an LLM proxy, …) instead of a ChatGPT +//! OAuth account. Unlike an OAuth profile it carries no `auth.json`; it holds the +//! Codex model-provider definition plus a bearer API key. +//! +//! Storage lives entirely under codex-switch's own home +//! (`$CODEX_SWITCH_HOME/providers//provider.toml`, mode `0600`) so nothing +//! is written into `~/.codex`. At launch the profile is translated into +//! `codex -c …` overrides while the key is injected into the child process +//! environment under `env_key` — never onto the command line — so it stays out of +//! the process table. Because `-c` layers on top of the base config, the user's +//! `~/.codex/config.toml` (MCP servers, skills, …) is left untouched. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::auth; + +/// Provider ids Codex reserves for its built-ins; a custom provider may not +/// reuse them. +const RESERVED_PROVIDER_IDS: [&str; 3] = ["openai", "ollama", "lmstudio"]; + +/// The only wire protocol current Codex supports (Chat Completions was removed +/// in early 2026). Kept configurable for forward-compatibility but defaulted. +const DEFAULT_WIRE_API: &str = "responses"; + +fn default_wire_api() -> String { + DEFAULT_WIRE_API.to_string() +} + +/// A saved custom-provider profile. `alias` is the codex-switch-facing name and +/// the on-disk directory; it is derived from the path on load, never stored. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderProfile { + #[serde(skip)] + pub alias: String, + /// The `[model_providers.]` key Codex sees. + pub provider_id: String, + /// Human-readable provider name (Codex requires a non-empty value). + pub name: String, + /// API base URL, e.g. `https://openrouter.ai/api/v1`. + pub base_url: String, + /// Environment variable Codex reads the key from. Derived from the alias and + /// owned by codex-switch, so it never collides with a provider's own var. + pub env_key: String, + /// Default model id (for OpenRouter, the full slug incl. provider prefix). + pub model: String, + #[serde(default = "default_wire_api")] + pub wire_api: String, + /// Bearer API key. Secret: stored `0600`, injected as an env var at launch, + /// and never printed or placed on the command line. + pub api_key: String, +} + +fn providers_dir() -> Result { + Ok(auth::app_home()?.join("providers")) +} + +fn provider_dir(alias: &str) -> Result { + Ok(providers_dir()?.join(alias)) +} + +pub fn provider_path(alias: &str) -> Result { + Ok(provider_dir(alias)?.join("provider.toml")) +} + +/// Whether a provider profile with this alias exists. +pub fn exists(alias: &str) -> bool { + provider_path(alias).map(|p| p.exists()).unwrap_or(false) +} + +/// Derive the codex-switch-owned environment variable name for an alias, e.g. +/// `my-router` → `CODEX_SWITCH_MY_ROUTER_KEY`. Using our own name (rather than a +/// provider's conventional var) keeps the injected key isolated from whatever +/// the user may already have exported. +pub fn derive_env_key(alias: &str) -> String { + let body: String = alias + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + format!("CODEX_SWITCH_{body}_KEY") +} + +/// Derive a Codex `model_providers.` id from an alias: lowercased, with any +/// character outside `[a-z0-9_]` replaced by `_`. +pub fn sanitize_provider_id(alias: &str) -> String { + alias + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() +} + +fn is_valid_env_key(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +impl ProviderProfile { + /// Reject anything Codex (or our launch translation) would choke on before + /// it is written to disk. + pub fn validate(&self) -> Result<()> { + crate::profile::validate_alias(&self.alias)?; + if self.provider_id.is_empty() + || !self + .provider_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') + { + anyhow::bail!( + "provider id '{}' must contain only lowercase letters, digits, and '_'", + self.provider_id + ); + } + if RESERVED_PROVIDER_IDS.contains(&self.provider_id.as_str()) { + anyhow::bail!( + "provider id '{}' is reserved by Codex; choose a different alias", + self.provider_id + ); + } + if self.name.trim().is_empty() { + anyhow::bail!("provider name cannot be empty (Codex requires it)"); + } + if !(self.base_url.starts_with("http://") || self.base_url.starts_with("https://")) { + anyhow::bail!("base_url must start with http:// or https://"); + } + if !is_valid_env_key(&self.env_key) { + anyhow::bail!( + "env_key '{}' is not a valid environment variable name", + self.env_key + ); + } + if self.model.trim().is_empty() { + anyhow::bail!("model cannot be empty"); + } + if self.wire_api.trim().is_empty() { + anyhow::bail!("wire_api cannot be empty"); + } + if self.api_key.is_empty() { + anyhow::bail!("api_key cannot be empty"); + } + Ok(()) + } + + /// A display-safe rendering of the key: never the raw value. + pub fn redacted_key(&self) -> String { + redact_key(&self.api_key) + } +} + +/// Mask a secret for display: keep the last 4 characters when long enough, +/// otherwise fully mask. Never returns the raw key. +pub fn redact_key(key: &str) -> String { + let len = key.chars().count(); + if len <= 4 { + "****".to_string() + } else { + let tail: String = key.chars().skip(len - 4).collect(); + format!("…{tail}") + } +} + +fn ensure_private_dir(path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .with_context(|| format!("creating directory {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("setting permissions on {}", path.display()))?; + } + Ok(()) +} + +/// List saved provider aliases (directories holding a `provider.toml`), sorted. +pub fn list_providers() -> Result> { + let dir = providers_dir()?; + if !dir.exists() { + return Ok(vec![]); + } + let mut names: Vec = std::fs::read_dir(&dir) + .with_context(|| format!("reading providers directory {}", dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|alias| exists(alias)) + .collect(); + names.sort(); + Ok(names) +} + +/// Load a provider profile by alias. +pub fn load(alias: &str) -> Result { + let path = provider_path(alias)?; + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("reading provider profile {}", path.display()))?; + let mut profile: ProviderProfile = toml::from_str(&raw) + .with_context(|| format!("parsing provider profile {}", path.display()))?; + profile.alias = alias.to_string(); + Ok(profile) +} + +/// Persist a provider profile (directory `0700`, file `0600`). +pub fn save(profile: &ProviderProfile) -> Result<()> { + let dir = provider_dir(&profile.alias)?; + ensure_private_dir(&dir)?; + let path = dir.join("provider.toml"); + let toml = toml::to_string_pretty(profile).context("serializing provider profile")?; + auth::atomic_write_private(&path, toml.as_bytes()) + .with_context(|| format!("writing provider profile {}", path.display())) +} + +/// Remove a provider profile and its stored key. +pub fn remove(alias: &str) -> Result<()> { + let dir = provider_dir(alias)?; + if !dir.exists() { + anyhow::bail!("provider '{alias}' not found"); + } + std::fs::remove_dir_all(&dir) + .with_context(|| format!("removing provider profile {}", dir.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use std::sync::MutexGuard; + + struct TestHome { + _lock: MutexGuard<'static, ()>, + _home: tempfile::TempDir, + previous: Option, + } + + impl TestHome { + fn new() -> Self { + let lock = crate::profile::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let home = tempfile::tempdir().unwrap(); + let previous = std::env::var_os("CODEX_SWITCH_HOME"); + unsafe { + std::env::set_var("CODEX_SWITCH_HOME", home.path()); + } + Self { + _lock: lock, + _home: home, + previous, + } + } + } + + impl Drop for TestHome { + fn drop(&mut self) { + unsafe { + match &self.previous { + Some(value) => std::env::set_var("CODEX_SWITCH_HOME", value), + None => std::env::remove_var("CODEX_SWITCH_HOME"), + } + } + } + } + + fn sample(alias: &str) -> ProviderProfile { + ProviderProfile { + alias: alias.to_string(), + provider_id: sanitize_provider_id(alias), + name: "OpenRouter".to_string(), + base_url: "https://openrouter.ai/api/v1".to_string(), + env_key: derive_env_key(alias), + model: "openai/gpt-5.3-codex".to_string(), + wire_api: default_wire_api(), + api_key: "sk-secret-1234".to_string(), + } + } + + #[test] + fn env_key_is_derived_from_the_alias_and_owned_by_codex_switch() { + assert_eq!(derive_env_key("openrouter"), "CODEX_SWITCH_OPENROUTER_KEY"); + assert_eq!( + derive_env_key("my-router.2"), + "CODEX_SWITCH_MY_ROUTER_2_KEY" + ); + } + + #[test] + fn provider_id_is_sanitized_lowercase() { + assert_eq!(sanitize_provider_id("My-Router.2"), "my_router_2"); + } + + #[test] + fn validate_accepts_a_well_formed_profile() { + assert!(sample("openrouter").validate().is_ok()); + } + + #[test] + fn validate_rejects_reserved_ids_empty_name_and_bad_url() { + let mut reserved = sample("openai"); + reserved.provider_id = "openai".to_string(); + assert!(reserved.validate().is_err(), "reserved id must be rejected"); + + let mut no_name = sample("p"); + no_name.name = " ".to_string(); + assert!(no_name.validate().is_err(), "empty name must be rejected"); + + let mut bad_url = sample("p"); + bad_url.base_url = "openrouter.ai/api/v1".to_string(); + assert!( + bad_url.validate().is_err(), + "base_url without a scheme must be rejected" + ); + + let mut no_key = sample("p"); + no_key.api_key = String::new(); + assert!(no_key.validate().is_err(), "empty api_key must be rejected"); + } + + #[test] + fn redact_never_leaks_the_raw_key() { + assert_eq!(redact_key("sk-secret-1234"), "…1234"); + assert_eq!(redact_key("tiny"), "****"); + assert!(!redact_key("sk-secret-1234").contains("secret")); + } + + #[test] + fn save_load_list_remove_round_trip() { + let _home = TestHome::new(); + assert!(list_providers().unwrap().is_empty()); + + let profile = sample("openrouter"); + save(&profile).unwrap(); + + assert!(exists("openrouter")); + assert_eq!(list_providers().unwrap(), vec!["openrouter".to_string()]); + + let loaded = load("openrouter").unwrap(); + assert_eq!(loaded.alias, "openrouter"); + assert_eq!(loaded.base_url, profile.base_url); + assert_eq!(loaded.env_key, "CODEX_SWITCH_OPENROUTER_KEY"); + assert_eq!(loaded.api_key, "sk-secret-1234"); + assert_eq!(loaded.wire_api, "responses"); + + remove("openrouter").unwrap(); + assert!(!exists("openrouter")); + assert!(list_providers().unwrap().is_empty()); + assert!(remove("openrouter").is_err(), "removing twice must error"); + } + + #[cfg(unix)] + #[test] + fn saved_key_file_is_private() { + use std::os::unix::fs::PermissionsExt; + let _home = TestHome::new(); + save(&sample("openrouter")).unwrap(); + let mode = std::fs::metadata(provider_path("openrouter").unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "the stored API key must not be world/group readable" + ); + } +}