mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(codex): preserve OAuth login state during third-party provider switching
Codex provider switches now only write config.toml for third-party providers, injecting the API key as experimental_bearer_token. The user's auth.json (ChatGPT OAuth tokens) is preserved. Official providers with login material still write auth.json normally. Backfill restores bearer tokens into stored provider auth.OPENAI_API_KEY to maintain canonical shape.
This commit is contained in:
+447
-14
@@ -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<String, AppError> {
|
||||
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::<toml::Table>(&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<String> {
|
||||
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<String> {
|
||||
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<u64> {
|
||||
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.<id>].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<String> {
|
||||
if !config_text.contains("experimental_bearer_token") {
|
||||
return None;
|
||||
}
|
||||
let doc = config_text.parse::<DocumentMut>().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<String, AppError> {
|
||||
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::<DocumentMut>()
|
||||
.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<String, AppError> {
|
||||
if config_text.trim().is_empty() || !config_text.contains("experimental_bearer_token") {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.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<Value, AppError> {
|
||||
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<String, AppError> {
|
||||
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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"}}"#;
|
||||
|
||||
@@ -427,14 +427,19 @@ impl CodexAdapter {
|
||||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||||
// 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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value, AppError> {
|
||||
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<bool
|
||||
}
|
||||
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => {
|
||||
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<bool
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
provider.category = Some("custom".to_string());
|
||||
provider.category = Some(
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
let config_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str);
|
||||
let has_provider_key = crate::codex_config::extract_codex_api_key(
|
||||
provider.settings_config.get("auth"),
|
||||
config_text,
|
||||
)
|
||||
.is_some();
|
||||
let has_login_material = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.is_some_and(crate::codex_config::codex_auth_has_login_material);
|
||||
|
||||
if has_login_material && !has_provider_key {
|
||||
"official"
|
||||
} else {
|
||||
"custom"
|
||||
}
|
||||
} else {
|
||||
"custom"
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
state
|
||||
|
||||
@@ -2244,7 +2244,7 @@ impl ProviderService {
|
||||
Ok((credentials.api_key, credentials.base_url))
|
||||
}
|
||||
AppType::Codex => {
|
||||
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(
|
||||
|
||||
+126
-31
@@ -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<Value, String> {
|
||||
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#"
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<UsageScriptModalProps> = ({
|
||||
// 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") {
|
||||
|
||||
@@ -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 官方模式
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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<string, any>;
|
||||
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<string, string> = {
|
||||
'"': '\\"',
|
||||
"\\": "\\\\",
|
||||
"\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<string, any>;
|
||||
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<string, any> } | undefined | null,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user