mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat: add Codex OAuth (ChatGPT Plus/Pro) reverse proxy support
Adds a new managed OAuth provider that lets Claude Code route requests through a user's ChatGPT Plus/Pro subscription via the chatgpt.com backend-api/codex endpoint. - CodexOAuthManager: OpenAI Device Code flow with multi-account support, JWT-based account identification, and automatic access_token refresh. - Reuses the generic managed-auth command surface (auth_start_login, auth_poll_for_account, etc.) via provider dispatch in commands/auth.rs. - ClaudeAdapter detects codex_oauth providers, forces the base URL to the ChatGPT backend, pins api_format to openai_responses, and emits Authorization + originator headers; the forwarder injects the dynamic access_token and ChatGPT-Account-Id per request. - transform_responses gains an is_codex_oauth path that aligns the body with OpenAI's codex-rs ResponsesApiRequest contract: sets store:false, appends reasoning.encrypted_content to include, strips max_output_tokens / temperature / top_p, injects default instructions/tools/parallel_tool_calls, and forces stream:true. Covered by 9 new unit tests plus regression guards for the non-Codex path. - Stream check reuses the same transform flag so detection matches the production request shape. - Frontend adds CodexOAuthSection + useCodexOauth hook, integrates it into ClaudeFormFields / ProviderForm / AuthCenterPanel, ships a new "Codex (ChatGPT Plus/Pro)" preset, and adds zh/en/ja i18n strings.
This commit is contained in:
+177
-61
@@ -1,9 +1,14 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::codex_oauth::CodexOAuthState;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthError;
|
||||
use crate::proxy::providers::copilot_auth::{
|
||||
CopilotAuthError, GitHubAccount, GitHubDeviceCodeResponse,
|
||||
};
|
||||
|
||||
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthAccount {
|
||||
@@ -34,9 +39,10 @@ pub struct ManagedAuthDeviceCodeResponse {
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> {
|
||||
fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> {
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
|
||||
AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH),
|
||||
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
|
||||
}
|
||||
}
|
||||
@@ -73,110 +79,220 @@ fn map_device_code_response(
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_start_login(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<ManagedAuthDeviceCodeResponse, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_poll_for_account(
|
||||
auth_provider: String,
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Option<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref())))
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
}))
|
||||
}
|
||||
Err(CopilotAuthError::AuthorizationPending) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(None)
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
}))
|
||||
}
|
||||
Err(CodexOAuthError::AuthorizationPending) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_list_accounts(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Vec<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_get_status(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<ManagedAuthStatus, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: status.migration_error,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect(),
|
||||
})
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: status.migration_error,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: None,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_remove_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_set_default_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_logout(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Codex OAuth Tauri Commands
|
||||
//!
|
||||
//! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。
|
||||
//!
|
||||
//! 注意:实际的命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端,
|
||||
//! 此处仅定义 State wrapper。
|
||||
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Codex OAuth 认证状态
|
||||
pub struct CodexOAuthState(pub Arc<RwLock<CodexOAuthManager>>);
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod auth;
|
||||
mod balance;
|
||||
mod codex_oauth;
|
||||
mod coding_plan;
|
||||
mod config;
|
||||
mod copilot;
|
||||
@@ -33,6 +34,7 @@ mod workspace;
|
||||
|
||||
pub use auth::*;
|
||||
pub use balance::*;
|
||||
pub use codex_oauth::*;
|
||||
pub use coding_plan::*;
|
||||
pub use config::*;
|
||||
pub use copilot::*;
|
||||
|
||||
@@ -715,6 +715,18 @@ pub fn run() {
|
||||
log::info!("✓ CopilotAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化 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))));
|
||||
log::info!("✓ CodexOAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
|
||||
@@ -17,7 +17,8 @@ use super::{
|
||||
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::commands::CopilotAuthState;
|
||||
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use http::Extensions;
|
||||
@@ -917,6 +918,9 @@ impl RequestForwarder {
|
||||
let force_identity_encoding = needs_transform
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
@@ -969,11 +973,71 @@ impl RequestForwarder {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Codex OAuth 特殊处理:从 CodexOAuthManager 获取真实 access_token
|
||||
if auth.strategy == AuthStrategy::CodexOAuth {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
let codex_state = app_handle.state::<CodexOAuthState>();
|
||||
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
|
||||
codex_state.0.read().await;
|
||||
|
||||
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("codex_oauth"));
|
||||
|
||||
let token_result = match &account_id {
|
||||
Some(id) => {
|
||||
log::debug!("[CodexOAuth] 使用指定账号 {id} 获取 token");
|
||||
codex_auth.get_valid_token_for_account(id).await
|
||||
}
|
||||
None => {
|
||||
log::debug!("[CodexOAuth] 使用默认账号获取 token");
|
||||
codex_auth.get_valid_token().await
|
||||
}
|
||||
};
|
||||
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
auth = AuthInfo::new(token, AuthStrategy::CodexOAuth);
|
||||
// 解析使用的 account_id(用于注入 ChatGPT-Account-Id header)
|
||||
codex_oauth_account_id = match account_id {
|
||||
Some(id) => Some(id),
|
||||
None => codex_auth.default_account_id().await,
|
||||
};
|
||||
log::debug!(
|
||||
"[CodexOAuth] 成功获取 access_token (account={})",
|
||||
codex_oauth_account_id.as_deref().unwrap_or("default")
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CodexOAuth] 获取 access_token 失败: {e}");
|
||||
return Err(ProxyError::AuthError(format!(
|
||||
"Codex OAuth 认证失败: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("[CodexOAuth] AppHandle 不可用");
|
||||
return Err(ProxyError::AuthError(
|
||||
"Codex OAuth 认证不可用(无 AppHandle)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
adapter.get_auth_headers(&auth)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 注入 Codex OAuth 的 ChatGPT-Account-Id header(如果有 account_id)
|
||||
if let Some(ref account_id) = codex_oauth_account_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(account_id) {
|
||||
auth_headers.push((http::HeaderName::from_static("chatgpt-account-id"), hv));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
|
||||
for (name, value) in auth_headers.iter_mut() {
|
||||
|
||||
@@ -119,6 +119,15 @@ pub enum AuthStrategy {
|
||||
///
|
||||
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
|
||||
GitHubCopilot,
|
||||
|
||||
/// Codex OAuth 认证方式(ChatGPT Plus/Pro)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <access_token>`
|
||||
/// - Header: `ChatGPT-Account-Id: <account_id>` (来自 forwarder 注入)
|
||||
/// - Header: `originator: cc-switch`
|
||||
///
|
||||
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -234,6 +243,7 @@ mod tests {
|
||||
AuthStrategy::Google,
|
||||
AuthStrategy::GoogleOAuth,
|
||||
AuthStrategy::GitHubCopilot,
|
||||
AuthStrategy::CodexOAuth,
|
||||
];
|
||||
|
||||
for (i, s1) in strategies.iter().enumerate() {
|
||||
|
||||
@@ -22,6 +22,13 @@ use crate::proxy::error::ProxyError;
|
||||
/// 供 handler/forwarder 外部使用的公开函数。
|
||||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return "openai_responses";
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
@@ -82,7 +89,18 @@ pub fn transform_claude_request_for_api_format(
|
||||
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||||
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
|
||||
let is_codex_oauth = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
super::transform_responses::anthropic_to_responses(
|
||||
body,
|
||||
Some(cache_key),
|
||||
is_codex_oauth,
|
||||
)
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
_ => Ok(body),
|
||||
@@ -101,10 +119,16 @@ impl ClaudeAdapter {
|
||||
///
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||||
/// - CodexOAuth: meta.provider_type 为 codex_oauth
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 Codex OAuth (ChatGPT Plus/Pro)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
|
||||
// 检测 GitHub Copilot
|
||||
if self.is_github_copilot(provider) {
|
||||
return ProviderType::GitHubCopilot;
|
||||
@@ -123,6 +147,16 @@ impl ClaudeAdapter {
|
||||
ProviderType::Claude
|
||||
}
|
||||
|
||||
/// 检测是否为 Codex OAuth 供应商(ChatGPT Plus/Pro 反代)
|
||||
fn is_codex_oauth(&self, provider: &Provider) -> bool {
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测是否为 GitHub Copilot 供应商
|
||||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||||
// 方式1: 检查 meta.provider_type
|
||||
@@ -254,6 +288,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return Ok("https://chatgpt.com/backend-api/codex".to_string());
|
||||
}
|
||||
|
||||
// 1. 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
@@ -304,6 +343,15 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
));
|
||||
}
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro) 同样使用占位符
|
||||
// 实际的 access_token 由 CodexOAuthManager 动态提供
|
||||
if provider_type == ProviderType::CodexOAuth {
|
||||
return Some(AuthInfo::new(
|
||||
"codex_oauth_placeholder".to_string(),
|
||||
AuthStrategy::CodexOAuth,
|
||||
));
|
||||
}
|
||||
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
@@ -315,6 +363,12 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
// Codex OAuth: 所有请求统一走 /responses 端点
|
||||
if base_url == "https://chatgpt.com/backend-api/codex" {
|
||||
let _ = endpoint; // 忽略原始 endpoint
|
||||
return "https://chatgpt.com/backend-api/codex/responses".to_string();
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||
@@ -347,6 +401,20 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
AuthStrategy::CodexOAuth => {
|
||||
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
|
||||
// ChatGPT-Account-Id 由 forwarder 注入额外 header
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static("cc-switch"),
|
||||
),
|
||||
]
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -412,6 +480,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Codex OAuth 总是需要格式转换 (Anthropic → OpenAI Responses API)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ mod adapter;
|
||||
mod auth;
|
||||
mod claude;
|
||||
mod codex;
|
||||
pub mod codex_oauth_auth;
|
||||
pub mod copilot_auth;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
@@ -58,6 +59,8 @@ pub enum ProviderType {
|
||||
OpenRouter,
|
||||
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
|
||||
GitHubCopilot,
|
||||
/// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换)
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
@@ -70,6 +73,7 @@ impl ProviderType {
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::GitHubCopilot => true,
|
||||
ProviderType::CodexOAuth => true,
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
@@ -86,6 +90,7 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +106,9 @@ impl ProviderType {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 base_url 是否为 GitHub Copilot
|
||||
@@ -175,6 +183,7 @@ impl ProviderType {
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
ProviderType::GitHubCopilot => "github_copilot",
|
||||
ProviderType::CodexOAuth => "codex_oauth",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +208,7 @@ impl std::str::FromStr for ProviderType {
|
||||
"github_copilot" | "github-copilot" | "githubcopilot" => {
|
||||
Ok(ProviderType::GitHubCopilot)
|
||||
}
|
||||
"codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth),
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -228,7 +238,8 @@ pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn Pr
|
||||
ProviderType::Claude
|
||||
| ProviderType::ClaudeAuth
|
||||
| ProviderType::OpenRouter
|
||||
| ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),
|
||||
| ProviderType::GitHubCopilot
|
||||
| ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()),
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
|
||||
@@ -14,7 +14,14 @@ use serde_json::{json, Value};
|
||||
/// Anthropic 请求 → OpenAI Responses 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
|
||||
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
|
||||
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
|
||||
pub fn anthropic_to_responses(
|
||||
body: Value,
|
||||
cache_key: Option<&str>,
|
||||
is_codex_oauth: bool,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -103,6 +110,59 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 特殊协议约束:
|
||||
// 整体依据:OpenAI 官方 codex-rs 的 `ResponsesApiRequest` 结构体
|
||||
// (codex-rs/codex-api/src/common.rs) 是 ChatGPT 反代后端的协议契约。
|
||||
// 任何不在该结构体里的字段都可能被 ChatGPT 后端以
|
||||
// "Unsupported parameter: ..." 400 拒绝;任何在结构体里的必填字段
|
||||
// 都需要在请求体里出现。
|
||||
//
|
||||
// 字段处理:
|
||||
// - store: 必须显式为 false(ChatGPT 消费级后端不允许服务端持久化)
|
||||
// - include: 必须包含 "reasoning.encrypted_content",
|
||||
// 否则多轮 reasoning 中间态会丢失(无服务端状态 + 无加密回传 = 上下文断链)
|
||||
// - max_output_tokens / temperature / top_p: 必须删除
|
||||
// (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们)
|
||||
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
|
||||
// (cc-switch 的 transform 当前是"条件写入",可能产生缺失)
|
||||
// - stream: 必须永远 true(codex-rs 硬编码 true,且 cc-switch 的
|
||||
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false)
|
||||
if is_codex_oauth {
|
||||
result["store"] = json!(false);
|
||||
|
||||
const REASONING_MARKER: &str = "reasoning.encrypted_content";
|
||||
let mut includes: Vec<Value> = body
|
||||
.get("include")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(REASONING_MARKER))
|
||||
{
|
||||
includes.push(json!(REASONING_MARKER));
|
||||
}
|
||||
result["include"] = json!(includes);
|
||||
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
// —— 删除 ChatGPT 反代不接受的字段 ——
|
||||
obj.remove("max_output_tokens");
|
||||
obj.remove("temperature");
|
||||
obj.remove("top_p");
|
||||
|
||||
// —— 兜底必填字段(or_insert:客户端送了什么就保留,否则注入默认值)——
|
||||
obj.entry("instructions".to_string()).or_insert(json!(""));
|
||||
obj.entry("tools".to_string()).or_insert(json!([]));
|
||||
obj.entry("parallel_tool_calls".to_string())
|
||||
.or_insert(json!(false));
|
||||
|
||||
// —— 强制覆盖 stream = true ——
|
||||
// 即便客户端误传 stream:false 也要覆盖,因为 codex-rs 永远 true,
|
||||
// 且 cc-switch SSE 解析层只支持流式响应。
|
||||
obj.insert("stream".to_string(), json!(true));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -468,7 +528,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
assert_eq!(result["max_output_tokens"], 1024);
|
||||
assert_eq!(result["input"][0]["role"], "user");
|
||||
@@ -487,7 +547,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
// system should not appear in input
|
||||
assert_eq!(result["input"].as_array().unwrap().len(), 1);
|
||||
@@ -505,7 +565,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
|
||||
}
|
||||
|
||||
@@ -522,7 +582,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["name"], "get_weather");
|
||||
assert!(result["tools"][0].get("parameters").is_some());
|
||||
@@ -539,7 +599,7 @@ mod tests {
|
||||
"tool_choice": {"type": "any"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tool_choice"], "required");
|
||||
}
|
||||
|
||||
@@ -552,7 +612,7 @@ mod tests {
|
||||
"tool_choice": {"type": "tool", "name": "get_weather"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["name"], "get_weather");
|
||||
}
|
||||
@@ -571,7 +631,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: assistant message (text) + function_call item
|
||||
@@ -601,7 +661,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: function_call_output item (lifted)
|
||||
@@ -625,7 +685,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// thinking should be discarded, only text remains
|
||||
@@ -648,7 +708,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let content = result["input"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
@@ -806,7 +866,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["model"], "o3-mini");
|
||||
}
|
||||
|
||||
@@ -818,7 +878,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id")).unwrap();
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "my-provider-id");
|
||||
}
|
||||
|
||||
@@ -836,7 +896,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result["tools"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
@@ -853,7 +913,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result["input"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
@@ -915,7 +975,7 @@ mod tests {
|
||||
"max_tokens": 4096,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["max_output_tokens"], 4096);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
@@ -929,7 +989,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -943,7 +1003,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -956,7 +1016,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -969,7 +1029,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "medium");
|
||||
}
|
||||
|
||||
@@ -982,7 +1042,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
@@ -995,7 +1055,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
@@ -1008,7 +1068,271 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
// ==================== Codex OAuth (ChatGPT 反代) 协议约束 ====================
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_sets_store_and_include() {
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
// store 必须显式为 false(ChatGPT 后端拒绝 true)
|
||||
assert_eq!(result["store"], json!(false));
|
||||
|
||||
// include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning)
|
||||
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_non_codex_omits_store_and_include() {
|
||||
// 回归护栏:is_codex_oauth=false 时,行为必须与今日字节级一致
|
||||
// —— 不写 store、不写 include,OpenRouter / Azure / OpenAI 付费 API 路径不受影响
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert!(result.get("store").is_none());
|
||||
assert!(result.get("include").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_preserves_existing_include() {
|
||||
// 客户端预置了 include:union 保留原有项 + 添加 marker,不重复
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"include": ["something.else", "reasoning.encrypted_content"]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let includes = result["include"]
|
||||
.as_array()
|
||||
.expect("include should be array");
|
||||
|
||||
// 原有项必须保留
|
||||
assert!(includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some("something.else")));
|
||||
// marker 必须存在
|
||||
assert!(includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some("reasoning.encrypted_content")));
|
||||
// 不重复:marker 只出现一次
|
||||
let marker_count = includes
|
||||
.iter()
|
||||
.filter(|v| v.as_str() == Some("reasoning.encrypted_content"))
|
||||
.count();
|
||||
assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
|
||||
// ChatGPT Plus/Pro 反代不接受 max_output_tokens(OpenAI 官方 codex-rs 的
|
||||
// ResponsesApiRequest 结构体里也没有这个字段),必须删除,否则服务端 400:
|
||||
// "Unsupported parameter: max_output_tokens"
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("max_output_tokens").is_none(),
|
||||
"Codex OAuth 路径必须删除 max_output_tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_non_codex_keeps_max_output_tokens() {
|
||||
// 回归护栏:非 Codex OAuth 路径必须保留 max_output_tokens
|
||||
// —— OpenAI 付费 Responses API / Azure 等仍然依赖这个字段
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert_eq!(result["max_output_tokens"], json!(1024));
|
||||
}
|
||||
|
||||
// ==================== 第二轮:P0 + P1 字段对齐 ====================
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_strips_temperature() {
|
||||
// P0: ChatGPT 反代不接受 temperature
|
||||
// 依据:OpenAI 官方 codex-rs 的 ResponsesApiRequest 结构体根本没有这个字段
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("temperature").is_none(),
|
||||
"Codex OAuth 路径必须删除 temperature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_strips_top_p() {
|
||||
// P0: ChatGPT 反代不接受 top_p
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"top_p": 0.9,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("top_p").is_none(),
|
||||
"Codex OAuth 路径必须删除 top_p"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_defaults_required_fields_when_absent() {
|
||||
// P1: 极简输入(无 system / 无 tools / 无 stream),断言四个必填字段都被注入默认值
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
json!(""),
|
||||
"instructions 缺失时应兜底为空字符串"
|
||||
);
|
||||
assert_eq!(result["tools"], json!([]), "tools 缺失时应兜底为空数组");
|
||||
assert_eq!(
|
||||
result["parallel_tool_calls"],
|
||||
json!(false),
|
||||
"parallel_tool_calls 应兜底为 false"
|
||||
);
|
||||
assert_eq!(result["stream"], json!(true), "stream 应被强制设为 true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_preserves_existing_instructions_and_tools() {
|
||||
// P1: 客户端送了 system 和 tools,应保留原值,不被默认值覆盖
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant",
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}}
|
||||
}
|
||||
}],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
json!("You are a helpful assistant"),
|
||||
"client 已送的 instructions 必须保留"
|
||||
);
|
||||
|
||||
let tools = result["tools"].as_array().expect("tools 应为数组");
|
||||
assert_eq!(tools.len(), 1, "client 已送的 tools 必须保留");
|
||||
assert_eq!(tools[0]["name"], json!("get_weather"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_forces_stream_true_even_when_client_sends_false() {
|
||||
// 即使客户端误传 stream:false,也要强制覆盖为 true
|
||||
// 依据:cc-switch SSE 解析层只支持流式响应
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"stream": false,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["stream"],
|
||||
json!(true),
|
||||
"Codex OAuth 路径下 stream 必须强制为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_codex_keeps_temperature_and_top_p() {
|
||||
// 回归护栏:非 Codex OAuth 路径必须保留 temperature/top_p
|
||||
// —— 防止 P0 删除逻辑误扩散到 OpenRouter / Azure / 付费 OpenAI 路径
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert_eq!(result["temperature"], json!(0.7));
|
||||
assert_eq!(result["top_p"], json!(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_codex_does_not_inject_default_required_fields() {
|
||||
// 回归护栏:非 Codex OAuth 路径不应被 P1 默认值污染
|
||||
// —— OpenRouter / Azure / 付费 OpenAI 等保持原有"条件写入"语义
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
"非 Codex OAuth 路径不应注入 parallel_tool_calls"
|
||||
);
|
||||
assert!(
|
||||
result.get("stream").is_none(),
|
||||
"非 Codex OAuth 路径不应注入 stream"
|
||||
);
|
||||
// instructions 和 tools 因为客户端没送,所以不应出现
|
||||
assert!(
|
||||
result.get("instructions").is_none(),
|
||||
"非 Codex OAuth 路径下 instructions 在客户端未送时不应被注入"
|
||||
);
|
||||
assert!(
|
||||
result.get("tools").is_none(),
|
||||
"非 Codex OAuth 路径下 tools 在客户端未送时不应被注入"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,8 +357,16 @@ impl StreamCheckService {
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记,
|
||||
// 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。
|
||||
let is_codex_oauth = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id))
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_openai_chat {
|
||||
anthropic_to_openai(anthropic_body, Some(&provider.id))
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
|
||||
import { CopilotAuthSection } from "./CopilotAuthSection";
|
||||
import { CodexOAuthSection } from "./CodexOAuthSection";
|
||||
import {
|
||||
copilotGetModels,
|
||||
copilotGetModelsForAccount,
|
||||
@@ -70,6 +71,12 @@ interface ClaudeFormFieldsProps {
|
||||
/** GitHub 账号选择回调(多账号支持) */
|
||||
onGitHubAccountSelect?: (accountId: string | null) => void;
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro)
|
||||
isCodexOauthPreset?: boolean;
|
||||
isCodexOauthAuthenticated?: boolean;
|
||||
selectedCodexAccountId?: string | null;
|
||||
onCodexAccountSelect?: (accountId: string | null) => void;
|
||||
|
||||
// Template Values
|
||||
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
||||
templateValues: Record<string, TemplateValueConfig>;
|
||||
@@ -134,6 +141,9 @@ export function ClaudeFormFields({
|
||||
isCopilotAuthenticated,
|
||||
selectedGitHubAccountId,
|
||||
onGitHubAccountSelect,
|
||||
isCodexOauthPreset,
|
||||
selectedCodexAccountId,
|
||||
onCodexAccountSelect,
|
||||
templateValueEntries,
|
||||
templateValues,
|
||||
templatePresetName,
|
||||
@@ -357,6 +367,14 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex OAuth 认证 (ChatGPT Plus/Pro) */}
|
||||
{isCodexOauthPreset && (
|
||||
<CodexOAuthSection
|
||||
selectedAccountId={selectedCodexAccountId}
|
||||
onAccountSelect={onCodexAccountSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API Key 输入框(非 OAuth 预设时显示) */}
|
||||
{shouldShowApiKey && !usesOAuth && (
|
||||
<ApiKeySection
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
LogOut,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
X,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
|
||||
interface CodexOAuthSectionProps {
|
||||
className?: string;
|
||||
/** 当前选中的 ChatGPT 账号 ID */
|
||||
selectedAccountId?: string | null;
|
||||
/** 账号选择回调 */
|
||||
onAccountSelect?: (accountId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex OAuth 认证区块
|
||||
*
|
||||
* 通过 OpenAI Device Code 流程登录 ChatGPT Plus/Pro 账号,
|
||||
* 用于将 Claude Code 请求反代到 Codex 后端 API。
|
||||
*/
|
||||
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
className,
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const {
|
||||
accounts,
|
||||
defaultAccountId,
|
||||
hasAnyAccount,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
error,
|
||||
isPolling,
|
||||
isAddingAccount,
|
||||
isRemovingAccount,
|
||||
isSettingDefaultAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
setDefaultAccount,
|
||||
cancelAuth,
|
||||
logout,
|
||||
} = useCodexOauth();
|
||||
|
||||
const copyUserCode = async () => {
|
||||
if (deviceCode?.user_code) {
|
||||
await copyText(deviceCode.user_code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountSelect = (value: string) => {
|
||||
onAccountSelect?.(value === "none" ? null : value);
|
||||
};
|
||||
|
||||
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
removeAccount(accountId);
|
||||
if (selectedAccountId === accountId) {
|
||||
onAccountSelect?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className || ""}`}>
|
||||
{/* 认证状态标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("codexOauth.authStatus", "ChatGPT Plus/Pro 认证")}</Label>
|
||||
<Badge
|
||||
variant={hasAnyAccount ? "default" : "secondary"}
|
||||
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
|
||||
>
|
||||
{hasAnyAccount
|
||||
? t("codexOauth.accountCount", {
|
||||
count: accounts.length,
|
||||
defaultValue: `${accounts.length} 个账号`,
|
||||
})
|
||||
: t("codexOauth.notAuthenticated", "未认证")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 账号选择器 */}
|
||||
{hasAnyAccount && onAccountSelect && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("codexOauth.selectAccount", "选择账号")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedAccountId || "none"}
|
||||
onValueChange={handleAccountSelect}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"codexOauth.selectAccountPlaceholder",
|
||||
"选择一个 ChatGPT 账号",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<span className="text-muted-foreground">
|
||||
{t("codexOauth.useDefaultAccount", "使用默认账号")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{account.login}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已登录账号列表 */}
|
||||
{hasAnyAccount && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("codexOauth.loggedInAccounts", "已登录账号")}
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{account.login}</span>
|
||||
{defaultAccountId === account.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("codexOauth.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountId === account.id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("codexOauth.selected", "已选中")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{defaultAccountId !== account.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setDefaultAccount(account.id)}
|
||||
disabled={isSettingDefaultAccount}
|
||||
>
|
||||
{t("codexOauth.setAsDefault", "设为默认")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||
disabled={isRemovingAccount}
|
||||
title={t("codexOauth.removeAccount", "移除账号")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未认证 - 登录按钮 */}
|
||||
{!hasAnyAccount && pollingState === "idle" && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t("codexOauth.loginWithChatGPT", "使用 ChatGPT 登录")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 已有账号 - 添加更多按钮 */}
|
||||
{hasAnyAccount && pollingState === "idle" && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isAddingAccount}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("codexOauth.addAnotherAccount", "添加其他账号")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 轮询中状态 */}
|
||||
{isPolling && deviceCode && (
|
||||
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("codexOauth.waitingForAuth", "等待授权中...")}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{t("codexOauth.enterCode", "在浏览器中输入以下代码:")}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border">
|
||||
{deviceCode.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={copyUserCode}
|
||||
title={t("codexOauth.copyCode", "复制代码")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href={deviceCode.verification_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline"
|
||||
>
|
||||
{deviceCode.verification_uri}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelAuth}
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{pollingState === "error" && error && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{t("codexOauth.retry", "重试")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={cancelAuth}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注销所有账号 */}
|
||||
{hasAnyAccount && accounts.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={logout}
|
||||
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("codexOauth.logoutAll", "注销所有账号")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexOAuthSection;
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
useOmoDraftState,
|
||||
useOpenclawFormState,
|
||||
useCopilotAuth,
|
||||
useCodexOauth,
|
||||
} from "./hooks";
|
||||
import {
|
||||
CLAUDE_DEFAULT_CONFIG,
|
||||
@@ -346,11 +347,19 @@ export function ProviderForm({
|
||||
// Copilot OAuth 认证状态(仅 Claude 应用需要)
|
||||
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
|
||||
|
||||
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
|
||||
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
|
||||
|
||||
// 选中的 GitHub 账号 ID(多账号支持)
|
||||
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
|
||||
string | null
|
||||
>(() => resolveManagedAccountId(initialData?.meta, "github_copilot"));
|
||||
|
||||
// 选中的 ChatGPT 账号 ID(Codex OAuth 多账号支持)
|
||||
const [selectedCodexAccountId, setSelectedCodexAccountId] = useState<
|
||||
string | null
|
||||
>(() => resolveManagedAccountId(initialData?.meta, "codex_oauth"));
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
@@ -782,6 +791,9 @@ export function ProviderForm({
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com");
|
||||
const isCodexOauthProvider =
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth";
|
||||
// GitHub Copilot 必须先登录才能添加
|
||||
if (isCopilotProvider && !isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
@@ -791,10 +803,19 @@ export function ProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Codex OAuth 必须先登录才能添加
|
||||
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
|
||||
toast.error(
|
||||
t("codexOauth.loginRequired", {
|
||||
defaultValue: "请先登录 ChatGPT 账号",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (category !== "official" && category !== "cloud_provider") {
|
||||
if (appId === "claude") {
|
||||
if (!baseUrl.trim()) {
|
||||
if (!isCodexOauthProvider && !baseUrl.trim()) {
|
||||
toast.error(
|
||||
t("providerForm.endpointRequired", {
|
||||
defaultValue: "非官方供应商请填写 API 端点",
|
||||
@@ -802,7 +823,7 @@ export function ProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isCopilotProvider && !apiKey.trim()) {
|
||||
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
|
||||
toast.error(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
@@ -1015,7 +1036,7 @@ export function ProviderForm({
|
||||
? useGeminiCommonConfigFlag
|
||||
: undefined,
|
||||
endpointAutoSelect,
|
||||
// 保存 providerType(用于识别 Copilot 等特殊供应商)
|
||||
// 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商)
|
||||
providerType,
|
||||
authBinding: isCopilotProvider
|
||||
? {
|
||||
@@ -1023,7 +1044,13 @@ export function ProviderForm({
|
||||
authProvider: "github_copilot",
|
||||
accountId: selectedGitHubAccountId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
: isCodexOauthProvider
|
||||
? {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: selectedCodexAccountId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
// GitHub Copilot 多账号:保存关联的账号 ID
|
||||
githubAccountId:
|
||||
isCopilotProvider && selectedGitHubAccountId
|
||||
@@ -1493,15 +1520,24 @@ export function ProviderForm({
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
isCodexOauthPreset={
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth"
|
||||
}
|
||||
usesOAuth={
|
||||
templatePreset?.requiresOAuth === true ||
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
baseUrl.includes("githubcopilot.com") ||
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth"
|
||||
}
|
||||
isCopilotAuthenticated={isCopilotAuthenticated}
|
||||
selectedGitHubAccountId={selectedGitHubAccountId}
|
||||
onGitHubAccountSelect={setSelectedGitHubAccountId}
|
||||
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
|
||||
selectedCodexAccountId={selectedCodexAccountId}
|
||||
onCodexAccountSelect={setSelectedCodexAccountId}
|
||||
templateValueEntries={templateValueEntries}
|
||||
templateValues={templateValues}
|
||||
templatePresetName={templatePreset?.name || ""}
|
||||
|
||||
@@ -18,3 +18,4 @@ export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||
export { useOmoDraftState } from "./useOmoDraftState";
|
||||
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||
export { useCopilotAuth } from "./useCopilotAuth";
|
||||
export { useCodexOauth } from "./useCodexOauth";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useManagedAuth } from "./useManagedAuth";
|
||||
|
||||
/**
|
||||
* Codex OAuth (ChatGPT Plus/Pro) 认证 hook
|
||||
*
|
||||
* 复用通用 useManagedAuth,仅指定 provider 为 "codex_oauth"
|
||||
*/
|
||||
export function useCodexOauth() {
|
||||
return useManagedAuth("codex_oauth");
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Github, ShieldCheck } from "lucide-react";
|
||||
import { Github, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
|
||||
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
|
||||
|
||||
export function AuthCenterPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -50,6 +51,25 @@ export function AuthCenterPanel() {
|
||||
|
||||
<CopilotAuthSection />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">ChatGPT (Codex OAuth)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.authCenter.codexOauthDescription", {
|
||||
defaultValue:
|
||||
"管理 ChatGPT Plus/Pro 账号,用于将 Claude Code 请求反代到 Codex 后端。仅限个人开发使用。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodexOAuthSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ export interface ProviderPreset {
|
||||
|
||||
// 供应商类型标识(用于特殊供应商检测)
|
||||
// - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证)
|
||||
providerType?: "github_copilot";
|
||||
// - "codex_oauth": OpenAI Codex via ChatGPT Plus/Pro 反代(需要 OAuth 认证)
|
||||
providerType?: "github_copilot" | "codex_oauth";
|
||||
|
||||
// 是否需要 OAuth 认证(而非 API Key)
|
||||
requiresOAuth?: boolean;
|
||||
@@ -708,6 +709,27 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "github",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "Codex (ChatGPT Plus/Pro)",
|
||||
websiteUrl: "https://openai.com/chatgpt/pricing",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
// base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex
|
||||
// 用户无需配置
|
||||
ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex",
|
||||
ANTHROPIC_MODEL: "gpt-5.1-codex",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.1-codex-mini",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.1-codex",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.1-codex-max",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
apiFormat: "openai_responses",
|
||||
providerType: "codex_oauth",
|
||||
requiresOAuth: true,
|
||||
icon: "openai",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
websiteUrl: "https://build.nvidia.com",
|
||||
|
||||
@@ -845,6 +845,27 @@
|
||||
"migrationFailed": "Legacy auth migration failed: {{error}}",
|
||||
"loadModelsFailed": "Failed to load Copilot models"
|
||||
},
|
||||
"codexOauth": {
|
||||
"authStatus": "ChatGPT Plus/Pro Auth",
|
||||
"notAuthenticated": "Not authenticated",
|
||||
"loginWithChatGPT": "Sign in with ChatGPT",
|
||||
"loginRequired": "Please sign in to ChatGPT first",
|
||||
"waitingForAuth": "Waiting for authorization...",
|
||||
"enterCode": "Enter the code in your browser:",
|
||||
"accountCount": "{{count}} account(s)",
|
||||
"selectAccount": "Select account",
|
||||
"selectAccountPlaceholder": "Choose a ChatGPT account",
|
||||
"useDefaultAccount": "Use default account",
|
||||
"loggedInAccounts": "Logged in accounts",
|
||||
"defaultAccount": "Default",
|
||||
"selected": "Selected",
|
||||
"removeAccount": "Remove account",
|
||||
"setAsDefault": "Set as default",
|
||||
"addAnotherAccount": "Add another account",
|
||||
"logoutAll": "Logout all accounts",
|
||||
"retry": "Retry",
|
||||
"copyCode": "Copy code"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "API Endpoint Management",
|
||||
"endpoints": "endpoints",
|
||||
|
||||
@@ -845,6 +845,27 @@
|
||||
"migrationFailed": "旧認証データの移行に失敗しました: {{error}}",
|
||||
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました"
|
||||
},
|
||||
"codexOauth": {
|
||||
"authStatus": "ChatGPT Plus/Pro 認証",
|
||||
"notAuthenticated": "未認証",
|
||||
"loginWithChatGPT": "ChatGPT でログイン",
|
||||
"loginRequired": "先に ChatGPT にログインしてください",
|
||||
"waitingForAuth": "認証を待機中...",
|
||||
"enterCode": "ブラウザで以下のコードを入力してください:",
|
||||
"accountCount": "{{count}} アカウント",
|
||||
"selectAccount": "アカウントを選択",
|
||||
"selectAccountPlaceholder": "ChatGPT アカウントを選択",
|
||||
"useDefaultAccount": "デフォルトアカウントを使用",
|
||||
"loggedInAccounts": "ログイン済みアカウント",
|
||||
"defaultAccount": "デフォルト",
|
||||
"selected": "選択中",
|
||||
"removeAccount": "アカウントを削除",
|
||||
"setAsDefault": "デフォルトに設定",
|
||||
"addAnotherAccount": "別のアカウントを追加",
|
||||
"logoutAll": "すべてのアカウントをログアウト",
|
||||
"retry": "再試行",
|
||||
"copyCode": "コードをコピー"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "API エンドポイント管理",
|
||||
"endpoints": "エンドポイント",
|
||||
|
||||
@@ -845,6 +845,27 @@
|
||||
"migrationFailed": "旧认证数据迁移失败:{{error}}",
|
||||
"loadModelsFailed": "加载 Copilot 模型列表失败"
|
||||
},
|
||||
"codexOauth": {
|
||||
"authStatus": "ChatGPT Plus/Pro 认证",
|
||||
"notAuthenticated": "未认证",
|
||||
"loginWithChatGPT": "使用 ChatGPT 登录",
|
||||
"loginRequired": "请先登录 ChatGPT 账号",
|
||||
"waitingForAuth": "等待授权中...",
|
||||
"enterCode": "请在浏览器中输入以下验证码:",
|
||||
"accountCount": "{{count}} 个账号",
|
||||
"selectAccount": "选择账号",
|
||||
"selectAccountPlaceholder": "选择一个 ChatGPT 账号",
|
||||
"useDefaultAccount": "使用默认账号",
|
||||
"loggedInAccounts": "已登录账号",
|
||||
"defaultAccount": "默认",
|
||||
"selected": "已选中",
|
||||
"removeAccount": "移除账号",
|
||||
"setAsDefault": "设为默认",
|
||||
"addAnotherAccount": "添加其他账号",
|
||||
"logoutAll": "注销所有账号",
|
||||
"retry": "重试",
|
||||
"copyCode": "复制代码"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "请求地址管理",
|
||||
"endpoints": "个端点",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type ManagedAuthProvider = "github_copilot";
|
||||
export type ManagedAuthProvider = "github_copilot" | "codex_oauth";
|
||||
|
||||
export interface ManagedAuthAccount {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user