diff --git a/Cargo.lock b/Cargo.lock index b58d74b..911fcca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3890,17 +3890,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-test" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -5126,7 +5115,6 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-serial", - "tokio-test", "tokio-tungstenite 0.24.0", "toml", "tower", diff --git a/Cargo.toml b/Cargo.toml index 2971871..92a063f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ chacha20poly1305 = "0.10" hmac = "0.12" sha2 = "0.10" hex = "0.4" -base64 = "0.22" # CSPRNG for secure token generation rand = "0.9" diff --git a/src/auth/anthropic_token.rs b/src/auth/anthropic_token.rs index c5f2f1c..fdf275b 100644 --- a/src/auth/anthropic_token.rs +++ b/src/auth/anthropic_token.rs @@ -21,9 +21,7 @@ impl AnthropicAuthKind { pub fn from_metadata_value(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "api-key" | "x-api-key" | "apikey" => Some(Self::ApiKey), - "authorization" | "bearer" | "auth-token" | "oauth" => { - Some(Self::Authorization) - } + "authorization" | "bearer" | "auth-token" | "oauth" => Some(Self::Authorization), _ => None, } } diff --git a/src/auth/profiles.rs b/src/auth/profiles.rs index 46884ae..48ba6ce 100644 --- a/src/auth/profiles.rs +++ b/src/auth/profiles.rs @@ -41,7 +41,8 @@ impl TokenSet { pub fn is_expiring_within(&self, skew: Duration) -> bool { match self.expires_at { Some(expires_at) => { - let now_plus_skew = Utc::now() + chrono::Duration::from_std(skew).unwrap_or_default(); + let now_plus_skew = + Utc::now() + chrono::Duration::from_std(skew).unwrap_or_default(); expires_at <= now_plus_skew } None => false, @@ -180,7 +181,8 @@ impl AuthProfilesStore { return Ok(false); } - data.active_profiles.retain(|_, active| active != profile_id); + data.active_profiles + .retain(|_, active| active != profile_id); data.updated_at = Utc::now(); self.save_locked(&data)?; Ok(true) @@ -234,7 +236,8 @@ impl AuthProfilesStore { let mut profiles = BTreeMap::new(); for (id, p) in &mut persisted.profiles { - let (access_token, access_migrated) = self.decrypt_optional(p.access_token.as_deref())?; + let (access_token, access_migrated) = + self.decrypt_optional(p.access_token.as_deref())?; let (refresh_token, refresh_migrated) = self.decrypt_optional(p.refresh_token.as_deref())?; let (id_token, id_migrated) = self.decrypt_optional(p.id_token.as_deref())?; @@ -370,12 +373,13 @@ impl AuthProfilesStore { return Ok(PersistedAuthProfiles::default()); } - let mut persisted: PersistedAuthProfiles = serde_json::from_slice(&bytes).with_context(|| { - format!( - "Failed to parse auth profile store at {}", - self.path.display() - ) - })?; + let mut persisted: PersistedAuthProfiles = + serde_json::from_slice(&bytes).with_context(|| { + format!( + "Failed to parse auth profile store at {}", + self.path.display() + ) + })?; if persisted.schema_version == 0 { persisted.schema_version = CURRENT_SCHEMA_VERSION; @@ -402,7 +406,8 @@ impl AuthProfilesStore { })?; } - let json = serde_json::to_vec_pretty(persisted).context("Failed to serialize auth profiles")?; + let json = + serde_json::to_vec_pretty(persisted).context("Failed to serialize auth profiles")?; let tmp_name = format!( "{}.tmp.{}.{}", PROFILES_FILENAME, @@ -576,9 +581,7 @@ fn profile_kind_to_string(kind: AuthProfileKind) -> &'static str { } fn parse_optional_datetime(value: Option<&str>) -> Result>> { - value - .map(parse_datetime) - .transpose() + value.map(parse_datetime).transpose() } fn parse_datetime(value: &str) -> Result> { @@ -602,7 +605,10 @@ mod tests { #[test] fn profile_id_format() { - assert_eq!(profile_id("openai-codex", "default"), "openai-codex:default"); + assert_eq!( + profile_id("openai-codex", "default"), + "openai-codex:default" + ); } #[test] diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 9235899..2cf7892 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -955,7 +955,7 @@ pub async fn start_channels(config: Config) -> Result<()> { .default_provider .clone() .unwrap_or_else(|| "openrouter".into()); - let provider: Arc = Arc::from(providers::create_resilient_provider( + let provider: Arc = Arc::from(providers::create_resilient_provider_with_options( &provider_name, config.api_key.as_deref(), config.api_url.as_deref(), diff --git a/src/lib.rs b/src/lib.rs index 9856880..0166bd5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,7 @@ use serde::{Deserialize, Serialize}; pub mod agent; pub mod approval; +pub mod auth; pub mod channels; pub mod config; pub mod cost; diff --git a/src/main.rs b/src/main.rs index 5ff12dc..21b22f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,13 +32,16 @@ dead_code )] -use anyhow::{Result, bail}; +use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; -use tracing::info; +use dialoguer::{Input, Password}; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; use tracing_subscriber::{fmt, EnvFilter}; mod agent; mod approval; +mod auth; mod channels; mod rag { pub use zeroclaw::rag::*; @@ -219,6 +222,12 @@ enum Commands { migrate_command: MigrateCommands, }, + /// Manage provider subscription authentication profiles + Auth { + #[command(subcommand)] + auth_command: AuthCommands, + }, + /// Discover and introspect USB hardware Hardware { #[command(subcommand)] @@ -232,6 +241,89 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum AuthCommands { + /// Login with OpenAI Codex OAuth + Login { + /// Provider (`openai-codex`) + #[arg(long)] + provider: String, + /// Profile name (default: default) + #[arg(long, default_value = "default")] + profile: String, + /// Use OAuth device-code flow + #[arg(long)] + device_code: bool, + }, + /// Complete OAuth by pasting redirect URL or auth code + PasteRedirect { + /// Provider (`openai-codex`) + #[arg(long)] + provider: String, + /// Profile name (default: default) + #[arg(long, default_value = "default")] + profile: String, + /// Full redirect URL or raw OAuth code + #[arg(long)] + input: Option, + }, + /// Paste setup token / auth token (for Anthropic subscription auth) + PasteToken { + /// Provider (`anthropic`) + #[arg(long)] + provider: String, + /// Profile name (default: default) + #[arg(long, default_value = "default")] + profile: String, + /// Token value (if omitted, read interactively) + #[arg(long)] + token: Option, + /// Auth kind override (`authorization` or `api-key`) + #[arg(long)] + auth_kind: Option, + }, + /// Alias for `paste-token` (interactive by default) + SetupToken { + /// Provider (`anthropic`) + #[arg(long)] + provider: String, + /// Profile name (default: default) + #[arg(long, default_value = "default")] + profile: String, + }, + /// Refresh OpenAI Codex access token using refresh token + Refresh { + /// Provider (`openai-codex`) + #[arg(long)] + provider: String, + /// Profile name or profile id + #[arg(long)] + profile: Option, + }, + /// Remove auth profile + Logout { + /// Provider + #[arg(long)] + provider: String, + /// Profile name (default: default) + #[arg(long, default_value = "default")] + profile: String, + }, + /// Set active profile for a provider + Use { + /// Provider + #[arg(long)] + provider: String, + /// Profile name or full profile id + #[arg(long)] + profile: String, + }, + /// List auth profiles + List, + /// Show auth status with active profile and token expiry info + Status, +} + #[derive(Subcommand, Debug)] enum MigrateCommands { /// Import memory from an `OpenClaw` workspace into this `ZeroClaw` workspace @@ -609,6 +701,8 @@ async fn main() -> Result<()> { migration::handle_command(migrate_command, &config).await } + Commands::Auth { auth_command } => handle_auth_command(auth_command, &config).await, + Commands::Hardware { hardware_command } => { hardware::handle_command(hardware_command.clone(), &config) } @@ -619,6 +713,443 @@ async fn main() -> Result<()> { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PendingOpenAiLogin { + profile: String, + code_verifier: String, + state: String, + created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PendingOpenAiLoginFile { + profile: String, + #[serde(skip_serializing_if = "Option::is_none")] + code_verifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + encrypted_code_verifier: Option, + state: String, + created_at: String, +} + +fn pending_openai_login_path(config: &Config) -> std::path::PathBuf { + auth::state_dir_from_config(config).join("auth-openai-pending.json") +} + +fn pending_openai_secret_store(config: &Config) -> security::secrets::SecretStore { + security::secrets::SecretStore::new( + &auth::state_dir_from_config(config), + config.secrets.encrypt, + ) +} + +#[cfg(unix)] +fn set_owner_only_permissions(path: &std::path::Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_owner_only_permissions(_path: &std::path::Path) -> Result<()> { + Ok(()) +} + +fn save_pending_openai_login(config: &Config, pending: &PendingOpenAiLogin) -> Result<()> { + let path = pending_openai_login_path(config); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let secret_store = pending_openai_secret_store(config); + let encrypted_code_verifier = secret_store.encrypt(&pending.code_verifier)?; + let persisted = PendingOpenAiLoginFile { + profile: pending.profile.clone(), + code_verifier: None, + encrypted_code_verifier: Some(encrypted_code_verifier), + state: pending.state.clone(), + created_at: pending.created_at.clone(), + }; + let tmp = path.with_extension(format!( + "tmp.{}.{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let json = serde_json::to_vec_pretty(&persisted)?; + std::fs::write(&tmp, json)?; + set_owner_only_permissions(&tmp)?; + std::fs::rename(tmp, &path)?; + set_owner_only_permissions(&path)?; + Ok(()) +} + +fn load_pending_openai_login(config: &Config) -> Result> { + let path = pending_openai_login_path(config); + if !path.exists() { + return Ok(None); + } + let bytes = std::fs::read(path)?; + if bytes.is_empty() { + return Ok(None); + } + let persisted: PendingOpenAiLoginFile = serde_json::from_slice(&bytes)?; + let secret_store = pending_openai_secret_store(config); + let code_verifier = if let Some(encrypted) = persisted.encrypted_code_verifier { + secret_store.decrypt(&encrypted)? + } else if let Some(plaintext) = persisted.code_verifier { + plaintext + } else { + bail!("Pending OpenAI login is missing code verifier"); + }; + Ok(Some(PendingOpenAiLogin { + profile: persisted.profile, + code_verifier, + state: persisted.state, + created_at: persisted.created_at, + })) +} + +fn clear_pending_openai_login(config: &Config) { + let path = pending_openai_login_path(config); + if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&path) { + let _ = file.set_len(0); + let _ = file.sync_all(); + } + let _ = std::fs::remove_file(path); +} + +fn read_auth_input(prompt: &str) -> Result { + let input = Password::new() + .with_prompt(prompt) + .allow_empty_password(false) + .interact()?; + Ok(input.trim().to_string()) +} + +fn read_plain_input(prompt: &str) -> Result { + let input: String = Input::new().with_prompt(prompt).interact_text()?; + Ok(input.trim().to_string()) +} + +fn extract_openai_account_id_for_profile(access_token: &str) -> Option { + let account_id = auth::openai_oauth::extract_account_id_from_jwt(access_token); + if account_id.is_none() { + warn!( + "Could not extract OpenAI account id from OAuth access token; \ + requests may fail until re-authentication." + ); + } + account_id +} + +fn format_expiry(profile: &auth::profiles::AuthProfile) -> String { + match profile + .token_set + .as_ref() + .and_then(|token_set| token_set.expires_at.as_ref().cloned()) + { + Some(ts) => { + let now = chrono::Utc::now(); + if ts <= now { + format!("expired at {}", ts.to_rfc3339()) + } else { + let mins = (ts - now).num_minutes(); + format!("expires in {mins}m ({})", ts.to_rfc3339()) + } + } + None => "n/a".to_string(), + } +} + +#[allow(clippy::too_many_lines)] +async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Result<()> { + let auth_service = auth::AuthService::from_config(config); + + match auth_command { + AuthCommands::Login { + provider, + profile, + device_code, + } => { + let provider = auth::normalize_provider(&provider)?; + if provider != "openai-codex" { + bail!("`auth login` currently supports only --provider openai-codex"); + } + + let client = reqwest::Client::new(); + + if device_code { + match auth::openai_oauth::start_device_code_flow(&client).await { + Ok(device) => { + println!("OpenAI device-code login started."); + println!("Visit: {}", device.verification_uri); + println!("Code: {}", device.user_code); + if let Some(uri_complete) = &device.verification_uri_complete { + println!("Fast link: {uri_complete}"); + } + if let Some(message) = &device.message { + println!("{message}"); + } + + let token_set = + auth::openai_oauth::poll_device_code_tokens(&client, &device).await?; + let account_id = + extract_openai_account_id_for_profile(&token_set.access_token); + + let saved = auth_service + .store_openai_tokens(&profile, token_set, account_id, true)?; + clear_pending_openai_login(config); + + println!("Saved profile {}", saved.id); + println!("Active profile for openai-codex: {}", saved.id); + return Ok(()); + } + Err(e) => { + println!( + "Device-code flow unavailable: {e}. Falling back to browser/paste flow." + ); + } + } + } + + let pkce = auth::openai_oauth::generate_pkce_state(); + let pending = PendingOpenAiLogin { + profile: profile.clone(), + code_verifier: pkce.code_verifier.clone(), + state: pkce.state.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + }; + save_pending_openai_login(config, &pending)?; + + let authorize_url = auth::openai_oauth::build_authorize_url(&pkce); + println!("Open this URL in your browser and authorize access:"); + println!("{authorize_url}"); + println!(); + println!("Waiting for callback at http://localhost:1455/auth/callback ..."); + + let code = match auth::openai_oauth::receive_loopback_code( + &pkce.state, + std::time::Duration::from_secs(180), + ) + .await + { + Ok(code) => code, + Err(e) => { + println!("Callback capture failed: {e}"); + println!( + "Run `zeroclaw auth paste-redirect --provider openai-codex --profile {profile}`" + ); + return Ok(()); + } + }; + + let token_set = + auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; + let account_id = extract_openai_account_id_for_profile(&token_set.access_token); + + let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; + clear_pending_openai_login(config); + + println!("Saved profile {}", saved.id); + println!("Active profile for openai-codex: {}", saved.id); + Ok(()) + } + + AuthCommands::PasteRedirect { + provider, + profile, + input, + } => { + let provider = auth::normalize_provider(&provider)?; + if provider != "openai-codex" { + bail!("`auth paste-redirect` currently supports only --provider openai-codex"); + } + + let pending = load_pending_openai_login(config)?.ok_or_else(|| { + anyhow::anyhow!( + "No pending OpenAI login found. Run `zeroclaw auth login --provider openai-codex` first." + ) + })?; + + if pending.profile != profile { + bail!( + "Pending login profile mismatch: pending={}, requested={}", + pending.profile, + profile + ); + } + + let redirect_input = match input { + Some(value) => value, + None => read_plain_input("Paste redirect URL or OAuth code")?, + }; + + let code = auth::openai_oauth::parse_code_from_redirect( + &redirect_input, + Some(&pending.state), + )?; + + let pkce = auth::openai_oauth::PkceState { + code_verifier: pending.code_verifier.clone(), + code_challenge: String::new(), + state: pending.state.clone(), + }; + + let client = reqwest::Client::new(); + let token_set = + auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; + let account_id = extract_openai_account_id_for_profile(&token_set.access_token); + + let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; + clear_pending_openai_login(config); + + println!("Saved profile {}", saved.id); + println!("Active profile for openai-codex: {}", saved.id); + Ok(()) + } + + AuthCommands::PasteToken { + provider, + profile, + token, + auth_kind, + } => { + let provider = auth::normalize_provider(&provider)?; + let token = match token { + Some(token) => token.trim().to_string(), + None => read_auth_input("Paste token")?, + }; + if token.is_empty() { + bail!("Token cannot be empty"); + } + + let kind = auth::anthropic_token::detect_auth_kind(&token, auth_kind.as_deref()); + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "auth_kind".to_string(), + kind.as_metadata_value().to_string(), + ); + + let saved = + auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; + println!("Saved profile {}", saved.id); + println!("Active profile for {provider}: {}", saved.id); + Ok(()) + } + + AuthCommands::SetupToken { provider, profile } => { + let provider = auth::normalize_provider(&provider)?; + let token = read_auth_input("Paste token")?; + if token.is_empty() { + bail!("Token cannot be empty"); + } + + let kind = auth::anthropic_token::detect_auth_kind(&token, Some("authorization")); + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "auth_kind".to_string(), + kind.as_metadata_value().to_string(), + ); + + let saved = + auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; + println!("Saved profile {}", saved.id); + println!("Active profile for {provider}: {}", saved.id); + Ok(()) + } + + AuthCommands::Refresh { provider, profile } => { + let provider = auth::normalize_provider(&provider)?; + if provider != "openai-codex" { + bail!("`auth refresh` currently supports only --provider openai-codex"); + } + + match auth_service + .get_valid_openai_access_token(profile.as_deref()) + .await? + { + Some(_) => { + println!("OpenAI Codex token is valid (refresh completed if needed)."); + Ok(()) + } + None => { + bail!( + "No OpenAI Codex auth profile found. Run `zeroclaw auth login --provider openai-codex`." + ) + } + } + } + + AuthCommands::Logout { provider, profile } => { + let provider = auth::normalize_provider(&provider)?; + let removed = auth_service.remove_profile(&provider, &profile)?; + if removed { + println!("Removed auth profile {provider}:{profile}"); + } else { + println!("Auth profile not found: {provider}:{profile}"); + } + Ok(()) + } + + AuthCommands::Use { provider, profile } => { + let provider = auth::normalize_provider(&provider)?; + let active = auth_service.set_active_profile(&provider, &profile)?; + println!("Active profile for {provider}: {active}"); + Ok(()) + } + + AuthCommands::List => { + let data = auth_service.load_profiles()?; + if data.profiles.is_empty() { + println!("No auth profiles configured."); + return Ok(()); + } + + for (id, profile) in &data.profiles { + let active = data + .active_profiles + .get(&profile.provider) + .is_some_and(|active_id| active_id == id); + let marker = if active { "*" } else { " " }; + println!("{marker} {id}"); + } + + Ok(()) + } + + AuthCommands::Status => { + let data = auth_service.load_profiles()?; + if data.profiles.is_empty() { + println!("No auth profiles configured."); + return Ok(()); + } + + for (id, profile) in &data.profiles { + let active = data + .active_profiles + .get(&profile.provider) + .is_some_and(|active_id| active_id == id); + let marker = if active { "*" } else { " " }; + println!( + "{} {} kind={:?} account={} expires={}", + marker, + id, + profile.kind, + profile.account_id.as_deref().unwrap_or("unknown"), + format_expiry(profile) + ); + } + + println!(); + println!("Active profiles:"); + for (provider, active) in &data.active_profiles { + println!(" {provider}: {active}"); + } + + Ok(()) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index c9cf52d..3c2e9b1 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -4083,8 +4083,10 @@ fn print_summary(config: &Config) { ); println!( " {}", - style("or: zeroclaw auth paste-token --provider anthropic --auth-kind authorization") - .yellow() + style( + "or: zeroclaw auth paste-token --provider anthropic --auth-kind authorization" + ) + .yellow() ); } else { let env_var = provider_env_var(provider); diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 58975f8..9b3a75f 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -6,7 +6,6 @@ use crate::tools::ToolSpec; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; pub struct AnthropicProvider { credential: Option, @@ -408,6 +407,7 @@ impl Provider for AnthropicProvider { #[cfg(test)] mod tests { use super::*; + use crate::auth::anthropic_token::{detect_auth_kind, AnthropicAuthKind}; #[test] fn creates_with_key() { diff --git a/src/providers/mod.rs b/src/providers/mod.rs index ae3dea9..1888b09 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -374,7 +374,21 @@ fn parse_custom_provider_url( /// Factory: create the right provider from config (without custom URL) pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result> { - create_provider_with_url(name, api_key, None) + create_provider_with_options(name, api_key, &ProviderRuntimeOptions::default()) +} + +/// Factory: create provider with runtime options (auth profile override, state dir). +pub fn create_provider_with_options( + name: &str, + api_key: Option<&str>, + options: &ProviderRuntimeOptions, +) -> anyhow::Result> { + match name { + "openai-codex" | "openai_codex" | "codex" => { + Ok(Box::new(openai_codex::OpenAiCodexProvider::new(options))) + } + _ => create_provider_with_url(name, api_key, None), + } } /// Factory: create the right provider from config with optional custom base URL @@ -561,6 +575,7 @@ pub fn create_resilient_provider( create_resilient_provider_with_options( primary_name, api_key, + api_url, reliability, &ProviderRuntimeOptions::default(), ) @@ -570,23 +585,27 @@ pub fn create_resilient_provider( pub fn create_resilient_provider_with_options( primary_name: &str, api_key: Option<&str>, + api_url: Option<&str>, reliability: &crate::config::ReliabilityConfig, options: &ProviderRuntimeOptions, ) -> anyhow::Result> { let mut providers: Vec<(String, Box)> = Vec::new(); - providers.push(( - primary_name.to_string(), - create_provider_with_url(primary_name, api_key, api_url)?, - )); + let primary_provider = match primary_name { + "openai-codex" | "openai_codex" | "codex" => { + create_provider_with_options(primary_name, api_key, options)? + } + _ => create_provider_with_url(primary_name, api_key, api_url)?, + }; + providers.push((primary_name.to_string(), primary_provider)); for fallback in &reliability.fallback_providers { if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) { continue; } - // Fallback providers don't use the custom api_url (it's specific to primary) - match create_provider(fallback, api_key) { + // Fallback providers don't use the custom api_url (it's specific to primary). + match create_provider_with_options(fallback, api_key, options) { Ok(provider) => providers.push((fallback.clone(), provider)), Err(_error) => { tracing::warn!( @@ -718,6 +737,12 @@ pub fn list_providers() -> Vec { aliases: &[], local: false, }, + ProviderInfo { + name: "openai-codex", + display_name: "OpenAI Codex (OAuth)", + aliases: &["openai_codex", "codex"], + local: false, + }, ProviderInfo { name: "ollama", display_name: "Ollama", diff --git a/src/providers/openai_codex.rs b/src/providers/openai_codex.rs index be9035d..e01dd82 100644 --- a/src/providers/openai_codex.rs +++ b/src/providers/openai_codex.rs @@ -195,7 +195,7 @@ fn extract_stream_event_text(event: &Value, saw_delta: bool) -> Option { Some("response.output_text.done") if !saw_delta => { nonempty_preserve(event.get("text").and_then(Value::as_str)) } - Some("response.completed") | Some("response.done") => event + Some("response.completed" | "response.done") => event .get("response") .and_then(|value| serde_json::from_value::(value.clone()).ok()) .and_then(|response| extract_responses_text(&response)),