diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 39b633217..b4767f39c 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1,14 +1,12 @@ -// unused imports removed -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::config::{ - atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file, - write_text_file, + atomic_write, delete_file, get_home_dir, read_json_file, sanitize_provider_name, + write_json_file, write_text_file, }; use crate::error::AppError; use serde_json::{json, Value}; use std::fs; -use std::path::Path; use std::process::Command; use toml_edit::DocumentMut; @@ -393,20 +391,123 @@ pub fn write_codex_live_atomic_with_stable_provider( ) -> Result<(), AppError> { match config_text_opt { Some(config_text) => { - let mut settings = serde_json::Map::new(); - settings.insert("config".to_string(), Value::String(config_text.to_string())); - let mut settings = Value::Object(settings); - normalize_codex_settings_config_model_provider(&mut settings)?; - let config_text = settings - .get("config") - .and_then(|value| value.as_str()) - .unwrap_or(config_text); - write_codex_live_atomic(auth, Some(config_text)) + let config_text = normalize_codex_config_for_live_provider(config_text)?; + write_codex_live_atomic(auth, Some(&config_text)) } None => write_codex_live_atomic(auth, None), } } +fn normalize_codex_config_for_live_provider(config_text: &str) -> Result { + let mut settings = serde_json::Map::new(); + settings.insert("config".to_string(), Value::String(config_text.to_string())); + let mut settings = Value::Object(settings); + normalize_codex_settings_config_model_provider(&mut settings)?; + Ok(settings + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or(config_text) + .to_string()) +} + +/// Write only Codex `config.toml` for provider switching. +/// +/// Codex login state lives in `auth.json`; provider routing, endpoint, model, +/// and provider-scoped bearer tokens live in `config.toml`. Provider switches +/// should not overwrite the user's ChatGPT login cache. +pub fn write_codex_live_config_atomic_with_stable_provider( + config_text_opt: Option<&str>, +) -> Result<(), AppError> { + let config_path = get_codex_config_path(); + let cfg_text = match config_text_opt { + Some(config_text) => normalize_codex_config_for_live_provider(config_text)?, + None => String::new(), + }; + + if !cfg_text.trim().is_empty() { + toml::from_str::(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?; + } + + write_text_file(&config_path, &cfg_text) +} + +pub fn extract_codex_auth_api_key(auth: &Value) -> Option { + auth.get("OPENAI_API_KEY") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) +} + +pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) -> Option { + auth.and_then(extract_codex_auth_api_key) + .or_else(|| config_text.and_then(extract_codex_experimental_bearer_token)) +} + +pub fn codex_auth_has_login_material(auth: &Value) -> bool { + let Some(obj) = auth.as_object() else { + return false; + }; + + obj.iter().any(|(key, value)| { + if key == "auth_mode" { + return false; + } + + if key == "OPENAI_API_KEY" { + return value + .as_str() + .map(str::trim) + .is_some_and(|token| !token.is_empty()); + } + + match value { + Value::Null => false, + Value::String(text) => !text.trim().is_empty(), + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + _ => true, + } + }) +} + +pub fn codex_auth_has_oauth_login_material(auth: &Value) -> bool { + let Some(obj) = auth.as_object() else { + return false; + }; + + obj.iter().any(|(key, value)| { + if key == "auth_mode" || key == "OPENAI_API_KEY" { + return false; + } + + match value { + Value::Null => false, + Value::String(text) => !text.trim().is_empty(), + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + _ => true, + } + }) +} + +pub fn should_restore_codex_provider_token_for_backfill( + category: Option<&str>, + template_settings: &Value, +) -> bool { + if category == Some("official") { + return false; + } + + let Some(auth) = template_settings.get("auth") else { + return true; + }; + + let has_provider_api_key = extract_codex_auth_api_key(auth).is_some(); + let has_oauth_login = codex_auth_has_oauth_login_material(auth); + !(has_oauth_login && !has_provider_api_key) +} + fn parse_codex_positive_u64(value: Option<&Value>) -> Option { match value { Some(Value::Number(n)) => n.as_u64().filter(|v| *v > 0), @@ -787,6 +888,235 @@ pub fn write_codex_live_with_catalog( write_codex_live_atomic_with_stable_provider(auth, prepared_config.as_deref()) } +pub fn write_codex_provider_live_with_catalog( + settings: &Value, + category: Option<&str>, + auth: &Value, + config_text: Option<&str>, +) -> Result<(), AppError> { + let prepared_config = config_text + .map(|text| prepare_codex_config_text_with_model_catalog(settings, text)) + .transpose()?; + + write_codex_live_for_provider(category, auth, prepared_config.as_deref()) +} + +/// Extract a provider-scoped `experimental_bearer_token` from Codex `config.toml`. +/// +/// Mobile compat: third-party providers may store the API key inside +/// `[model_providers.].experimental_bearer_token` while keeping the +/// user's ChatGPT login cache intact in `auth.json`. Falls back to the +/// top-level `experimental_bearer_token` when no active model provider is set. +pub fn extract_codex_experimental_bearer_token(config_text: &str) -> Option { + if !config_text.contains("experimental_bearer_token") { + return None; + } + let doc = config_text.parse::().ok()?; + let provider_id = active_codex_model_provider_id(&doc); + + let top_level_token = || { + doc.get("experimental_bearer_token") + .and_then(|item| item.as_str()) + }; + let token = match provider_id.as_deref() { + Some(id) if is_custom_codex_model_provider_id(id) => doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|table| table.get(id)) + .and_then(|item| item.as_table()) + .and_then(|table| table.get("experimental_bearer_token")) + .and_then(|item| item.as_str()) + .or_else(top_level_token), + Some(_) => top_level_token(), + None => top_level_token(), + }; + + token + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_string) +} + +fn set_codex_experimental_bearer_token(config_text: &str, token: &str) -> Result { + if config_text.trim().is_empty() { + return Err(AppError::localized( + "provider.codex.config.missing", + "Codex 第三方供应商缺少 config.toml 配置,无法写入 bearer token", + "Codex third-party provider is missing config.toml, cannot write bearer token", + )); + } + + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + let Some(provider_id) = active_codex_model_provider_id(&doc) else { + doc["experimental_bearer_token"] = toml_edit::value(token); + return Ok(doc.to_string()); + }; + + if !is_custom_codex_model_provider_id(&provider_id) { + // Reserved Codex provider IDs are owned by the CLI. Keep third-party + // bearer tokens at the top level so we do not shadow built-in tables. + doc["experimental_bearer_token"] = toml_edit::value(token); + return Ok(doc.to_string()); + } + + if let Some(model_providers) = doc + .get_mut("model_providers") + .and_then(|item| item.as_table_mut()) + { + if let Some(provider_table) = model_providers + .get_mut(provider_id.as_str()) + .and_then(|item| item.as_table_mut()) + { + provider_table["experimental_bearer_token"] = toml_edit::value(token); + return Ok(doc.to_string()); + } + } + + doc["experimental_bearer_token"] = toml_edit::value(token); + Ok(doc.to_string()) +} + +fn remove_codex_experimental_bearer_token(config_text: &str) -> Result { + if config_text.trim().is_empty() || !config_text.contains("experimental_bearer_token") { + return Ok(config_text.to_string()); + } + + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + if let Some(provider_id) = active_codex_model_provider_id(&doc) { + if let Some(provider_table) = doc + .get_mut("model_providers") + .and_then(|item| item.as_table_mut()) + .and_then(|table| table.get_mut(provider_id.as_str())) + .and_then(|item| item.as_table_mut()) + { + provider_table.remove("experimental_bearer_token"); + } + } + + doc.as_table_mut().remove("experimental_bearer_token"); + Ok(doc.to_string()) +} + +/// Read the current Codex live settings as a `{ auth, config }` object. +/// +/// Missing `auth.json` collapses to `{}` so a config-only third-party install +/// is still importable; both files empty is treated as "no live install". +pub fn read_codex_live_settings() -> Result { + let auth_path = get_codex_auth_path(); + let auth_present = auth_path.exists(); + let auth: Value = if auth_present { + read_json_file(&auth_path)? + } else { + json!({}) + }; + let cfg_text = read_and_validate_codex_config_text()?; + if !auth_present && cfg_text.trim().is_empty() { + return Err(AppError::localized( + "codex.live.missing", + "Codex 配置文件不存在", + "Codex configuration is missing", + )); + } + Ok(json!({ "auth": auth, "config": cfg_text })) +} + +/// Route a Codex live write between full auth+config or config-only. +/// +/// Official providers with usable login material own `auth.json`; everyone +/// else only touches `config.toml` so the user's ChatGPT login cache survives +/// third-party switches. +pub fn write_codex_live_for_provider( + category: Option<&str>, + auth: &Value, + config_text: Option<&str>, +) -> Result<(), AppError> { + if category == Some("official") && codex_auth_has_login_material(auth) { + write_codex_live_atomic_with_stable_provider(auth, config_text) + } else { + let live_config = prepare_codex_provider_live_config(auth, config_text.unwrap_or(""))?; + write_codex_live_config_atomic_with_stable_provider(Some(&live_config)) + } +} + +/// Build the live Codex config for provider switching. +/// +/// The stored provider keeps its API key in `auth.OPENAI_API_KEY`. Live Codex +/// requests can use a provider-scoped `experimental_bearer_token`, so switching +/// providers only needs to update `config.toml`; `auth.json` stays as the user's +/// long-lived ChatGPT login cache. +pub fn prepare_codex_provider_live_config( + auth: &Value, + config_text: &str, +) -> Result { + let token = extract_codex_auth_api_key(auth) + .or_else(|| extract_codex_experimental_bearer_token(config_text)); + + Ok(match token { + Some(token) => set_codex_experimental_bearer_token(config_text, &token)?, + None => config_text.to_string(), + }) +} + +/// During DB backfill, lift a live `experimental_bearer_token` back into +/// `auth.OPENAI_API_KEY` so the stored provider keeps its canonical shape +/// and generated live tokens don't leak into stored provider TOML. +/// +/// Only intervenes when the live config actually carries a bearer token — +/// otherwise the function is a no-op so the caller's normal backfill path +/// (which keeps live `auth` as the authoritative source) is unaffected. +pub fn restore_codex_provider_token_for_backfill( + settings: &mut Value, + template_settings: &Value, +) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + + let Some(token) = extract_codex_experimental_bearer_token(&config_text) else { + return Ok(()); + }; + + let cleaned_config = remove_codex_experimental_bearer_token(&config_text)?; + + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(cleaned_config)); + + let mut auth = template_settings + .get("auth") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + if let Some(auth_obj) = auth.as_object_mut() { + auth_obj.insert("OPENAI_API_KEY".to_string(), Value::String(token)); + } + obj.insert("auth".to_string(), auth); + } + + Ok(()) +} + +pub fn restore_codex_settings_for_backfill( + settings: &mut Value, + template_settings: &Value, + restore_provider_token: bool, +) -> Result<(), AppError> { + restore_codex_settings_config_model_provider_for_backfill(settings, template_settings)?; + if restore_provider_token { + restore_codex_provider_token_for_backfill(settings, template_settings)?; + } + Ok(()) +} + /// Update a field in Codex config.toml using toml_edit (syntax-preserving). /// /// Supported fields: @@ -905,6 +1235,7 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn normalize_live_config_uses_custom_for_third_party_model_provider_id() { @@ -984,6 +1315,108 @@ wire_api = "responses" assert_eq!(result, ""); } + #[test] + fn prepare_provider_live_config_rejects_key_without_config() { + let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "") + .expect_err("empty config with API key should not truncate live config"); + + assert!( + err.to_string().contains("config.toml"), + "error should explain missing config.toml, got: {err}" + ); + } + + #[test] + fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() { + let input = r#"model_provider = "openai" +model = "gpt-5" +"#; + + let output = + prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), input) + .expect("prepare live config"); + let parsed: toml::Value = toml::from_str(&output).expect("parse output"); + + assert_eq!( + parsed + .get("experimental_bearer_token") + .and_then(|v| v.as_str()), + Some("sk-test") + ); + assert!( + parsed.get("model_providers").is_none(), + "reserved provider tables should not be synthesized" + ); + } + + #[test] + fn extract_bearer_uses_top_level_token_for_reserved_provider() { + let input = r#"model_provider = "openai" +experimental_bearer_token = "top-level-key" + +[model_providers.openai] +experimental_bearer_token = "stale-table-key" +"#; + + assert_eq!( + extract_codex_experimental_bearer_token(input).as_deref(), + Some("top-level-key") + ); + } + + #[test] + fn should_not_restore_provider_token_for_oauth_only_template() { + let oauth_template = json!({ + "auth": { + "auth_mode": "chatgpt", + "tokens": { + "access_token": "oauth-access" + } + } + }); + let api_key_template = json!({ + "auth": { + "OPENAI_API_KEY": "sk-test" + } + }); + + assert!( + !should_restore_codex_provider_token_for_backfill(Some("custom"), &oauth_template), + "OAuth-only templates should not backfill bearer tokens into OPENAI_API_KEY" + ); + assert!( + should_restore_codex_provider_token_for_backfill(Some("custom"), &api_key_template), + "custom API-key providers should still restore provider bearer tokens" + ); + assert!( + !should_restore_codex_provider_token_for_backfill(Some("official"), &api_key_template), + "official providers should never restore third-party bearer tokens" + ); + } + + #[test] + fn prepare_provider_live_config_does_not_create_incomplete_provider_table() { + let input = r#"model_provider = "vendor_x" +model = "gpt-5" +"#; + + let output = + prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), input) + .expect("prepare live config"); + let parsed: toml::Value = toml::from_str(&output).expect("parse output"); + + assert_eq!( + parsed + .get("experimental_bearer_token") + .and_then(|v| v.as_str()), + Some("sk-test") + ); + assert!( + parsed.get("model_providers").is_none(), + "missing provider tables should not be synthesized without endpoint fields" + ); + } + #[test] fn normalize_live_config_rewrites_matching_profile_model_provider_refs() { let target = r#"model_provider = "vendor_alpha" diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 05f6875ea..dea8befde 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -82,7 +82,8 @@ pub async fn get_config_status( } AppType::Codex => { let auth_path = codex_config::get_codex_auth_path(); - let exists = auth_path.exists(); + let config_text = codex_config::read_codex_config_text().unwrap_or_default(); + let exists = auth_path.exists() || !config_text.trim().is_empty(); let path = codex_config::get_codex_config_dir() .to_string_lossy() .to_string(); diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index d7ba9aed5..abd646ee8 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -616,12 +616,11 @@ fn merge_codex_config( request: &mut DeepLinkImportRequest, config: &serde_json::Value, ) -> Result<(), AppError> { - // Auto-fill API key from auth.OPENAI_API_KEY + // Auto-fill API key from auth.OPENAI_API_KEY or Codex mobile-compatible bearer token. if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { - if let Some(api_key) = config - .get("auth") - .and_then(|v| v.get("OPENAI_API_KEY")) - .and_then(|v| v.as_str()) + let config_str = config.get("config").and_then(|v| v.as_str()); + if let Some(api_key) = + crate::codex_config::extract_codex_api_key(config.get("auth"), config_str) { request.api_key = Some(api_key.to_string()); } diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index a56187f84..5b128be26 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -267,6 +267,47 @@ fn test_parse_and_merge_config_claude() { assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string())); } +#[test] +fn test_parse_and_merge_config_codex_uses_bearer_token() { + let config_toml = r#"model_provider = "rightcode" +model = "gpt-5-codex" + +[model_providers.rightcode] +base_url = "https://rightcode.example/v1" +wire_api = "responses" +experimental_bearer_token = "sk-rightcode" +"#; + let config_json = serde_json::json!({ + "auth": {}, + "config": config_toml, + }) + .to_string(); + let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes()); + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("codex".to_string()), + name: Some("RightCode".to_string()), + config: Some(config_b64), + config_format: Some("json".to_string()), + ..Default::default() + }; + + let merged = parse_and_merge_config(&request).unwrap(); + + assert_eq!(merged.api_key, Some("sk-rightcode".to_string())); + assert_eq!( + merged.endpoint, + Some("https://rightcode.example/v1".to_string()) + ); + assert_eq!( + merged.homepage, + Some("https://rightcode.example".to_string()) + ); + assert_eq!(merged.model, Some("gpt-5-codex".to_string())); +} + #[test] fn test_parse_and_merge_config_url_override() { let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#; diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index f8c175595..827d7db8a 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -427,14 +427,19 @@ impl CodexAdapter { fn extract_key(&self, provider: &Provider) -> Option { // 1. 尝试从 env 中获取 if let Some(env) = provider.settings_config.get("env") { - if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { + if let Some(key) = env + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|key| !key.is_empty()) + { return Some(key.to_string()); } } // 2. 尝试从 auth 中获取 (Codex CLI 格式) if let Some(auth) = provider.settings_config.get("auth") { - if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { + if let Some(key) = crate::codex_config::extract_codex_auth_api_key(auth) { return Some(key.to_string()); } } @@ -445,6 +450,8 @@ impl CodexAdapter { .get("apiKey") .or_else(|| provider.settings_config.get("api_key")) .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|key| !key.is_empty()) { return Some(key.to_string()); } @@ -455,9 +462,19 @@ impl CodexAdapter { .get("api_key") .or_else(|| config.get("apiKey")) .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|key| !key.is_empty()) { return Some(key.to_string()); } + + if let Some(config_str) = config.as_str() { + if let Some(key) = + crate::codex_config::extract_codex_experimental_bearer_token(config_str) + { + return Some(key); + } + } } None @@ -619,6 +636,25 @@ mod tests { assert_eq!(auth.strategy, AuthStrategy::Bearer); } + #[test] + fn test_extract_auth_falls_back_to_config_bearer_when_auth_key_empty() { + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "auth": { + "OPENAI_API_KEY": "" + }, + "config": r#"model_provider = "custom" + +[model_providers.custom] +experimental_bearer_token = "sk-config-key" +"# + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-config-key"); + assert_eq!(auth.strategy, AuthStrategy::Bearer); + } + #[test] fn test_extract_auth_from_env() { let adapter = CodexAdapter::new(); diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 6dbbdb7f7..62c1f1ae3 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -159,8 +159,9 @@ impl ConfigService { } let cfg_text = settings.get("config").and_then(Value::as_str); - crate::codex_config::write_codex_live_with_catalog( + crate::codex_config::write_codex_provider_live_with_catalog( &provider.settings_config, + provider.category.as_deref(), auth, cfg_text, )?; @@ -172,10 +173,30 @@ impl ConfigService { if let Some(manager) = config.get_manager_mut(&AppType::Codex) { if let Some(target) = manager.providers.get_mut(provider_id) { if let Some(obj) = target.settings_config.as_object_mut() { - obj.insert( - "config".to_string(), - serde_json::Value::String(cfg_text_after), - ); + let mut restored = serde_json::json!({ + "auth": auth.clone(), + "config": cfg_text_after, + }); + let restore_provider_token = + crate::codex_config::should_restore_codex_provider_token_for_backfill( + provider.category.as_deref(), + &provider.settings_config, + ); + crate::codex_config::restore_codex_settings_for_backfill( + &mut restored, + &provider.settings_config, + restore_provider_token, + )?; + // 必须同时写回 auth 和 config: backfill 会恢复 stored provider id, + // 并把 live 的 experimental_bearer_token 移到 restored.auth.OPENAI_API_KEY。 + if let Some(restored_obj) = restored.as_object() { + if let Some(auth_value) = restored_obj.get("auth") { + obj.insert("auth".to_string(), auth_value.clone()); + } + if let Some(config_value) = restored_obj.get("config") { + obj.insert("config".to_string(), config_value.clone()); + } + } } } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index d1311bbf5..b4f70bd8e 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -580,12 +580,18 @@ fn restore_live_settings_for_provider_backfill( } let mut settings = live_settings; - if let Err(err) = crate::codex_config::restore_codex_settings_config_model_provider_for_backfill( + let restore_provider_token = + crate::codex_config::should_restore_codex_provider_token_for_backfill( + provider.category.as_deref(), + &provider.settings_config, + ); + if let Err(err) = crate::codex_config::restore_codex_settings_for_backfill( &mut settings, &provider.settings_config, + restore_provider_token, ) { log::warn!( - "Failed to restore Codex provider id while backfilling '{}': {err}", + "Failed to restore Codex settings while backfilling '{}': {err}", provider.id ); } @@ -728,8 +734,9 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; let config_str = obj.get("config").and_then(|v| v.as_str()); - crate::codex_config::write_codex_live_with_catalog( + crate::codex_config::write_codex_provider_live_with_catalog( &provider.settings_config, + provider.category.as_deref(), auth, config_str, )?; @@ -951,17 +958,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { pub fn read_live_settings(app_type: AppType) -> Result { match app_type { AppType::Codex => { - let auth_path = get_codex_auth_path(); - if !auth_path.exists() { - return Err(AppError::localized( - "codex.auth.missing", - "Codex 配置文件不存在:缺少 auth.json", - "Codex configuration missing: auth.json not found", - )); - } - let auth: Value = read_json_file(&auth_path)?; - let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?; - let mut result = json!({ "auth": auth, "config": cfg_text }); + let mut result = crate::codex_config::read_codex_live_settings()?; // `modelCatalog` is a cc-switch private field that lives only in // the DB SSOT plus the `cc-switch-model-catalog.json` projection // file — it is never inlined into `auth.json` or `config.toml`. @@ -1091,19 +1088,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { - let auth_path = get_codex_auth_path(); - if !auth_path.exists() { - return Err(AppError::localized( - "codex.live.missing", - "Codex 配置文件不存在", - "Codex configuration file is missing", - )); - } - let auth: Value = read_json_file(&auth_path)?; - let config_str = crate::codex_config::read_and_validate_codex_config_text()?; - json!({ "auth": auth, "config": config_str }) - } + AppType::Codex => crate::codex_config::read_codex_live_settings()?, AppType::Claude => { let settings_path = get_claude_settings_path(); if !settings_path.exists() { @@ -1169,7 +1154,32 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { - let auth = provider + let _auth = provider .settings_config .get("auth") .and_then(|v| v.as_object()) @@ -2256,24 +2256,24 @@ impl ProviderService { ) })?; - let api_key = auth - .get("OPENAI_API_KEY") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AppError::localized( - "provider.codex.api_key.missing", - "缺少 API Key", - "API key is missing", - ) - })? - .to_string(); - let config_toml = provider .settings_config .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); + let api_key = crate::codex_config::extract_codex_api_key( + provider.settings_config.get("auth"), + Some(config_toml), + ) + .ok_or_else(|| { + AppError::localized( + "provider.codex.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })?; + let base_url = if config_toml.contains("base_url") { let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| { AppError::localized( diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index f6225f1b6..fc9fa6551 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -357,7 +357,7 @@ impl ProxyService { effective_settings["config"] = json!(updated_config); Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider)); - self.write_codex_live(&effective_settings)?; + self.write_codex_live_for_provider(&effective_settings, Some(provider))?; Ok(()) } @@ -1190,7 +1190,7 @@ impl ProxyService { codex_provider.as_ref(), ); - self.write_codex_live(&live_config)?; + self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } @@ -1257,7 +1257,7 @@ impl ProxyService { codex_provider.as_ref(), ); - self.write_codex_live(&live_config)?; + self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } AppType::Gemini => { @@ -1336,7 +1336,8 @@ impl ProxyService { codex_provider.as_ref(), ); - let _ = self.write_codex_live(&live_config); + let _ = + self.write_codex_live_for_provider(&live_config, codex_provider.as_ref()); } } AppType::Gemini => { @@ -2045,43 +2046,55 @@ impl ProxyService { } fn read_codex_live(&self) -> Result { - use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; - - let auth_path = get_codex_auth_path(); - if !auth_path.exists() { - return Err("Codex auth.json 不存在".to_string()); - } - - let auth: Value = - read_json_file(&auth_path).map_err(|e| format!("读取 Codex auth 失败: {e}"))?; - - let config_path = get_codex_config_path(); - let config_str = if config_path.exists() { - std::fs::read_to_string(&config_path) - .map_err(|e| format!("读取 Codex config 失败: {e}"))? - } else { - String::new() - }; - - Ok(json!({ - "auth": auth, - "config": config_str - })) + crate::codex_config::read_codex_live_settings() + .map_err(|e| format!("读取 Codex Live 配置失败: {e}")) } fn write_codex_live(&self, config: &Value) -> Result<(), String> { + self.write_codex_live_verbatim(config) + } + + fn write_codex_live_for_provider( + &self, + config: &Value, + provider: Option<&Provider>, + ) -> Result<(), String> { + let Some(provider) = provider else { + return self.write_codex_live_verbatim(config); + }; + + let auth = config + .get("auth") + .ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?; + let config_str = config.get("config").and_then(|v| v.as_str()); + + crate::codex_config::write_codex_provider_live_with_catalog( + config, + provider.category.as_deref(), + auth, + config_str, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}")) + } + + fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> { use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; let auth = config.get("auth"); let config_str = config.get("config").and_then(|v| v.as_str()); - // Proxy restore writes saved live backups verbatim. Provider-driven writes go - // through write_live_with_common_config(), which normalizes Codex provider ids. match (auth, config_str) { (Some(auth), Some(cfg)) => { - // Use unified helper to prepare catalog if present - crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg)) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + let auth_path = get_codex_auth_path(); + if auth.as_object().is_some_and(|obj| obj.is_empty()) { + let _ = crate::config::delete_file(&auth_path); + let config_path = get_codex_config_path(); + crate::config::write_text_file(&config_path, cfg) + .map_err(|e| format!("写入 Codex config 失败: {e}"))?; + } else { + crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg)) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + } } (Some(auth), None) => { let auth_path = get_codex_auth_path(); @@ -2547,6 +2560,88 @@ mod tests { ); } + #[test] + #[serial] + fn codex_custom_provider_live_write_preserves_oauth_auth_json() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db); + let oauth_auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "oauth-id", + "access_token": "oauth-access" + } + }); + crate::codex_config::write_codex_live_atomic( + &oauth_auth, + Some( + r#"model_provider = "openai" +model = "gpt-5-codex" +"#, + ), + ) + .expect("seed live OAuth auth"); + + let mut provider = Provider::with_id( + "rightcode".to_string(), + "RightCode".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "rightcode-key" + }, + "config": r#"model_provider = "rightcode" +model = "gpt-5-codex" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +"# + }), + None, + ); + provider.category = Some("custom".to_string()); + let takeover_settings = json!({ + "auth": { + "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER + }, + "config": r#"model_provider = "rightcode" +model = "gpt-5-codex" + +[model_providers.rightcode] +name = "RightCode" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +"# + }); + + service + .write_codex_live_for_provider(&takeover_settings, Some(&provider)) + .expect("write provider-driven Codex live config"); + + let live_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read live auth"); + assert_eq!( + live_auth, oauth_auth, + "third-party Codex proxy writes must not overwrite ChatGPT OAuth login state" + ); + + let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read live config"); + assert!( + live_config.contains("experimental_bearer_token"), + "proxy placeholder should move into config.toml instead of auth.json" + ); + assert!( + live_config.contains(PROXY_TOKEN_PLACEHOLDER), + "live config should carry the proxy placeholder token" + ); + } + #[test] fn update_toml_base_url_updates_active_model_provider_base_url() { let input = r#" diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 685831612..7018d52aa 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -70,14 +70,14 @@ fn sync_claude_provider_writes_live_settings() { } #[test] -fn sync_codex_provider_writes_auth_and_config() { +fn sync_codex_provider_writes_config_without_touching_auth() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let mut config = MultiAppConfig::default(); // 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发 - // 此测试仅验证 auth.json 和 config.toml 基础配置的写入 + // Codex provider 切换只写 config.toml;auth.json 保留用户登录态。 let provider_config = json!({ "auth": { @@ -105,8 +105,8 @@ fn sync_codex_provider_writes_auth_and_config() { let config_path = cc_switch_lib::get_codex_config_path(); assert!( - auth_path.exists(), - "auth.json should exist at {}", + !auth_path.exists(), + "auth.json should not be created by provider switching at {}", auth_path.display() ); assert!( @@ -115,20 +115,16 @@ fn sync_codex_provider_writes_auth_and_config() { config_path.display() ); - let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth"); - assert_eq!( - auth_value, - provider_config.get("auth").cloned().expect("auth object") - ); - let toml_text = fs::read_to_string(&config_path).expect("read config.toml"); - // 验证基础配置正确写入 assert!( toml_text.contains("base_url"), "config.toml should contain base_url from provider config" ); + assert!( + toml_text.contains("experimental_bearer_token"), + "config.toml should contain provider-scoped bearer token" + ); - // 当前供应商应同步最新 config 文本 let manager = config.get_manager(&AppType::Codex).expect("codex manager"); let synced = manager.providers.get("codex-1").expect("codex provider"); let synced_cfg = synced @@ -136,7 +132,81 @@ fn sync_codex_provider_writes_auth_and_config() { .get("config") .and_then(|v| v.as_str()) .expect("config string"); - assert_eq!(synced_cfg, toml_text); + assert!( + !synced_cfg.contains("experimental_bearer_token"), + "provider storage should not persist generated live bearer token" + ); + assert!( + toml_text.contains("experimental_bearer_token"), + "live config should include generated bearer token" + ); +} + +#[test] +fn sync_codex_provider_with_config_only_token_backfills_auth() { + // P2-2 回归: stored provider 的 token 只藏在 config.toml 的 experimental_bearer_token 时, + // sync 路径必须把 token 从 live config 提取并写回 stored auth.OPENAI_API_KEY, + // 否则下一轮 sync 会在 cleaned config + 空 auth 之间丢失 token。 + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + + let stored_config = r#"model_provider = "thirdparty" +model = "gpt-5.4" + +[model_providers.thirdparty] +name = "Thirdparty" +base_url = "https://thirdparty.example/v1" +wire_api = "responses" +requires_openai_auth = true +experimental_bearer_token = "stored-bearer-key" +"#; + + let provider = Provider::with_id( + "thirdparty-1".to_string(), + "Thirdparty".to_string(), + json!({ + "auth": {}, + "config": stored_config, + }), + None, + ); + + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager + .providers + .insert("thirdparty-1".to_string(), provider); + manager.current = "thirdparty-1".to_string(); + + ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live"); + + let manager = config.get_manager(&AppType::Codex).expect("codex manager"); + let synced = manager + .providers + .get("thirdparty-1") + .expect("provider survives sync"); + + assert_eq!( + synced + .settings_config + .pointer("/auth/OPENAI_API_KEY") + .and_then(|v| v.as_str()), + Some("stored-bearer-key"), + "config-only bearer token must be backfilled into stored auth.OPENAI_API_KEY" + ); + + let synced_cfg = synced + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + assert!( + !synced_cfg.contains("experimental_bearer_token"), + "live-only bearer token should not be persisted in stored provider config" + ); } #[test] @@ -221,8 +291,8 @@ requires_openai_auth = true .and_then(|v| v.as_str()) .expect("synced config string"); assert!( - synced_cfg.contains("[model_providers.custom]"), - "ConfigService keeps its existing behavior of syncing provider config from live" + synced_cfg.contains("[model_providers.aihubmix]"), + "ConfigService should restore the provider-specific id before writing stored config" ); } diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 7c407dc73..8fa723067 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -93,6 +93,98 @@ fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() { ); } +#[test] +fn codex_startup_import_accepts_config_without_auth_file() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let config_path = get_codex_config_path(); + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).expect("create codex config dir"); + } + std::fs::write( + &config_path, + r#"model_provider = "aihubmix" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +experimental_bearer_token = "live-key" +"#, + ) + .expect("seed config.toml without auth.json"); + assert!( + !get_codex_auth_path().exists(), + "test should not seed auth.json" + ); + + let state = create_test_state().expect("create test state"); + import_default_config_test_hook(&state, AppType::Codex) + .expect("import codex config-only default"); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("get codex providers after import"); + let provider = providers.get("default").expect("default provider exists"); + assert_eq!( + provider.settings_config.pointer("/auth"), + Some(&json!({})), + "missing auth.json should import as an empty auth object" + ); + assert!( + provider + .settings_config + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .contains("experimental_bearer_token"), + "config.toml content should still be imported" + ); +} + +#[test] +fn codex_startup_import_marks_oauth_only_default_official() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "oauth-id", + "access_token": "oauth-access" + } + }); + let config = r#"[mcp_servers.echo] +command = "echo" +"#; + write_codex_live_atomic(&auth, Some(config)).expect("seed oauth-only codex live config"); + + let state = create_test_state().expect("create test state"); + import_default_config_test_hook(&state, AppType::Codex).expect("import codex default"); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("get codex providers after import"); + let provider = providers.get("default").expect("default provider exists"); + + assert_eq!( + provider.category.as_deref(), + Some("official"), + "OAuth-only live Codex installs should keep official behavior" + ); + assert_eq!( + provider.settings_config.pointer("/auth/tokens/id_token"), + Some(&json!("oauth-id")), + "import should preserve OAuth login material" + ); +} + #[test] fn codex_startup_import_skips_when_only_official_seed_exists() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -228,8 +320,8 @@ command = "say" .get("OPENAI_API_KEY") .and_then(|v| v.as_str()) .unwrap_or(""), - "fresh-key", - "live auth.json should reflect new provider" + "legacy-key", + "Codex provider switching should preserve the existing live auth.json" ); let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml"); @@ -237,6 +329,10 @@ command = "say" config_text.contains("mcp_servers.echo-server"), "config.toml should contain synced MCP servers" ); + assert!( + config_text.contains("experimental_bearer_token"), + "config.toml should carry the selected provider API key as bearer token" + ); let current_id = app_state .db diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 97f92eea1..f9187c659 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -181,8 +181,8 @@ command = "say" read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json"); assert_eq!( auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()), - Some("fresh-key"), - "live auth.json should reflect new provider" + Some("legacy-key"), + "Codex provider switching should preserve the existing live auth.json" ); let config_text = @@ -191,6 +191,10 @@ command = "say" config_text.contains("mcp_servers.echo-server"), "config.toml should contain synced MCP servers" ); + assert!( + config_text.contains("experimental_bearer_token"), + "config.toml should carry the selected provider API key" + ); let current_id = state .db @@ -347,6 +351,356 @@ requires_openai_auth = true ); } +#[test] +fn provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let live_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "oauth-token", + "account_id": "acct-1" + } + }); + let legacy_config = r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + write_codex_live_atomic(&live_auth, Some(legacy_config)) + .expect("seed existing Codex OAuth live config"); + + let bridge_provider = Provider::with_id( + "bridge-provider".to_string(), + "Bridge Provider".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "bridge-key"}, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "legacy-provider".to_string(); + manager.providers.insert( + "legacy-provider".to_string(), + Provider::with_id( + "legacy-provider".to_string(), + "RightCode".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "rightcode-key"}, + "config": legacy_config + }), + None, + ), + ); + manager + .providers + .insert("bridge-provider".to_string(), bridge_provider); + manager.providers.insert( + "plain-provider".to_string(), + Provider::with_id( + "plain-provider".to_string(), + "Plain Provider".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "plain-key"}, + "config": r#"model_provider = "plain" +model = "gpt-5.4" + +[model_providers.plain] +name = "Plain" +base_url = "https://plain.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "bridge-provider") + .expect("switch to bridge provider should succeed"); + + let auth_value: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json"); + assert_eq!( + auth_value.get("auth_mode").and_then(|v| v.as_str()), + Some("chatgpt") + ); + assert!( + auth_value + .get("OPENAI_API_KEY") + .is_some_and(|v| v.is_null()), + "provider switching should keep OPENAI_API_KEY null in live auth.json" + ); + assert_eq!( + auth_value + .pointer("/tokens/access_token") + .and_then(|v| v.as_str()), + Some("oauth-token"), + "existing ChatGPT OAuth token should be preserved" + ); + + let live_config = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + let parsed_live: toml::Value = toml::from_str(&live_config).expect("parse live config"); + assert_eq!( + parsed_live + .get("model_providers") + .and_then(|v| v.get("custom")) + .and_then(|v| v.get("experimental_bearer_token")) + .and_then(|v| v.as_str()), + Some("bridge-key"), + "third-party key should be injected into the stable live provider table" + ); + assert_eq!( + parsed_live + .get("model_providers") + .and_then(|v| v.get("custom")) + .and_then(|v| v.get("requires_openai_auth")) + .and_then(|v| v.as_bool()), + Some(true) + ); + + ProviderService::switch(&state, AppType::Codex, "plain-provider") + .expect("switch away should backfill bridge provider"); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("read providers"); + let stored_bridge = providers + .get("bridge-provider") + .expect("bridge provider exists after backfill"); + assert_eq!( + stored_bridge + .settings_config + .pointer("/auth/OPENAI_API_KEY") + .and_then(|v| v.as_str()), + Some("bridge-key"), + "backfill should restore the API key into stored provider auth" + ); + assert!( + stored_bridge + .settings_config + .pointer("/auth/tokens") + .is_none(), + "backfill should not persist ChatGPT OAuth tokens into provider storage" + ); + assert!( + !stored_bridge + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .contains("experimental_bearer_token"), + "stored provider config should stay clean; bridge token is generated only for live config" + ); +} + +#[test] +fn provider_service_switch_codex_supports_official_login_provider_without_auth_write() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let live_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "official-oauth-token", + "account_id": "acct-official" + } + }); + write_codex_live_atomic(&live_auth, Some("")).expect("seed official OAuth live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "legacy-provider".to_string(); + manager.providers.insert( + "legacy-provider".to_string(), + Provider::with_id( + "legacy-provider".to_string(), + "Legacy".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "legacy-key"}, + "config": r#"model_provider = "legacy" + +[model_providers.legacy] +name = "Legacy" +base_url = "https://legacy.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ), + ); + manager.providers.insert( + "official-provider".to_string(), + Provider::with_id( + "official-provider".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": {}, + "config": "" + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "official-provider") + .expect("switch to official provider should succeed without API key"); + + let auth_value: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json"); + assert_eq!( + auth_value.get("auth_mode").and_then(|v| v.as_str()), + Some("chatgpt") + ); + assert!( + auth_value + .get("OPENAI_API_KEY") + .is_some_and(|v| v.is_null()), + "official provider switching should keep OPENAI_API_KEY null" + ); + assert_eq!( + auth_value + .pointer("/tokens/access_token") + .and_then(|v| v.as_str()), + Some("official-oauth-token"), + "official provider should preserve the existing ChatGPT OAuth token" + ); + + let live_config = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + assert!( + !live_config.contains("experimental_bearer_token"), + "official login provider has no API key to inject" + ); +} + +#[test] +fn provider_service_switch_codex_official_accounts_write_auth_json() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let live_auth_a = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "official-a-live-token", + "account_id": "acct-a" + } + }); + write_codex_live_atomic(&live_auth_a, Some("")).expect("seed official account A live auth"); + + let mut official_a = Provider::with_id( + "official-a".to_string(), + "Official A".to_string(), + json!({ + "auth": { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "stale-a-token", + "account_id": "acct-a" + } + }, + "config": "" + }), + None, + ); + official_a.category = Some("official".to_string()); + + let mut official_b = Provider::with_id( + "official-b".to_string(), + "Official B".to_string(), + json!({ + "auth": { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "official-b-token", + "account_id": "acct-b" + } + }, + "config": "" + }), + None, + ); + official_b.category = Some("official".to_string()); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "official-a".to_string(); + manager + .providers + .insert("official-a".to_string(), official_a); + manager + .providers + .insert("official-b".to_string(), official_b); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "official-b") + .expect("switch to official account B should write auth.json"); + let auth_b: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth B"); + assert_eq!( + auth_b + .pointer("/tokens/access_token") + .and_then(|v| v.as_str()), + Some("official-b-token"), + "switching official accounts must replace auth.json with the selected account" + ); + + ProviderService::switch(&state, AppType::Codex, "official-a") + .expect("switch back to official account A should use backfilled live auth"); + let auth_a: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth A"); + assert_eq!( + auth_a + .pointer("/tokens/access_token") + .and_then(|v| v.as_str()), + Some("official-a-live-token"), + "backfill should preserve account A's latest live token for later official switches" + ); +} + #[test] fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index e3083d716..6c1124012 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -8,7 +8,10 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api"; import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot"; import { useSettingsQuery } from "@/lib/query"; import { resolveManagedAccountId } from "@/lib/authBinding"; -import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; +import { + extractCodexBaseUrl, + extractCodexExperimentalBearerToken, +} from "@/utils/providerConfigUtils"; import JsonEditor from "./JsonEditor"; import * as prettier from "prettier/standalone"; import * as parserBabel from "prettier/parser-babel"; @@ -173,8 +176,13 @@ const UsageScriptModal: React.FC = ({ // Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url } const auth = (config as any).auth || {}; const configToml = (config as any).config || ""; + const apiKey = + typeof auth.OPENAI_API_KEY === "string" && + auth.OPENAI_API_KEY.trim() + ? auth.OPENAI_API_KEY + : extractCodexExperimentalBearerToken(configToml); return { - apiKey: auth.OPENAI_API_KEY, + apiKey, baseUrl: extractCodexBaseUrl(configToml), }; } else if (appId === "gemini") { diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 9d131deb7..a61cead88 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -20,6 +20,7 @@ import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge" import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { extractCodexBaseUrl, + extractCodexExperimentalBearerToken, extractCodexWireApi, isCodexChatWireApi, } from "@/utils/providerConfigUtils"; @@ -78,7 +79,14 @@ function isOfficialProvider(provider: Provider, appId: AppId): boolean { if (appId === "codex") { // 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方) const apiKey = config?.auth?.OPENAI_API_KEY; - return !apiKey || (typeof apiKey === "string" && apiKey.trim() === ""); + const bearerToken = + typeof config?.config === "string" + ? extractCodexExperimentalBearerToken(config.config) + : undefined; + return ( + !bearerToken && + (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) + ); } if (appId === "gemini") { // 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式 diff --git a/src/components/providers/forms/hooks/useCodexConfigState.ts b/src/components/providers/forms/hooks/useCodexConfigState.ts index 79fcab22c..0b0962117 100644 --- a/src/components/providers/forms/hooks/useCodexConfigState.ts +++ b/src/components/providers/forms/hooks/useCodexConfigState.ts @@ -1,7 +1,9 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { extractCodexBaseUrl, + extractCodexExperimentalBearerToken, setCodexBaseUrl as setCodexBaseUrlInConfig, + updateCodexExperimentalBearerToken, } from "@/utils/providerConfigUtils"; import { normalizeTomlText } from "@/utils/textNormalization"; import type { CodexCatalogModel } from "@/types"; @@ -12,6 +14,19 @@ interface UseCodexConfigStateProps { }; } +// auth.json 缺 OPENAI_API_KEY 时回退到 config.toml 的 experimental_bearer_token +// (Mobile 兼容形态:保留 ChatGPT 登录态但用第三方 token) +function pickCodexApiKey( + authObj: { OPENAI_API_KEY?: unknown } | null | undefined, + configText: string, +): string { + if (authObj && typeof authObj.OPENAI_API_KEY === "string") { + const key = authObj.OPENAI_API_KEY; + if (key) return key; + } + return extractCodexExperimentalBearerToken(configText) || ""; +} + /** * 管理 Codex 配置状态 * Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串) @@ -77,14 +92,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { setCodexBaseUrl(initialBaseUrl); } - // 提取 API Key - try { - if (auth && typeof auth.OPENAI_API_KEY === "string") { - setCodexApiKey(auth.OPENAI_API_KEY); - } - } catch { - // ignore - } + setCodexApiKey(pickCodexApiKey(auth, configStr)); } }, [initialData]); @@ -109,11 +117,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { // 从 codexAuth 中提取并同步 API Key useEffect(() => { - const extractedKey = getCodexAuthApiKey(codexAuth); - if (extractedKey !== codexApiKey) { - setCodexApiKey(extractedKey); + let parsed: { OPENAI_API_KEY?: unknown } | null = null; + try { + parsed = JSON.parse(codexAuth || "{}"); + } catch { + parsed = null; } - }, [codexAuth, codexApiKey]); + const extractedKey = pickCodexApiKey(parsed, codexConfig); + setCodexApiKey((prev) => (prev === extractedKey ? prev : extractedKey)); + }, [codexAuth, codexConfig]); // 验证 Codex Auth JSON const validateCodexAuth = useCallback((value: string): string => { @@ -151,6 +163,8 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { ); // 处理 Codex API Key 输入并写回 auth.json + // 同步: 若 config.toml 当前含 experimental_bearer_token (Mobile 兼容形态), + // 也一并更新/清除——否则用户清空输入框会被 pickCodexApiKey 的 fallback 又填回去 const handleCodexApiKeyChange = useCallback( (key: string) => { const trimmed = key.trim(); @@ -162,8 +176,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { } catch { // ignore } + setCodexConfig((prev) => + updateCodexExperimentalBearerToken(prev, trimmed), + ); }, - [codexAuth, setCodexAuth], + [codexAuth, setCodexAuth, setCodexConfig], ); // 处理 Codex Base URL 变化 @@ -213,16 +230,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { const baseUrl = extractCodexBaseUrl(config); setCodexBaseUrl(baseUrl || ""); - // 提取 API Key - try { - if (auth && typeof auth.OPENAI_API_KEY === "string") { - setCodexApiKey(auth.OPENAI_API_KEY); - } else { - setCodexApiKey(""); - } - } catch { - setCodexApiKey(""); - } + setCodexApiKey(pickCodexApiKey(auth, config)); }, [setCodexAuth, setCodexConfig, setCodexCatalogModels], ); diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 9bd32c672..b3df73aeb 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -417,13 +417,23 @@ export const hasTomlCommonConfigSnippet = ( const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/; const TOML_BASE_URL_PATTERN = /^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; +const TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN = + /^\s*experimental_bearer_token\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; +const TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN = + /^(\s*experimental_bearer_token\s*=\s*)(?:"(?:\\.|[^"\\\r\n])*"|'[^'\r\n]*')(\s*(?:#.*)?)$/; const TOML_MODEL_PATTERN = /^\s*model\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; const TOML_WIRE_API_PATTERN = /^\s*wire_api\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; const TOML_MODEL_PROVIDER_LINE_PATTERN = /^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; -const TOML_MODEL_PROVIDER_PATTERN = - /^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/m; +const CODEX_RESERVED_MODEL_PROVIDER_IDS = new Set([ + "amazon-bedrock", + "openai", + "ollama", + "lmstudio", + "oss", + "ollama-chat", +]); interface TomlSectionRange { bodyEndIndex: number; @@ -499,7 +509,22 @@ const getTomlSectionInsertIndex = ( }; const getCodexModelProviderName = (configText: string): string | undefined => { - const match = configText.match(TOML_MODEL_PROVIDER_PATTERN); + const normalized = normalizeTomlText(configText); + try { + const parsed = parseToml(normalized) as Record; + const providerName = + typeof parsed.model_provider === "string" + ? parsed.model_provider.trim() + : undefined; + if (providerName) return providerName; + } catch { + // Fall back to a top-level line scan while the user is editing invalid TOML. + } + + const lines = normalized.split("\n"); + const index = getTopLevelModelProviderLineIndex(lines); + if (index === -1) return undefined; + const match = lines[index].match(TOML_MODEL_PROVIDER_LINE_PATTERN); const providerName = match?.[2]?.trim(); return providerName || undefined; }; @@ -511,6 +536,20 @@ const getCodexProviderSectionName = ( return providerName ? `model_providers.${providerName}` : undefined; }; +const isCustomCodexModelProviderId = (providerName: string): boolean => { + const id = providerName.trim().toLowerCase(); + return Boolean(id) && !CODEX_RESERVED_MODEL_PROVIDER_IDS.has(id); +}; + +const getCodexCustomProviderSectionName = ( + configText: string, +): string | undefined => { + const providerName = getCodexModelProviderName(configText); + return providerName && isCustomCodexModelProviderId(providerName) + ? `model_providers.${providerName}` + : undefined; +}; + const findTomlAssignmentInRange = ( lines: string[], pattern: RegExp, @@ -532,6 +571,21 @@ const findTomlAssignmentInRange = ( return undefined; }; +const findTomlLineInRange = ( + lines: string[], + pattern: RegExp, + startIndex: number, + endIndex: number, +): number => { + for (let index = startIndex; index < endIndex; index += 1) { + if (pattern.test(lines[index])) { + return index; + } + } + + return -1; +}; + const findTomlAssignments = ( lines: string[], pattern: RegExp, @@ -601,6 +655,23 @@ const getTopLevelModelProviderLineIndex = (lines: string[]): number => { return -1; }; +const TOML_BASIC_STRING_ESCAPES: Record = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +}; + +const escapeTomlBasicString = (value: string): string => + value.replace(/["\\\u0000-\u001f]/g, (ch) => { + const escaped = TOML_BASIC_STRING_ESCAPES[ch]; + if (escaped) return escaped; + return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`; + }); + const CODEX_CHAT_WIRE_API_VALUES = new Set([ "chat", "chat_completions", @@ -792,6 +863,134 @@ export const extractCodexBaseUrl = ( } }; +// 从 Codex 的 TOML 配置文本中提取 experimental_bearer_token(兼容 Mobile 模式) +export const extractCodexExperimentalBearerToken = ( + configText: string | undefined | null, +): string | undefined => { + try { + const raw = typeof configText === "string" ? configText : ""; + const text = normalizeTomlText(raw); + if (!text) return undefined; + + try { + const parsed = parseToml(text) as Record; + const providerName = + typeof parsed.model_provider === "string" + ? parsed.model_provider.trim() + : undefined; + const providerToken = + providerName && + isCustomCodexModelProviderId(providerName) && + parsed.model_providers && + typeof parsed.model_providers === "object" && + typeof parsed.model_providers[providerName] + ?.experimental_bearer_token === "string" + ? parsed.model_providers[ + providerName + ].experimental_bearer_token.trim() + : undefined; + if (providerToken) return providerToken; + const topLevelToken = + typeof parsed.experimental_bearer_token === "string" + ? parsed.experimental_bearer_token.trim() + : undefined; + if (topLevelToken) return topLevelToken; + } catch { + // Fall back to the line scanner for partially edited TOML. + } + + const lines = text.split("\n"); + const targetSectionName = getCodexCustomProviderSectionName(text); + + if (targetSectionName) { + const sectionRange = getTomlSectionRange(lines, targetSectionName); + if (sectionRange) { + const match = findTomlAssignmentInRange( + lines, + TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN, + sectionRange.bodyStartIndex, + sectionRange.bodyEndIndex, + targetSectionName, + ); + if (match?.value) { + return match.value; + } + } + } + + const topLevelMatch = findTomlAssignmentInRange( + lines, + TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN, + 0, + getTopLevelEndIndex(lines), + ); + return topLevelMatch?.value; + } catch { + return undefined; + } +}; + +// 同步更新 Codex config.toml 中已有的 experimental_bearer_token +// 仅修改已存在的条目, 不主动新增——避免破坏未使用 Mobile 兼容模式的普通 third-party 配置 +// token 为空时删除该行 (让用户能真正清空 API key, 而不是被 pickCodexApiKey 的 fallback 又填回去) +export const updateCodexExperimentalBearerToken = ( + configText: string, + token: string, +): string => { + const normalizedText = normalizeTomlText(configText); + if ( + !normalizedText || + !normalizedText.includes("experimental_bearer_token") + ) { + return configText; + } + + const lines = normalizedText.split("\n"); + const targetSectionName = getCodexCustomProviderSectionName(normalizedText); + + let tokenLineIndex = -1; + if (targetSectionName) { + const sectionRange = getTomlSectionRange(lines, targetSectionName); + if (sectionRange) { + const index = findTomlLineInRange( + lines, + TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN, + sectionRange.bodyStartIndex, + sectionRange.bodyEndIndex, + ); + if (index !== -1) tokenLineIndex = index; + } + } + if (tokenLineIndex === -1) { + const topLevelIndex = findTomlLineInRange( + lines, + TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN, + 0, + getTopLevelEndIndex(lines), + ); + if (topLevelIndex !== -1) tokenLineIndex = topLevelIndex; + } + + if (tokenLineIndex === -1) return configText; + + const trimmed = token.trim(); + if (!trimmed) { + lines.splice(tokenLineIndex, 1); + } else { + const escaped = escapeTomlBasicString(trimmed); + const existingLine = lines[tokenLineIndex]; + lines[tokenLineIndex] = TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN.test( + existingLine, + ) + ? existingLine.replace( + TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN, + `$1"${escaped}"$2`, + ) + : `experimental_bearer_token = "${escaped}"`; + } + return finalizeTomlText(lines); +}; + // 从 Provider 对象中提取 Codex base_url(当 settingsConfig.config 为 TOML 字符串时) export const getCodexBaseUrl = ( provider: { settingsConfig?: Record } | undefined | null, diff --git a/tests/utils/providerConfigUtils.codex.test.ts b/tests/utils/providerConfigUtils.codex.test.ts index 372bb3360..be5915290 100644 --- a/tests/utils/providerConfigUtils.codex.test.ts +++ b/tests/utils/providerConfigUtils.codex.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import { extractCodexBaseUrl, + extractCodexExperimentalBearerToken, extractCodexModelName, extractCodexTopLevelInt, removeCodexTopLevelField, setCodexBaseUrl, setCodexModelName, setCodexTopLevelInt, + updateCodexExperimentalBearerToken, } from "@/utils/providerConfigUtils"; describe("Codex TOML utils", () => { @@ -177,15 +179,10 @@ describe("Codex TOML utils", () => { 128000, ); expect( - extractCodexTopLevelInt( - withCompact, - "model_auto_compact_token_limit", - ), + extractCodexTopLevelInt(withCompact, "model_auto_compact_token_limit"), ).toBe(90000); expect(withCompact).toMatch(/^model_context_window = 128000$/m); - expect(withCompact).toMatch( - /^model_auto_compact_token_limit = 90000$/m, - ); + expect(withCompact).toMatch(/^model_auto_compact_token_limit = 90000$/m); const removed = removeCodexTopLevelField( withCompact, @@ -197,4 +194,138 @@ describe("Codex TOML utils", () => { ).toBeUndefined(); expect(removed).toContain("[model_providers.custom]"); }); + + // P3 回归: 不能在 config 没用 bearer token 模式时, 误为它新增一行 + it("updateCodexExperimentalBearerToken leaves config without the token alone", () => { + const input = [ + 'model_provider = "openai"', + 'base_url = "https://api.example.com/v1"', + "", + ].join("\n"); + + expect(updateCodexExperimentalBearerToken(input, "new-key")).toBe(input); + expect(updateCodexExperimentalBearerToken(input, "")).toBe(input); + }); + + // P3 回归: bearer 模式下清空 API key 必须真正擦掉 token, 让 pickCodexApiKey 的 fallback 找不到 + it("updateCodexExperimentalBearerToken removes the token line when set to empty", () => { + const input = [ + 'model_provider = "thirdparty"', + "", + "[model_providers.thirdparty]", + 'name = "Thirdparty"', + 'base_url = "https://thirdparty.example/v1"', + 'experimental_bearer_token = "old-key"', + "requires_openai_auth = true", + "", + ].join("\n"); + + const cleared = updateCodexExperimentalBearerToken(input, ""); + + expect(extractCodexExperimentalBearerToken(cleared)).toBeUndefined(); + expect(cleared).toMatch(/requires_openai_auth = true/); + expect(cleared).toMatch(/base_url = "https:\/\/thirdparty\.example\/v1"/); + }); + + it("updateCodexExperimentalBearerToken replaces the token inside the active model_providers section", () => { + const input = [ + 'model_provider = "thirdparty"', + "", + "[model_providers.thirdparty]", + 'experimental_bearer_token = "old-key"', + "", + ].join("\n"); + + const updated = updateCodexExperimentalBearerToken(input, "new-key"); + + expect(extractCodexExperimentalBearerToken(updated)).toBe("new-key"); + expect(updated).not.toMatch(/old-key/); + }); + + it("updateCodexExperimentalBearerToken escapes basic TOML strings and keeps comments", () => { + const input = [ + 'model_provider = "thirdparty"', + "", + "[model_providers.thirdparty]", + 'experimental_bearer_token = "old-key" # vendor token', + "", + ].join("\n"); + + const updated = updateCodexExperimentalBearerToken( + input, + 'abc"def\\ghi', + ); + + expect(updated).toContain( + 'experimental_bearer_token = "abc\\"def\\\\ghi" # vendor token', + ); + expect(extractCodexExperimentalBearerToken(updated)).toBe('abc"def\\ghi'); + }); + + it("updateCodexExperimentalBearerToken escapes all TOML control characters", () => { + const input = [ + 'model_provider = "thirdparty"', + "", + "[model_providers.thirdparty]", + 'experimental_bearer_token = "old-key"', + "", + ].join("\n"); + + const updated = updateCodexExperimentalBearerToken( + input, + "a\u0000b\u0001c\u001fd", + ); + + expect(updated).toContain( + 'experimental_bearer_token = "a\\u0000b\\u0001c\\u001fd"', + ); + expect(extractCodexExperimentalBearerToken(updated)).toBe( + "a\u0000b\u0001c\u001fd", + ); + }); + + it("updateCodexExperimentalBearerToken can replace an already escaped basic string", () => { + const input = [ + 'model_provider = "thirdparty"', + "", + "[model_providers.thirdparty]", + 'experimental_bearer_token = "old\\"key" # vendor token', + "", + ].join("\n"); + + const updated = updateCodexExperimentalBearerToken(input, "new-key"); + + expect(updated).toContain( + 'experimental_bearer_token = "new-key" # vendor token', + ); + expect(extractCodexExperimentalBearerToken(updated)).toBe("new-key"); + }); + + it("extractCodexExperimentalBearerToken ignores reserved provider tables", () => { + const input = [ + 'model_provider = "openai"', + 'experimental_bearer_token = "top-level-key"', + "", + "[model_providers.openai]", + 'experimental_bearer_token = "stale-table-key"', + "", + ].join("\n"); + + expect(extractCodexExperimentalBearerToken(input)).toBe("top-level-key"); + }); + + it("extractCodexExperimentalBearerToken reads only top-level model_provider", () => { + const input = [ + 'experimental_bearer_token = "top-level-key"', + "", + "[profiles.work]", + 'model_provider = "fake"', + "", + "[model_providers.fake]", + 'experimental_bearer_token = "wrong-key"', + "", + ].join("\n"); + + expect(extractCodexExperimentalBearerToken(input)).toBe("top-level-key"); + }); });