mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Add managed ChatGPT account binding for Codex
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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-switch–private 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(¤t_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(
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user