mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
feat(codex): route native ChatGPT sessions through proxy takeover
Allow the built-in Codex official provider to participate in takeover mode while preserving Codex's native OAuth or API-key credentials instead of persisting them into provider records. Project official routing into a dedicated TOML provider, normalize inline tables, clean stale managed placeholders, and fail closed when the live configuration cannot be transformed safely. Validate forwarded authorization, make official 401/403 responses non-retryable, avoid circuit-breaker pollution, and share the first-party ChatGPT endpoint across the Codex and Claude adapters.
This commit is contained in:
@@ -39,6 +39,22 @@ use tokio::sync::RwLock;
|
||||
|
||||
const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
fn validate_codex_official_authorization(headers: &http::HeaderMap) -> Result<(), ProxyError> {
|
||||
let authorization = headers
|
||||
.get(http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim);
|
||||
match authorization {
|
||||
None | Some("") => Err(ProxyError::AuthError(
|
||||
"Codex 官方登录不可用,请先在 Codex 中完成 ChatGPT 登录".to_string(),
|
||||
)),
|
||||
Some(value) if value.contains(PROXY_AUTH_PLACEHOLDER) => Err(ProxyError::AuthError(
|
||||
"已切换到 OpenAI 官方供应商,请重启 Codex 或新建会话以加载官方登录配置".to_string(),
|
||||
)),
|
||||
Some(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ForwardResult {
|
||||
pub response: ProxyResponse,
|
||||
pub provider: Provider,
|
||||
@@ -986,7 +1002,7 @@ impl RequestForwarder {
|
||||
// 先分类错误,决定是否计入 provider 健康度
|
||||
// —— NonRetryable / ClientAbort 是客户端层错误,无论换哪家 provider 都会被拒绝,
|
||||
// 不应污染熔断器和数据库健康度(与 release_permit_neutral 同语义)。
|
||||
let category = self.categorize_proxy_error(&e);
|
||||
let category = self.categorize_proxy_error(&e, provider);
|
||||
|
||||
match category {
|
||||
ErrorCategory::Retryable => {
|
||||
@@ -1130,6 +1146,12 @@ impl RequestForwarder {
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
|
||||
let codex_official_auth_passthrough = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::is_codex_official_provider(provider);
|
||||
|
||||
if codex_official_auth_passthrough {
|
||||
validate_codex_official_authorization(headers)?;
|
||||
}
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
|
||||
@@ -1840,6 +1862,17 @@ impl RequestForwarder {
|
||||
|| key_str.eq_ignore_ascii_case("x-api-key")
|
||||
|| key_str.eq_ignore_ascii_case("x-goog-api-key")
|
||||
{
|
||||
// The built-in Codex official provider deliberately has no
|
||||
// credential in CC Switch. `requires_openai_auth = true` makes
|
||||
// Codex send its native ChatGPT authorization, which must reach
|
||||
// the fixed official upstream unchanged. Other credential
|
||||
// headers are still discarded.
|
||||
if codex_official_auth_passthrough && key_str.eq_ignore_ascii_case("authorization")
|
||||
{
|
||||
saw_auth = true;
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
continue;
|
||||
}
|
||||
if !saw_auth {
|
||||
saw_auth = true;
|
||||
for (ah_name, ah_value) in &auth_headers {
|
||||
@@ -2494,7 +2527,23 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||
fn categorize_proxy_error(&self, error: &ProxyError, provider: &Provider) -> ErrorCategory {
|
||||
// Authentication belongs to the Codex client for the built-in official
|
||||
// route. Retrying another provider would silently move the conversation
|
||||
// away from the selected official account and poison its health state.
|
||||
if super::providers::is_codex_official_provider(provider)
|
||||
&& (matches!(error, ProxyError::AuthError(_))
|
||||
|| matches!(
|
||||
error,
|
||||
ProxyError::UpstreamError {
|
||||
status: 401 | 403,
|
||||
..
|
||||
}
|
||||
))
|
||||
{
|
||||
return ErrorCategory::NonRetryable;
|
||||
}
|
||||
|
||||
match error {
|
||||
// 网络和上游错误:都应该尝试下一个供应商
|
||||
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
||||
@@ -4197,14 +4246,53 @@ mod tests {
|
||||
#[test]
|
||||
fn invalid_client_history_is_not_retryable() {
|
||||
let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO);
|
||||
let provider = test_provider_with_type(None);
|
||||
assert_eq!(
|
||||
forwarder.categorize_proxy_error(&ProxyError::InvalidRequest(
|
||||
"invalid historical tool arguments".to_string()
|
||||
)),
|
||||
forwarder.categorize_proxy_error(
|
||||
&ProxyError::InvalidRequest("invalid historical tool arguments".to_string()),
|
||||
&provider,
|
||||
),
|
||||
ErrorCategory::NonRetryable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_codex_auth_failures_are_not_retryable() {
|
||||
let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO);
|
||||
let mut provider = test_provider_with_type(None);
|
||||
provider.id = "codex-official".to_string();
|
||||
provider.category = Some("official".to_string());
|
||||
|
||||
for error in [
|
||||
ProxyError::AuthError("restart Codex".to_string()),
|
||||
ProxyError::UpstreamError {
|
||||
status: 401,
|
||||
body: None,
|
||||
},
|
||||
ProxyError::UpstreamError {
|
||||
status: 403,
|
||||
body: None,
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
forwarder.categorize_proxy_error(&error, &provider),
|
||||
ErrorCategory::NonRetryable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_codex_rejects_stale_proxy_placeholder_with_restart_hint() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer PROXY_MANAGED"),
|
||||
);
|
||||
let error = validate_codex_official_authorization(&headers)
|
||||
.expect_err("stale placeholder must be rejected");
|
||||
assert!(matches!(error, ProxyError::AuthError(message) if message.contains("重启 Codex")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() {
|
||||
let (endpoint, passthrough_query) =
|
||||
|
||||
@@ -666,7 +666,7 @@ 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());
|
||||
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
|
||||
}
|
||||
|
||||
// 1. 从 env 中获取
|
||||
@@ -778,9 +778,9 @@ 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" {
|
||||
if base_url == super::CHATGPT_CODEX_BASE_URL {
|
||||
let _ = endpoint; // 忽略原始 endpoint
|
||||
return "https://chatgpt.com/backend-api/codex/responses".to_string();
|
||||
return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL);
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
|
||||
@@ -206,6 +206,14 @@ pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint
|
||||
) && codex_provider_uses_anthropic(provider)
|
||||
}
|
||||
|
||||
/// The single built-in official Codex provider. Unlike managed Codex OAuth
|
||||
/// providers used by Claude, this route receives authentication from the
|
||||
/// calling Codex client (`requires_openai_auth = true`).
|
||||
pub fn is_codex_official_provider(provider: &Provider) -> bool {
|
||||
provider.id == crate::database::CODEX_OFFICIAL_PROVIDER_ID
|
||||
&& provider.category.as_deref() == Some("official")
|
||||
}
|
||||
|
||||
/// Resolve the model-catalog tool profile for a Codex provider using the SAME
|
||||
/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the
|
||||
/// generated catalog never disagrees with the routed transform. A provider whose
|
||||
@@ -217,6 +225,9 @@ pub fn resolve_codex_catalog_tool_profile(
|
||||
provider: &Provider,
|
||||
) -> crate::codex_config::CodexCatalogToolProfile {
|
||||
use crate::codex_config::CodexCatalogToolProfile;
|
||||
if is_codex_official_provider(provider) {
|
||||
return CodexCatalogToolProfile::NativeResponses;
|
||||
}
|
||||
if codex_provider_uses_anthropic(provider) {
|
||||
return CodexCatalogToolProfile::Anthropic;
|
||||
}
|
||||
@@ -634,6 +645,10 @@ impl ProviderAdapter for CodexAdapter {
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
if is_codex_official_provider(provider) {
|
||||
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
|
||||
}
|
||||
|
||||
// 1. 尝试直接获取 base_url 字段
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
@@ -781,6 +796,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
|
||||
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
|
||||
provider.id = "codex-official".to_string();
|
||||
provider.category = Some("official".to_string());
|
||||
let adapter = CodexAdapter::new();
|
||||
|
||||
assert!(is_codex_official_provider(&provider));
|
||||
assert_eq!(
|
||||
adapter
|
||||
.extract_base_url(&provider)
|
||||
.expect("official base url"),
|
||||
"https://chatgpt.com/backend-api/codex"
|
||||
);
|
||||
assert!(adapter.extract_auth(&provider).is_none());
|
||||
assert_eq!(
|
||||
adapter.build_url(
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
"/responses/compact"
|
||||
),
|
||||
"https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_routing_auto_enables_known_upstreams_only() {
|
||||
let kimi = create_provider(json!({
|
||||
|
||||
@@ -41,6 +41,8 @@ use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
|
||||
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
@@ -52,9 +54,9 @@ pub use claude::{
|
||||
pub use codex::CodexAdapter;
|
||||
pub use codex::{
|
||||
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
|
||||
inject_codex_chat_prompt_cache_key, resolve_codex_catalog_tool_profile,
|
||||
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_anthropic,
|
||||
should_convert_codex_responses_to_chat,
|
||||
inject_codex_chat_prompt_cache_key, is_codex_official_provider,
|
||||
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
|
||||
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
|
||||
};
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
@@ -110,7 +112,7 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
|
||||
ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user