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:
Jason
2026-07-12 17:51:56 +08:00
parent 13e7c1fcc4
commit 51d6c458ff
11 changed files with 835 additions and 179 deletions
+93 -5
View File
@@ -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) =
+3 -3
View File
@@ -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:
+39
View File
@@ -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!({
+6 -4
View File
@@ -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,
}
}