Add managed ChatGPT account binding for Codex

This commit is contained in:
saladday
2026-06-08 04:39:53 +08:00
parent 5c36ae066b
commit 7480cec0ba
11 changed files with 1080 additions and 102 deletions
+196
View File
@@ -6,7 +6,9 @@ use crate::config::{
write_json_file, write_text_file,
};
use crate::error::AppError;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::process::Command;
use toml_edit::DocumentMut;
@@ -14,6 +16,14 @@ use toml_edit::DocumentMut;
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
const CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME: &str = "codex_managed_oauth_live_auth.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexManagedOAuthLiveAuthMarker {
version: u32,
account_id: String,
access_token_sha256: String,
}
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
@@ -41,6 +51,121 @@ pub fn get_codex_auth_path() -> PathBuf {
get_codex_config_dir().join("auth.json")
}
fn get_codex_managed_oauth_live_auth_marker_path() -> PathBuf {
crate::config::get_app_config_dir().join(CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME)
}
fn sha256_hex(text: &str) -> String {
let digest = Sha256::digest(text.as_bytes());
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> {
let auth_obj = auth.as_object()?;
if auth_obj
.keys()
.any(|key| !matches!(key.as_str(), "auth_mode" | "OPENAI_API_KEY" | "tokens"))
{
return None;
}
if auth.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") {
return None;
}
let api_key_is_clearable = auth
.get("OPENAI_API_KEY")
.is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED"));
if !api_key_is_clearable {
return None;
}
let tokens = auth.get("tokens").and_then(|value| value.as_object())?;
if tokens
.keys()
.any(|key| !matches!(key.as_str(), "access_token" | "account_id"))
{
return None;
}
let account_id = tokens
.get("account_id")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())?;
let access_token = tokens
.get("access_token")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|token| !token.is_empty())?;
Some((account_id.to_string(), access_token.to_string()))
}
pub fn record_codex_managed_oauth_live_auth(auth: &Value) -> Result<(), AppError> {
let Some((account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(());
};
let marker = CodexManagedOAuthLiveAuthMarker {
version: 1,
account_id,
access_token_sha256: sha256_hex(&access_token),
};
crate::config::write_json_file(&get_codex_managed_oauth_live_auth_marker_path(), &marker)
}
pub fn codex_auth_matches_recorded_managed_oauth(
auth: &Value,
account_id: &str,
) -> Result<bool, AppError> {
let account_id = account_id.trim();
if account_id.is_empty() {
return Ok(false);
}
let Some((auth_account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(false);
};
if auth_account_id != account_id {
return Ok(false);
}
let marker_path = get_codex_managed_oauth_live_auth_marker_path();
let marker: CodexManagedOAuthLiveAuthMarker = match read_json_file(&marker_path) {
Ok(marker) => marker,
Err(err) => {
log::warn!(
"Failed to read Codex managed OAuth auth marker at {}: {err}",
marker_path.display()
);
return Ok(false);
}
};
Ok(marker.version == 1
&& marker.account_id == account_id
&& marker.access_token_sha256 == sha256_hex(&access_token))
}
pub fn clear_codex_live_auth_for_managed_account(account_id: &str) -> Result<(), AppError> {
let auth_path = get_codex_auth_path();
if auth_path.exists() {
let auth = read_json_file(&auth_path)?;
if !codex_auth_matches_recorded_managed_oauth(&auth, account_id)? {
return Ok(());
}
delete_file(&auth_path)?;
let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path());
}
Ok(())
}
/// 获取 Codex config.toml 路径
pub fn get_codex_config_path() -> PathBuf {
get_codex_config_dir().join("config.toml")
@@ -1253,6 +1378,36 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) ->
mod tests {
use super::*;
use serde_json::json;
use serial_test::serial;
use std::env;
struct TempHome {
#[allow(dead_code)]
dir: tempfile::TempDir,
original_test_home: Option<String>,
}
impl TempHome {
fn new() -> Self {
let dir = tempfile::tempdir().expect("create temp home");
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
Self {
dir,
original_test_home,
}
}
}
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_test_home {
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
None => env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
#[test]
fn prepare_provider_live_config_rejects_key_without_config() {
@@ -1265,6 +1420,47 @@ mod tests {
);
}
#[test]
#[serial]
fn recorded_managed_oauth_marker_matches_only_same_account_and_token() {
let _home = TempHome::new();
let managed_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "managed-token",
"account_id": "acct-managed"
}
});
let same_account_native_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "native-token",
"account_id": "acct-managed"
}
});
assert!(
!codex_auth_matches_recorded_managed_oauth(&managed_auth, "acct-managed")
.expect("marker check before record"),
"without a cc-switch marker, OAuth-shaped auth must not be treated as managed"
);
record_codex_managed_oauth_live_auth(&managed_auth).expect("record marker");
assert!(
codex_auth_matches_recorded_managed_oauth(&managed_auth, "acct-managed")
.expect("marker should match"),
"the recorded managed token should be clearable"
);
assert!(
!codex_auth_matches_recorded_managed_oauth(&same_account_native_auth, "acct-managed")
.expect("same-account native token should not match"),
"a later native login for the same account must not be cleared"
);
}
#[test]
fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() {
let input = r#"model_provider = "openai"
+3 -5
View File
@@ -921,13 +921,11 @@ pub fn run() {
// 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代)
{
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use commands::CodexOAuthState;
use tokio::sync::RwLock;
let app_config_dir = crate::config::get_app_config_dir();
let codex_oauth_manager = CodexOAuthManager::new(app_config_dir);
app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager))));
let codex_oauth_manager =
app.state::<AppState>().codex_oauth_manager.clone();
app.manage(CodexOAuthState(codex_oauth_manager));
log::info!("✓ CodexOAuthManager initialized");
}
@@ -695,6 +695,31 @@ impl CodexOAuthManager {
}
}
#[cfg(test)]
pub(crate) async fn add_test_account_with_access_token(
&self,
account_id: &str,
access_token: &str,
) -> Result<(), CodexOAuthError> {
self.add_account_internal(
account_id.to_string(),
"test-refresh-token".to_string(),
Some(format!("{account_id}@example.test")),
)
.await?;
let mut tokens = self.access_tokens.write().await;
tokens.insert(
account_id.to_string(),
CachedAccessToken {
token: access_token.to_string(),
expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000,
},
);
Ok(())
}
// ==================== 内部方法 ====================
async fn add_account_internal(
+269 -9
View File
@@ -3,8 +3,10 @@
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::sync::RwLock;
use toml_edit::{DocumentMut, Item, TableLike};
use crate::app_config::AppType;
@@ -13,6 +15,7 @@ use crate::config::{delete_file, get_claude_settings_path, read_json_file, write
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::mcp::McpService;
use crate::store::AppState;
@@ -506,14 +509,31 @@ pub(crate) fn build_effective_settings_with_common_config(
Ok(effective_settings)
}
pub(crate) fn write_live_with_common_config(
db: &Database,
pub(crate) fn write_live_with_common_config_for_state(
state: &AppState,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
write_live_with_common_config_for_codex_oauth_manager(
state.db.as_ref(),
app_type,
provider,
&state.codex_oauth_manager,
)
}
pub(crate) fn write_live_with_common_config_for_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<RwLock<CodexOAuthManager>>,
) -> Result<(), AppError> {
let effective_provider = build_effective_provider_for_live_with_codex_oauth_manager(
db,
app_type,
provider,
codex_oauth_manager,
)?;
if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
@@ -528,6 +548,92 @@ pub(crate) fn write_live_with_common_config(
write_live_snapshot(app_type, &effective_provider)
}
pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<RwLock<CodexOAuthManager>>,
) -> Result<Provider, AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
apply_codex_managed_oauth_auth(app_type, &mut effective_provider, Some(codex_oauth_manager))?;
Ok(effective_provider)
}
fn apply_codex_managed_oauth_auth(
app_type: &AppType,
provider: &mut Provider,
codex_oauth_manager: Option<&Arc<RwLock<CodexOAuthManager>>>,
) -> Result<(), AppError> {
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
return Ok(());
}
let Some(account_id) = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
else {
return Ok(());
};
let Some(manager) = codex_oauth_manager else {
return Err(AppError::Message(
"Codex OAuth 托管账号不可用,请重启应用后重试".to_string(),
));
};
let access_token = get_codex_managed_oauth_token(manager.clone(), account_id.clone())?;
let auth = codex_managed_oauth_live_auth(&account_id, &access_token);
let Some(settings_obj) = provider.settings_config.as_object_mut() else {
return Err(AppError::Config(
"Codex 供应商配置必须是 JSON 对象".to_string(),
));
};
settings_obj.insert("auth".to_string(), auth);
Ok(())
}
fn get_codex_managed_oauth_token(
manager: Arc<RwLock<CodexOAuthManager>>,
account_id: String,
) -> Result<String, AppError> {
let result = std::thread::spawn(move || {
tauri::async_runtime::block_on(async move {
let error_account_id = account_id.clone();
let manager = manager.read().await;
manager
.get_valid_token_for_account(&account_id)
.await
.map_err(|err| {
format!(
"Codex OAuth 账号 {error_account_id} 认证失败,请重新登录 ChatGPT 账号: {err}"
)
})
})
})
.join()
.map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?;
result.map_err(AppError::Message)
}
pub(crate) fn codex_managed_oauth_live_auth(account_id: &str, access_token: &str) -> Value {
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": access_token,
"account_id": account_id,
},
})
}
pub(crate) fn strip_common_config_from_live_settings(
db: &Database,
app_type: &AppType,
@@ -596,6 +702,8 @@ fn restore_live_settings_for_provider_backfill(
);
}
strip_codex_managed_oauth_auth_for_backfill(provider, &mut settings);
// `modelCatalog` is a cc-switchprivate field whose SSOT is the DB. Live's
// `config.toml` only carries a lossy projection (`model_catalog_json` →
// generated catalog file) that proxy takeover/restore cycles and Codex.app
@@ -612,6 +720,41 @@ fn restore_live_settings_for_provider_backfill(
settings
}
fn strip_codex_managed_oauth_auth_for_backfill(provider: &Provider, settings: &mut Value) {
let Some(account_id) = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
else {
return;
};
let Some(auth) = settings.get("auth") else {
return;
};
match crate::codex_config::codex_auth_matches_recorded_managed_oauth(auth, &account_id) {
Ok(true) => {
let stored_auth = provider
.settings_config
.get("auth")
.filter(|auth| auth.is_object())
.cloned()
.unwrap_or_else(|| json!({}));
if let Some(obj) = settings.as_object_mut() {
obj.insert("auth".to_string(), stored_auth);
}
}
Ok(false) => {}
Err(err) => {
log::warn!(
"Failed to check Codex managed OAuth marker while backfilling '{}': {err}",
provider.id
);
}
}
}
pub(crate) fn normalize_provider_common_config_for_storage(
db: &Database,
app_type: &AppType,
@@ -753,6 +896,14 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
auth,
config_str,
)?;
if provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some()
{
crate::codex_config::record_codex_managed_oauth_live_auth(auth)?;
}
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
@@ -884,7 +1035,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
continue;
}
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
if let Err(e) = write_live_with_common_config_for_state(state, app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
@@ -914,7 +1065,7 @@ pub(crate) fn sync_current_provider_for_app_to_live(
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
write_live_with_common_config_for_state(state, app_type, provider)?;
}
}
@@ -950,7 +1101,7 @@ fn sync_current_provider_for_app_respecting_takeover(
// that normal provider sync must not rewrite the managed live file.
if has_live_backup || live_taken_over {
if matches!(app_type, AppType::ClaudeDesktop) {
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
write_live_with_common_config_for_state(state, app_type, provider)?;
} else {
futures::executor::block_on(
state
@@ -962,7 +1113,7 @@ fn sync_current_provider_for_app_respecting_takeover(
return Ok(());
}
write_live_with_common_config(state.db.as_ref(), app_type, provider)
write_live_with_common_config_for_state(state, app_type, provider)
}
/// Sync current provider to live configuration
@@ -1569,6 +1720,7 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
use serde_json::json;
#[test]
@@ -1615,6 +1767,114 @@ mod tests {
assert_eq!(stripped, settings);
}
#[test]
fn codex_managed_oauth_live_auth_matches_codex_cli_shape() {
assert_eq!(
codex_managed_oauth_live_auth("acct-managed", "access-token"),
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "access-token",
"account_id": "acct-managed"
}
})
);
}
#[test]
fn codex_official_managed_oauth_binding_replaces_auth_with_selected_account_token() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(RwLock::new(CodexOAuthManager::new(
temp.path().to_path_buf(),
)));
tauri::async_runtime::block_on(async {
manager
.read()
.await
.add_test_account_with_access_token("acct-managed", "managed-token")
.await
.expect("seed managed account");
});
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "stale-key"
},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
provider.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
.expect("apply managed OAuth auth");
assert_eq!(
provider.settings_config.get("auth"),
Some(&codex_managed_oauth_live_auth(
"acct-managed",
"managed-token"
)),
"explicit account binding should write the managed ChatGPT token to Codex live auth"
);
}
#[test]
fn codex_official_without_binding_does_not_fall_back_to_managed_default_account() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(RwLock::new(CodexOAuthManager::new(
temp.path().to_path_buf(),
)));
tauri::async_runtime::block_on(async {
manager
.read()
.await
.add_test_account_with_access_token("acct-managed", "managed-token")
.await
.expect("seed managed account");
});
let original_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "native-codex-token",
"account_id": "acct-native"
}
});
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": original_auth.clone(),
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
.expect("no binding should be a no-op");
assert_eq!(
provider.settings_config.get("auth"),
Some(&original_auth),
"unbound official providers must keep native Codex auth instead of using the managed default account"
);
}
#[test]
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
let mut provider = Provider::with_id(
+171 -15
View File
@@ -30,9 +30,11 @@ pub use live::{
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::{
build_effective_provider_for_live_with_codex_oauth_manager,
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
provider_exists_in_live_config, strip_common_config_from_live_settings,
sync_current_provider_for_app_to_live, write_live_with_common_config,
sync_current_provider_for_app_to_live, write_live_with_common_config_for_codex_oauth_manager,
write_live_with_common_config_for_state,
};
// Internal re-exports
@@ -59,7 +61,7 @@ mod tests {
use crate::claude_desktop_config::PROFILE_ID;
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::provider::ProviderMeta;
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
#[cfg(any(target_os = "macos", windows))]
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
use crate::proxy::types::ProxyConfig;
@@ -831,6 +833,93 @@ base_url = "http://localhost:8080"
});
}
#[test]
#[serial]
fn switch_from_managed_codex_official_to_unbound_clears_live_without_backfilling_token() {
with_test_home(|state, _| {
crate::settings::reload_settings().expect("reload settings");
tauri::async_runtime::block_on(async {
state
.codex_oauth_manager
.read()
.await
.add_test_account_with_access_token("acct-managed", "managed-token")
.await
.expect("seed managed Codex OAuth account");
});
let mut managed = Provider::with_id(
"managed-official".to_string(),
"Managed Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
managed.category = Some("official".to_string());
managed.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
let mut unbound = Provider::with_id(
"unbound-official".to_string(),
"Unbound Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
unbound.category = Some("official".to_string());
state
.db
.save_provider(AppType::Codex.as_str(), &managed)
.expect("save managed provider");
state
.db
.save_provider(AppType::Codex.as_str(), &unbound)
.expect("save unbound provider");
ProviderService::switch(state, AppType::Codex, "managed-official")
.expect("switch to managed official");
let live_auth: Value = read_json_file(&crate::codex_config::get_codex_auth_path())
.expect("read managed live auth");
assert_eq!(
live_auth
.pointer("/tokens/access_token")
.and_then(Value::as_str),
Some("managed-token"),
"managed switch should write the selected ChatGPT token to live auth"
);
ProviderService::switch(state, AppType::Codex, "unbound-official")
.expect("switch to unbound official");
assert!(
!crate::codex_config::get_codex_auth_path().exists(),
"switching to an unbound official provider should clear the recorded managed live auth"
);
let saved_managed = state
.db
.get_provider_by_id("managed-official", AppType::Codex.as_str())
.expect("query managed provider")
.expect("managed provider should exist");
assert_eq!(
saved_managed.settings_config.get("auth"),
Some(&json!({})),
"switch-away backfill must not persist the managed access token into provider storage"
);
});
}
#[test]
#[serial]
fn import_opencode_providers_from_live_marks_provider_as_live_managed() {
@@ -1116,6 +1205,35 @@ base_url = "http://localhost:8080"
}
impl ProviderService {
fn managed_codex_oauth_account_id(provider: &Provider) -> Option<String> {
provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
}
fn unbound_managed_codex_oauth_account_id(
app_type: &AppType,
existing_provider: Option<&Provider>,
provider: &Provider,
) -> Option<String> {
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
return None;
}
let existing_provider = existing_provider
.filter(|existing| existing.category.as_deref() == Some("official"))?;
let old_account_id = Self::managed_codex_oauth_account_id(existing_provider)?;
if Self::managed_codex_oauth_account_id(provider).is_some() {
return None;
}
Some(old_account_id)
}
fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {
if matches!(app_type, AppType::Claude) {
let mut v = provider.settings_config.clone();
@@ -1209,7 +1327,7 @@ impl ProviderService {
if !add_to_live {
return Ok(true);
}
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
write_live_with_common_config_for_state(state, &app_type, &provider)?;
return Ok(true);
}
@@ -1220,7 +1338,7 @@ impl ProviderService {
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
write_live_with_common_config_for_state(state, &app_type, &provider)?;
}
Ok(true)
@@ -1370,10 +1488,16 @@ impl ProviderService {
if !live_config_managed {
return Ok(true);
}
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
write_live_with_common_config_for_state(state, &app_type, &provider)?;
return Ok(true);
}
let unbound_codex_managed_account_id = Self::unbound_managed_codex_oauth_account_id(
&app_type,
existing_provider.as_ref(),
&provider,
);
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
@@ -1401,14 +1525,32 @@ impl ProviderService {
if should_sync_via_proxy {
if matches!(app_type, AppType::ClaudeDesktop) {
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
write_live_with_common_config_for_state(state, &app_type, &provider)?;
} else {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), &provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
let update_backup_result =
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider_clearing_codex_auth(
app_type.as_str(),
&provider,
account_id,
),
)
} else {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), &provider),
)
};
update_backup_result
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
}
}
if matches!(app_type, AppType::Claude)
@@ -1422,7 +1564,10 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("同步 Claude Live 配置失败: {e}")))?;
}
} else {
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
write_live_with_common_config_for_state(state, &app_type, &provider)?;
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
}
// Sync MCP
McpService::sync_all_enabled(state)?;
}
@@ -1691,6 +1836,10 @@ impl ProviderService {
// Backfill: Backfill current live config to current provider
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let current_managed_codex_account_id = current_id
.as_deref()
.and_then(|current_id| providers.get(current_id))
.and_then(Self::managed_codex_oauth_account_id);
if let Some(current_id) = current_id {
if current_id != id {
@@ -1731,7 +1880,14 @@ impl ProviderService {
}
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
write_live_with_common_config_for_state(state, &app_type, provider)?;
if matches!(app_type, AppType::Codex)
&& Self::managed_codex_oauth_account_id(provider).is_none()
{
if let Some(account_id) = current_managed_codex_account_id.as_deref() {
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
}
}
// Hermes is additive, so "switching" doesn't overwrite a live config file
// — we instead update the top-level `model:` section to point at this
@@ -1831,7 +1987,7 @@ impl ProviderService {
// here, not just proxy_config.enabled.
if has_live_backup || live_taken_over {
if matches!(app_type, AppType::ClaudeDesktop) {
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
write_live_with_common_config_for_state(state, &app_type, provider)?;
return Ok(());
}
+266 -9
View File
@@ -6,11 +6,14 @@ use crate::app_config::AppType;
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::server::ProxyServer;
use crate::proxy::switch_lock::SwitchLockManager;
use crate::proxy::types::*;
use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config,
build_effective_provider_for_live_with_codex_oauth_manager,
build_effective_settings_with_common_config,
write_live_with_common_config_for_codex_oauth_manager,
};
use serde_json::{json, Map, Value};
use std::str::FromStr;
@@ -54,6 +57,7 @@ enum ClaudeTakeoverAuthPolicy {
#[derive(Clone)]
pub struct ProxyService {
db: Arc<Database>,
codex_oauth_manager: Arc<RwLock<CodexOAuthManager>>,
server: Arc<RwLock<Option<ProxyServer>>>,
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
@@ -67,8 +71,20 @@ pub struct HotSwitchOutcome {
impl ProxyService {
pub fn new(db: Arc<Database>) -> Self {
let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new(
crate::config::get_app_config_dir(),
)));
Self::new_with_codex_oauth_manager(db, codex_oauth_manager)
}
pub fn new_with_codex_oauth_manager(
db: Arc<Database>,
codex_oauth_manager: Arc<RwLock<CodexOAuthManager>>,
) -> Self {
Self {
db,
codex_oauth_manager,
server: Arc::new(RwLock::new(None)),
app_handle: Arc::new(RwLock::new(None)),
switch_locks: SwitchLockManager::new(),
@@ -1650,8 +1666,13 @@ impl ProxyService {
return Ok(false);
};
write_live_with_common_config(self.db.as_ref(), app_type, provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
write_live_with_common_config_for_codex_oauth_manager(
self.db.as_ref(),
app_type,
provider,
&self.codex_oauth_manager,
)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
Ok(true)
}
@@ -1975,7 +1996,18 @@ impl ProxyService {
provider: &Provider,
) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type).await;
self.update_live_backup_from_provider_inner(app_type, provider)
self.update_live_backup_from_provider_inner(app_type, provider, None)
.await
}
pub(crate) async fn update_live_backup_from_provider_clearing_codex_auth(
&self,
app_type: &str,
provider: &Provider,
account_id: &str,
) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type).await;
self.update_live_backup_from_provider_inner(app_type, provider, Some(account_id))
.await
}
@@ -1984,12 +2016,23 @@ impl ProxyService {
&self,
app_type: &str,
provider: &Provider,
clear_codex_auth_for_account: Option<&str>,
) -> Result<(), String> {
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
let mut effective_settings =
let mut effective_settings = if matches!(app_type_enum, AppType::Codex) {
build_effective_provider_for_live_with_codex_oauth_manager(
self.db.as_ref(),
&app_type_enum,
provider,
&self.codex_oauth_manager,
)
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?
.settings_config
} else {
build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?
};
if matches!(app_type_enum, AppType::Codex) {
let existing_backup_value = self
@@ -2008,7 +2051,19 @@ impl ProxyService {
&mut effective_settings,
existing_value,
)?;
Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;
if let Some(account_id) = clear_codex_auth_for_account {
Self::clear_codex_auth_in_backup(
&mut effective_settings,
existing_value,
account_id,
)?;
} else {
Self::preserve_codex_oauth_auth_in_backup(
&mut effective_settings,
existing_value,
provider,
)?;
}
}
}
@@ -2035,6 +2090,19 @@ impl ProxyService {
.await
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
if matches!(app_type_enum, AppType::Codex)
&& provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some()
{
if let Some(auth) = effective_settings.get("auth") {
crate::codex_config::record_codex_managed_oauth_live_auth(auth)
.map_err(|e| format!("记录 Codex 托管认证标记失败: {e}"))?;
}
}
log::info!("已更新 {app_type} Live 备份(热切换)");
Ok(())
}
@@ -2091,7 +2159,7 @@ impl ProxyService {
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
if should_sync_backup {
self.update_live_backup_from_provider_inner(app_type, &provider)
self.update_live_backup_from_provider_inner(app_type, &provider, None)
.await?;
if matches!(app_type_enum, AppType::Claude) {
@@ -2201,14 +2269,48 @@ impl ProxyService {
Ok(())
}
fn clear_codex_auth_in_backup(
target_settings: &mut Value,
existing_backup: &Value,
account_id: &str,
) -> Result<(), String> {
let Some(existing_auth) = existing_backup.get("auth") else {
return Ok(());
};
let Some(target_obj) = target_settings.as_object_mut() else {
return Err("Codex 备份必须是 JSON 对象".to_string());
};
if crate::codex_config::codex_auth_matches_recorded_managed_oauth(existing_auth, account_id)
.map_err(|e| format!("检查 Codex 托管认证标记失败: {e}"))?
{
target_obj.insert("auth".to_string(), json!({}));
return Ok(());
}
target_obj.insert("auth".to_string(), existing_auth.clone());
Ok(())
}
fn preserve_codex_oauth_auth_in_backup(
target_settings: &mut Value,
existing_backup: &Value,
provider: &Provider,
) -> Result<(), String> {
if !crate::settings::preserve_codex_official_auth_on_switch() {
return Ok(());
}
if provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.map(|id| !id.trim().is_empty())
.unwrap_or(false)
{
return Ok(());
}
let Some(existing_auth) = existing_backup
.get("auth")
.filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))
@@ -2668,7 +2770,7 @@ impl ProxyService {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ProviderMeta;
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
use serial_test::serial;
use std::env;
use tempfile::TempDir;
@@ -4826,6 +4928,161 @@ base_url = "https://codex.example/v1"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_uses_managed_codex_oauth_binding_over_existing_oauth_backup(
) {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
crate::settings::update_settings(crate::settings::AppSettings {
preserve_codex_official_auth_on_switch: true,
..Default::default()
})
.expect("enable official auth preservation");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
service
.codex_oauth_manager
.read()
.await
.add_test_account_with_access_token("acct-managed", "managed-token")
.await
.expect("seed managed Codex OAuth account");
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": {
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "old-native-token",
"account_id": "acct-native"
}
},
"config": ""
}))
.expect("serialize seed backup"),
)
.await
.expect("seed live backup");
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
provider.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
assert_eq!(
stored
.pointer("/auth/tokens/access_token")
.and_then(|value| value.as_str()),
Some("managed-token"),
"explicit managed account binding must replace stale native OAuth backup auth"
);
assert_eq!(
stored
.pointer("/auth/tokens/account_id")
.and_then(|value| value.as_str()),
Some("acct-managed")
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_clearing_managed_codex_auth_preserves_native_backup_auth() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
crate::settings::update_settings(crate::settings::AppSettings {
preserve_codex_official_auth_on_switch: true,
..Default::default()
})
.expect("enable official auth preservation");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let native_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "native-token",
"account_id": "acct-managed"
}
});
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": native_auth,
"config": ""
}))
.expect("serialize seed backup"),
)
.await
.expect("seed live backup");
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
service
.update_live_backup_from_provider_clearing_codex_auth(
"codex",
&provider,
"acct-managed",
)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
assert_eq!(
stored.get("auth"),
Some(&native_auth),
"unbinding should not overwrite a later native Codex login in the backup"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
+9 -1
View File
@@ -1,23 +1,31 @@
use crate::database::Database;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::{ProxyService, UsageCache};
use std::sync::Arc;
use tokio::sync::RwLock;
/// 全局应用状态
pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,
pub usage_cache: Arc<UsageCache>,
pub codex_oauth_manager: Arc<RwLock<CodexOAuthManager>>,
}
impl AppState {
/// 创建新的应用状态
pub fn new(db: Arc<Database>) -> Self {
let proxy_service = ProxyService::new(db.clone());
let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new(
crate::config::get_app_config_dir(),
)));
let proxy_service =
ProxyService::new_with_codex_oauth_manager(db.clone(), codex_oauth_manager.clone());
Self {
db,
proxy_service,
usage_cache: Arc::new(UsageCache::new()),
codex_oauth_manager,
}
}
}
@@ -19,6 +19,7 @@ import {
Trash2,
} from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { CodexOAuthSection } from "./CodexOAuthSection";
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
import {
fetchModelsForConfig,
@@ -46,6 +47,10 @@ interface CodexFormFieldsProps {
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
isCodexOauthPreset?: boolean;
selectedCodexAccountId?: string | null;
onCodexAccountSelect?: (accountId: string | null) => void;
codexOauthNoneOptionLabel?: string;
// Base URL
shouldShowSpeedTest: boolean;
@@ -111,6 +116,10 @@ export function CodexFormFields({
websiteUrl,
isPartner,
partnerPromotionKey,
isCodexOauthPreset = false,
selectedCodexAccountId,
onCodexAccountSelect,
codexOauthNoneOptionLabel,
shouldShowSpeedTest,
codexBaseUrl,
onBaseUrlChange,
@@ -284,26 +293,37 @@ export function CodexFormFields({
return (
<>
{/* Codex OAuth 账号选择 */}
{isCodexOauthPreset && (
<CodexOAuthSection
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
noneOptionLabel={codexOauthNoneOptionLabel}
/>
)}
{/* Codex API Key 输入框 */}
<ApiKeySection
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
placeholder={{
official: t("providerForm.codexOfficialNoApiKey", {
defaultValue: "官方供应商无需 API Key",
}),
thirdParty: t("providerForm.codexApiKeyAutoFill", {
defaultValue: "输入 API Key,将自动填充到配置",
}),
}}
/>
{!isCodexOauthPreset && (
<ApiKeySection
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
placeholder={{
official: t("providerForm.codexOfficialNoApiKey", {
defaultValue: "官方供应商无需 API Key",
}),
thirdParty: t("providerForm.codexApiKeyAutoFill", {
defaultValue: "输入 API Key,将自动填充到配置",
}),
}}
/>
)}
{/* Codex Base URL 输入框 */}
{shouldShowSpeedTest && (
@@ -31,6 +31,8 @@ interface CodexOAuthSectionProps {
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 空选择项文案;默认表示使用托管认证的默认账号 */
noneOptionLabel?: string;
/** 是否开启 Codex FAST mode */
fastModeEnabled?: boolean;
/** FAST mode 切换回调 */
@@ -47,6 +49,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
selectedAccountId,
onAccountSelect,
noneOptionLabel,
fastModeEnabled = false,
onFastModeChange,
}) => {
@@ -111,7 +114,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
</div>
{/* 账号选择器 */}
{hasAnyAccount && onAccountSelect && (
{onAccountSelect && (hasAnyAccount || noneOptionLabel) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.selectAccount", "选择账号")}
@@ -131,7 +134,8 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("codexOauth.useDefaultAccount", "使用默认账号")}
{noneOptionLabel ??
t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
+93 -40
View File
@@ -126,6 +126,16 @@ type PresetEntry = {
| HermesProviderPreset;
};
function getPresetProviderType(
preset: PresetEntry["preset"] | null | undefined,
): "github_copilot" | "codex_oauth" | undefined {
if (!preset || !("providerType" in preset)) return undefined;
return preset.providerType === "github_copilot" ||
preset.providerType === "codex_oauth"
? preset.providerType
: undefined;
}
const codexApiFormatFromWireApi = (
wireApi: string | undefined,
): CodexApiFormat | undefined => {
@@ -354,6 +364,13 @@ function ProviderFormFull({
initialData?.meta?.pricingModelSource,
),
});
setSelectedGitHubAccountId(
resolveManagedAccountId(initialData?.meta, "github_copilot"),
);
setSelectedCodexAccountId(
resolveManagedAccountId(initialData?.meta, "codex_oauth"),
);
setCodexFastMode(initialData?.meta?.codexFastMode ?? false);
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
}, [appId, initialData, supportsFullUrl]);
@@ -635,6 +652,41 @@ function ProviderFormFull({
}));
}, [appId]);
const selectedPresetEntry = useMemo(
() =>
selectedPresetId && selectedPresetId !== "custom"
? (presetEntries.find((entry) => entry.id === selectedPresetId) ??
null)
: null,
[presetEntries, selectedPresetId],
);
const selectedPresetProviderType = getPresetProviderType(
selectedPresetEntry?.preset,
);
const initialProviderType = initialData?.meta?.providerType;
const isCopilotProvider =
appId === "claude" &&
(selectedPresetProviderType === "github_copilot" ||
initialProviderType === "github_copilot" ||
baseUrl.includes("githubcopilot.com"));
const isClaudeCodexOauthProvider =
appId === "claude" &&
(selectedPresetProviderType === "codex_oauth" ||
initialProviderType === "codex_oauth");
const isCodexOfficialProvider =
appId === "codex" &&
(category === "official" ||
(selectedPresetProviderType === "codex_oauth" &&
selectedPresetEntry?.preset.category === "official"));
const isCodexOfficialManagedOauthBound =
isCodexOfficialProvider && Boolean(selectedCodexAccountId);
const wasCodexOfficialManagedOauthBound =
appId === "codex" &&
initialData?.category === "official" &&
Boolean(resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const requiresCodexOauthLogin =
isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound;
const {
templateValues,
templateValueEntries,
@@ -1051,13 +1103,6 @@ function ProviderFormFull({
}
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
if (isCopilotProvider && !isCopilotAuthenticated) {
toast.error(
t("copilot.loginRequired", {
@@ -1066,7 +1111,7 @@ function ProviderFormFull({
);
return;
}
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
if (requiresCodexOauthLogin && !isCodexOauthAuthenticated) {
toast.error(
t("codexOauth.loginRequired", {
defaultValue: "请先登录 ChatGPT 账号",
@@ -1110,14 +1155,14 @@ function ProviderFormFull({
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!isCodexOauthProvider && !baseUrl.trim()) {
if (!isClaudeCodexOauthProvider && !baseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
if (!isCopilotProvider && !isClaudeCodexOauthProvider && !apiKey.trim()) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
@@ -1168,20 +1213,17 @@ function ProviderFormFull({
};
const performSubmit = async (values: ProviderFormData) => {
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
let settingsConfig: string;
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
const shouldStripManagedCodexAuth =
category === "official" &&
(isCodexOfficialManagedOauthBound ||
wasCodexOfficialManagedOauthBound);
const authJson = shouldStripManagedCodexAuth
? {}
: JSON.parse(codexAuth);
let normalizedCodexConfig =
category !== "official" && (codexConfig ?? "").trim()
? setCodexWireApi(codexConfig ?? "", "responses")
@@ -1343,9 +1385,11 @@ function ProviderFormFull({
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType =
templatePreset?.providerType || initialData?.meta?.providerType;
const providerType = isCopilotProvider
? "github_copilot"
: isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound
? "codex_oauth"
: undefined;
const nextMeta: ProviderMeta = {
...(baseMeta ?? {}),
@@ -1367,19 +1411,25 @@ function ProviderFormFull({
authProvider: "github_copilot",
accountId: selectedGitHubAccountId ?? undefined,
}
: isCodexOauthProvider
: isClaudeCodexOauthProvider
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: isCodexOfficialManagedOauthBound
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
? selectedGitHubAccountId
: undefined,
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
codexFastMode: isClaudeCodexOauthProvider ? codexFastMode : undefined,
codexChatReasoning:
appId === "codex" &&
category !== "official" &&
@@ -1412,9 +1462,18 @@ function ProviderFormFull({
: undefined,
};
if (!isCodexOauthProvider && "codexFastMode" in nextMeta) {
if (!isClaudeCodexOauthProvider && "codexFastMode" in nextMeta) {
delete nextMeta.codexFastMode;
}
if (!providerType && "providerType" in nextMeta) {
delete nextMeta.providerType;
}
if (!nextMeta.authBinding && "authBinding" in nextMeta) {
delete nextMeta.authBinding;
}
if (!nextMeta.githubAccountId && "githubAccountId" in nextMeta) {
delete nextMeta.githubAccountId;
}
payload.meta = nextMeta;
@@ -1953,22 +2012,12 @@ function ProviderFormFull({
websiteUrl={claudeWebsiteUrl}
isPartner={isClaudePartner}
partnerPromotionKey={claudePartnerPromotionKey}
isCopilotPreset={
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com")
}
isCodexOauthPreset={
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
isCopilotPreset={isCopilotProvider}
isCodexOauthPreset={isClaudeCodexOauthProvider}
usesOAuth={
templatePreset?.requiresOAuth === true ||
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com") ||
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
isCopilotProvider ||
isClaudeCodexOauthProvider
}
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
@@ -2022,6 +2071,10 @@ function ProviderFormFull({
websiteUrl={codexWebsiteUrl}
isPartner={isCodexPartner}
partnerPromotionKey={codexPartnerPromotionKey}
isCodexOauthPreset={isCodexOfficialProvider}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
codexOauthNoneOptionLabel="暂不绑定托管账号"
shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange}
+3 -2
View File
@@ -35,6 +35,8 @@ export interface CodexProviderPreset {
modelCatalog?: CodexCatalogModel[];
// Codex Responses -> Chat Completions reasoning capability defaults
codexChatReasoning?: CodexChatReasoning;
// Managed OAuth provider type
providerType?: "codex_oauth";
}
/**
@@ -90,12 +92,11 @@ export const codexProviderPresets: CodexProviderPreset[] = [
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
category: "official",
providerType: "codex_oauth",
auth: {},
config: ``,
theme: {
icon: "codex",
backgroundColor: "#1F2937", // gray-800
textColor: "#FFFFFF",
},
icon: "openai",
iconColor: "#00A67E",