mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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:
@@ -12,7 +12,13 @@ use std::process::Command;
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
|
||||
/// Temporary model-provider id used while the built-in `codex-official`
|
||||
/// provider is routed through CC Switch. A dedicated id is an ownership
|
||||
/// marker: unlike a generic localhost `base_url`, it can be detected and
|
||||
/// cleaned up without mistaking a user's own local provider for takeover.
|
||||
pub const CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID: &str = "cc-switch-official";
|
||||
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
|
||||
const CODEX_PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
/// Top-level `config.toml` key that controls Codex's built-in web-search tool.
|
||||
pub(crate) const CODEX_WEB_SEARCH_FIELD: &str = "web_search";
|
||||
@@ -1338,15 +1344,139 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
|
||||
/// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates
|
||||
/// (web search, remote compaction), and `supports_websockets` restores the
|
||||
/// built-in default that custom entries otherwise lose.
|
||||
fn codex_unified_official_provider_table() -> toml_edit::Table {
|
||||
fn codex_official_provider_table(
|
||||
base_url: Option<&str>,
|
||||
supports_websockets: bool,
|
||||
) -> toml_edit::Table {
|
||||
let mut table = toml_edit::Table::new();
|
||||
table["name"] = toml_edit::value("OpenAI");
|
||||
table["requires_openai_auth"] = toml_edit::value(true);
|
||||
table["supports_websockets"] = toml_edit::value(true);
|
||||
table["supports_websockets"] = toml_edit::value(supports_websockets);
|
||||
table["wire_api"] = toml_edit::value("responses");
|
||||
if let Some(base_url) = base_url {
|
||||
table["base_url"] = toml_edit::value(base_url.trim_end_matches('/'));
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
fn codex_unified_official_provider_table() -> toml_edit::Table {
|
||||
codex_official_provider_table(None, true)
|
||||
}
|
||||
|
||||
fn remove_codex_proxy_placeholders_from_providers(providers: &mut toml_edit::Table) {
|
||||
for (_, item) in providers.iter_mut() {
|
||||
if let Some(table) = item.as_table_mut() {
|
||||
let should_remove = table
|
||||
.get("experimental_bearer_token")
|
||||
.and_then(|item| item.as_str())
|
||||
== Some(CODEX_PROXY_AUTH_PLACEHOLDER);
|
||||
if should_remove {
|
||||
table.remove("experimental_bearer_token");
|
||||
}
|
||||
} else if let Some(table) = item.as_inline_table_mut() {
|
||||
let should_remove = table
|
||||
.get("experimental_bearer_token")
|
||||
.and_then(|value| value.as_str())
|
||||
== Some(CODEX_PROXY_AUTH_PLACEHOLDER);
|
||||
if should_remove {
|
||||
table.remove("experimental_bearer_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project the built-in Codex official provider through the local proxy while
|
||||
/// keeping authentication owned by Codex itself.
|
||||
///
|
||||
/// The resulting custom provider explicitly opts into OpenAI authentication,
|
||||
/// so Codex forwards its existing ChatGPT login to the local `/responses`
|
||||
/// endpoint. No API key or bearer placeholder is written to `auth.json`.
|
||||
pub fn apply_codex_official_proxy_route(
|
||||
config_text: &str,
|
||||
proxy_base_url: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||
|
||||
// A third-party takeover may have left the proxy placeholder in config.toml.
|
||||
// The official route must use Codex's native OpenAI login instead.
|
||||
doc.as_table_mut().remove("experimental_bearer_token");
|
||||
doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID);
|
||||
|
||||
let mut providers = match doc.as_table_mut().remove("model_providers") {
|
||||
Some(item) => item.into_table().map_err(|_| {
|
||||
AppError::Message(
|
||||
"Invalid Codex config.toml: model_providers must be a table".to_string(),
|
||||
)
|
||||
})?,
|
||||
None => {
|
||||
let mut table = toml_edit::Table::new();
|
||||
table.set_implicit(true);
|
||||
table
|
||||
}
|
||||
};
|
||||
|
||||
// Clean only CC Switch's placeholder from every stale provider table. Real
|
||||
// user bearer tokens are preserved, as are all unrelated provider fields.
|
||||
remove_codex_proxy_placeholders_from_providers(&mut providers);
|
||||
|
||||
// The local proxy currently exposes HTTP/SSE, not Codex websocket routes.
|
||||
let table = codex_official_provider_table(Some(proxy_base_url), false);
|
||||
|
||||
providers.insert(
|
||||
CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID,
|
||||
toml_edit::Item::Table(table),
|
||||
);
|
||||
doc["model_providers"] = toml_edit::Item::Table(providers);
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Whether a live Codex config is the official route projected by CC Switch.
|
||||
pub fn codex_config_has_official_proxy_route(config_text: &str) -> bool {
|
||||
if !config_text.contains(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) {
|
||||
return false;
|
||||
}
|
||||
config_text
|
||||
.parse::<DocumentMut>()
|
||||
.ok()
|
||||
.and_then(|doc| {
|
||||
doc.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.as_deref()
|
||||
== Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID)
|
||||
}
|
||||
|
||||
/// Remove only the official takeover route owned by CC Switch. This is a
|
||||
/// last-resort crash cleanup when no live backup or provider SSOT is usable.
|
||||
pub fn remove_codex_official_proxy_route(config_text: &str) -> Result<String, AppError> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||
if doc.get("model_provider").and_then(|item| item.as_str())
|
||||
!= Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID)
|
||||
{
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
doc.as_table_mut().remove("model_provider");
|
||||
if let Some(item) = doc.as_table_mut().remove("model_providers") {
|
||||
let mut providers = item.into_table().map_err(|_| {
|
||||
AppError::Message(
|
||||
"Invalid Codex config.toml: model_providers must be a table".to_string(),
|
||||
)
|
||||
})?;
|
||||
providers.remove(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID);
|
||||
remove_codex_proxy_placeholders_from_providers(&mut providers);
|
||||
if !providers.is_empty() {
|
||||
doc["model_providers"] = toml_edit::Item::Table(providers);
|
||||
}
|
||||
}
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool {
|
||||
table.len() == 4
|
||||
&& table.get("name").and_then(|item| item.as_str()) == Some("OpenAI")
|
||||
@@ -1812,6 +1942,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_proxy_route_uses_native_auth_and_local_responses_provider() {
|
||||
let input = r#"model = "gpt-5.4"
|
||||
experimental_bearer_token = "PROXY_MANAGED"
|
||||
|
||||
[mcp_servers.example]
|
||||
command = "example"
|
||||
"#;
|
||||
let output = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1")
|
||||
.expect("apply official proxy route");
|
||||
let doc: toml::Value = toml::from_str(&output).expect("parse output");
|
||||
|
||||
assert_eq!(
|
||||
doc.get("model_provider").and_then(toml::Value::as_str),
|
||||
Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID)
|
||||
);
|
||||
assert!(doc.get("experimental_bearer_token").is_none());
|
||||
assert!(
|
||||
doc.get("mcp_servers").is_some(),
|
||||
"unrelated config survives"
|
||||
);
|
||||
|
||||
let provider = &doc["model_providers"][CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID];
|
||||
assert_eq!(
|
||||
provider.get("base_url").and_then(toml::Value::as_str),
|
||||
Some("http://127.0.0.1:15721/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.get("requires_openai_auth")
|
||||
.and_then(toml::Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.get("supports_websockets")
|
||||
.and_then(toml::Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
assert!(codex_config_has_official_proxy_route(&output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_proxy_route_cleanup_only_removes_owned_provider() {
|
||||
let projected =
|
||||
apply_codex_official_proxy_route("model = \"gpt-5.4\"\n", "http://127.0.0.1:15721/v1")
|
||||
.expect("project");
|
||||
let cleaned = remove_codex_official_proxy_route(&projected).expect("clean");
|
||||
let doc: toml::Value = toml::from_str(&cleaned).expect("parse cleaned");
|
||||
assert!(doc.get("model_provider").is_none());
|
||||
assert!(doc.get("model_providers").is_none());
|
||||
assert_eq!(
|
||||
doc.get("model").and_then(toml::Value::as_str),
|
||||
Some("gpt-5.4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_proxy_route_rejects_non_table_model_providers_without_panicking() {
|
||||
for input in [
|
||||
"model_providers = 3\n",
|
||||
"[[model_providers]]\nname = \"broken\"\n",
|
||||
] {
|
||||
let result = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_proxy_route_normalizes_inline_tables_and_cleans_stale_placeholder() {
|
||||
let input = r#"model_provider = "rightcode"
|
||||
model_providers = { rightcode = { name = "RightCode", experimental_bearer_token = "PROXY_MANAGED" } }
|
||||
"#;
|
||||
let projected = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1")
|
||||
.expect("project inline provider table");
|
||||
let projected_doc: toml::Value = toml::from_str(&projected).expect("parse projected");
|
||||
assert!(projected_doc["model_providers"]["rightcode"]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert!(projected_doc["model_providers"]
|
||||
.get(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID)
|
||||
.is_some());
|
||||
|
||||
let cleaned = remove_codex_official_proxy_route(&projected).expect("clean projected");
|
||||
let cleaned_doc: toml::Value = toml::from_str(&cleaned).expect("parse cleaned");
|
||||
assert!(cleaned_doc.get("model_provider").is_none());
|
||||
assert!(cleaned_doc["model_providers"].get("rightcode").is_some());
|
||||
assert!(cleaned_doc["model_providers"]
|
||||
.get(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_session_bucket_preserves_other_keys_and_explicit_routing() {
|
||||
let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n";
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
|
||||
#[tauri::command]
|
||||
@@ -279,13 +280,18 @@ pub async fn switch_proxy_provider(
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
// Block official providers during proxy takeover
|
||||
// Codex's built-in official provider can use the client's native OpenAI
|
||||
// login through takeover. Other official providers remain blocked.
|
||||
let provider = state
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, &app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
let app = crate::app_config::AppType::from_str(&app_type)
|
||||
.map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||
if provider.category.as_deref() == Some("official")
|
||||
&& !crate::services::provider::official_provider_supports_proxy_takeover(&app, &provider)
|
||||
{
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use crate::app_config::AppType;
|
||||
|
||||
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
|
||||
pub(crate) const CODEX_OFFICIAL_PROVIDER_ID: &str = "codex-official";
|
||||
|
||||
/// 单条官方供应商种子定义。
|
||||
pub(crate) struct OfficialProviderSeed {
|
||||
@@ -49,7 +50,7 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
settings_config_json: r#"{"env":{}}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: "codex-official",
|
||||
id: CODEX_OFFICIAL_PROVIDER_ID,
|
||||
app_type: AppType::Codex,
|
||||
name: "OpenAI Official",
|
||||
website_url: "https://chatgpt.com/codex",
|
||||
|
||||
@@ -32,7 +32,9 @@ mod schema;
|
||||
mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub(crate) use dao::providers_seed::{is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID};
|
||||
pub(crate) use dao::providers_seed::{
|
||||
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
|
||||
};
|
||||
pub(crate) use dao::proxy::{
|
||||
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
|
||||
PRICING_SOURCE_RESPONSE,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ use live::{
|
||||
};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// The built-in Codex official provider is safe to select during takeover:
|
||||
/// Codex keeps ownership of its ChatGPT login and the proxy only forwards the
|
||||
/// authenticated request. Other official providers retain the existing block.
|
||||
pub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: &Provider) -> bool {
|
||||
matches!(app_type, AppType::Codex)
|
||||
&& crate::proxy::providers::is_codex_official_provider(provider)
|
||||
}
|
||||
|
||||
/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的
|
||||
/// live 配置,使开关即时生效(无需等下一次切换)。
|
||||
/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,
|
||||
@@ -2394,7 +2402,10 @@ impl ProviderService {
|
||||
|
||||
// Block switching to official providers when proxy takeover is active.
|
||||
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
|
||||
if should_hot_switch && _provider.category.as_deref() == Some("official") {
|
||||
if should_hot_switch
|
||||
&& _provider.category.as_deref() == Some("official")
|
||||
&& !official_provider_supports_proxy_takeover(&app_type, _provider)
|
||||
{
|
||||
return Err(AppError::localized(
|
||||
"switch.official_blocked_by_proxy",
|
||||
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
|
||||
|
||||
+426
-145
@@ -374,29 +374,11 @@ impl ProxyService {
|
||||
}
|
||||
let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;
|
||||
|
||||
if let Some(auth) = effective_settings
|
||||
.get_mut("auth")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
} else if let Some(root) = effective_settings.as_object_mut() {
|
||||
root.insert(
|
||||
"auth".to_string(),
|
||||
json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }),
|
||||
);
|
||||
}
|
||||
|
||||
let config_str = effective_settings
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let updated_config = Self::apply_codex_proxy_toml_config_for_provider(
|
||||
config_str,
|
||||
Self::apply_codex_takeover_fields_for_provider(
|
||||
&mut effective_settings,
|
||||
&proxy_codex_base_url,
|
||||
Some(provider),
|
||||
);
|
||||
effective_settings["config"] = json!(updated_config);
|
||||
Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));
|
||||
provider,
|
||||
)?;
|
||||
|
||||
self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?;
|
||||
Ok(())
|
||||
@@ -738,7 +720,11 @@ impl ProxyService {
|
||||
crate::settings::get_effective_current_provider(&self.db, &app)
|
||||
{
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) {
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
if provider.category.as_deref() == Some("official")
|
||||
&& !crate::services::provider::official_provider_supports_proxy_takeover(
|
||||
&app, &provider,
|
||||
)
|
||||
{
|
||||
if let Some(handle) = self.app_handle.read().await.as_ref() {
|
||||
let _ = handle.emit(
|
||||
"proxy-official-warning",
|
||||
@@ -984,6 +970,12 @@ impl ProxyService {
|
||||
if let Ok(Some(mut provider)) =
|
||||
self.db.get_provider_by_id(&provider_id, "codex")
|
||||
{
|
||||
// The built-in official row is a routing capability, not
|
||||
// a credential store. Its auth must remain empty even
|
||||
// when the live Codex login uses OPENAI_API_KEY mode.
|
||||
if crate::proxy::providers::is_codex_official_provider(&provider) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(token) = live_config
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
@@ -1361,34 +1353,16 @@ impl ProxyService {
|
||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
|
||||
// Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)
|
||||
// Codex: project the selected provider through the local Responses endpoint.
|
||||
if let Ok(mut live_config) = self.read_codex_live() {
|
||||
// 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)
|
||||
if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) {
|
||||
auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
}
|
||||
|
||||
// 2. 修改 config.toml 中的 base_url
|
||||
let config_str = live_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let codex_provider = self
|
||||
.get_current_provider_for_app(&AppType::Codex)
|
||||
.ok()
|
||||
.flatten();
|
||||
let updated_config = Self::apply_codex_proxy_toml_config_for_provider(
|
||||
config_str,
|
||||
&proxy_codex_base_url,
|
||||
codex_provider.as_ref(),
|
||||
);
|
||||
live_config["config"] = json!(updated_config);
|
||||
Self::attach_codex_model_catalog_from_provider(
|
||||
let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;
|
||||
Self::apply_codex_takeover_fields_for_provider(
|
||||
&mut live_config,
|
||||
codex_provider.as_ref(),
|
||||
);
|
||||
&proxy_codex_base_url,
|
||||
&codex_provider,
|
||||
)?;
|
||||
|
||||
self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?;
|
||||
self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;
|
||||
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
||||
}
|
||||
|
||||
@@ -1431,26 +1405,12 @@ impl ProxyService {
|
||||
}
|
||||
AppType::Codex => {
|
||||
let mut live_config = self.read_codex_live()?;
|
||||
|
||||
if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) {
|
||||
auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
}
|
||||
|
||||
let config_str = live_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;
|
||||
let updated_config = Self::apply_codex_proxy_toml_config_for_provider(
|
||||
config_str,
|
||||
&proxy_codex_base_url,
|
||||
Some(&codex_provider),
|
||||
);
|
||||
live_config["config"] = json!(updated_config);
|
||||
Self::attach_codex_model_catalog_from_provider(
|
||||
Self::apply_codex_takeover_fields_for_provider(
|
||||
&mut live_config,
|
||||
Some(&codex_provider),
|
||||
);
|
||||
&proxy_codex_base_url,
|
||||
&codex_provider,
|
||||
)?;
|
||||
|
||||
self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;
|
||||
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
||||
@@ -1507,34 +1467,17 @@ impl ProxyService {
|
||||
}
|
||||
AppType::Codex => {
|
||||
if let Ok(mut live_config) = self.read_codex_live() {
|
||||
if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut())
|
||||
{
|
||||
auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
}
|
||||
|
||||
let config_str = live_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let codex_provider = self
|
||||
.get_current_provider_for_app(&AppType::Codex)
|
||||
.ok()
|
||||
.flatten();
|
||||
let updated_config = Self::apply_codex_proxy_toml_config_for_provider(
|
||||
config_str,
|
||||
&proxy_codex_base_url,
|
||||
codex_provider.as_ref(),
|
||||
);
|
||||
live_config["config"] = json!(updated_config);
|
||||
Self::attach_codex_model_catalog_from_provider(
|
||||
let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;
|
||||
Self::apply_codex_takeover_fields_for_provider(
|
||||
&mut live_config,
|
||||
codex_provider.as_ref(),
|
||||
);
|
||||
&proxy_codex_base_url,
|
||||
&codex_provider,
|
||||
)?;
|
||||
|
||||
let _ = self.write_codex_takeover_live_for_provider(
|
||||
self.write_codex_takeover_live_for_provider(
|
||||
&live_config,
|
||||
codex_provider.as_ref(),
|
||||
);
|
||||
Some(&codex_provider),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
AppType::Gemini => {
|
||||
@@ -1831,7 +1774,7 @@ impl ProxyService {
|
||||
Self::proxy_urls_match(url, &proxy_codex_base_url)
|
||||
})
|
||||
});
|
||||
Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches)
|
||||
Ok(Self::is_codex_live_taken_over(&config) && base_url_matches)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let config = self.read_gemini_live()?;
|
||||
@@ -1894,6 +1837,8 @@ impl ProxyService {
|
||||
token == PROXY_TOKEN_PLACEHOLDER
|
||||
})
|
||||
.map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?;
|
||||
let updated = crate::codex_config::remove_codex_official_proxy_route(&updated)
|
||||
.map_err(|e| format!("清理 Codex 官方接管路由失败: {e}"))?;
|
||||
config["config"] = json!(updated);
|
||||
}
|
||||
|
||||
@@ -2027,6 +1972,10 @@ impl ProxyService {
|
||||
|
||||
fn is_codex_live_taken_over(config: &Value) -> bool {
|
||||
Self::codex_live_has_proxy_placeholder(config)
|
||||
|| config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(crate::codex_config::codex_config_has_official_proxy_route)
|
||||
}
|
||||
|
||||
fn is_gemini_live_taken_over(config: &Value) -> bool {
|
||||
@@ -2046,7 +1995,7 @@ impl ProxyService {
|
||||
fn live_has_proxy_placeholder_for_app(app_type: &AppType, config: &Value) -> bool {
|
||||
match app_type {
|
||||
AppType::Claude => Self::is_claude_live_taken_over(config),
|
||||
AppType::Codex => Self::codex_live_has_proxy_placeholder(config),
|
||||
AppType::Codex => Self::is_codex_live_taken_over(config),
|
||||
AppType::Gemini => Self::is_gemini_live_taken_over(config),
|
||||
_ => false,
|
||||
}
|
||||
@@ -2089,13 +2038,24 @@ impl ProxyService {
|
||||
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))
|
||||
})
|
||||
.transpose()?;
|
||||
// A stale takeover marker can survive without its DB backup (for
|
||||
// example after a partial cleanup). In that abnormal state the live
|
||||
// auth file is still the only copy of the user's login, so use it as
|
||||
// the preservation source instead of replacing it with the empty
|
||||
// official seed snapshot.
|
||||
let existing_backup_value =
|
||||
existing_backup_value.or_else(|| self.read_codex_live().ok());
|
||||
|
||||
if let Some(existing_value) = existing_backup_value.as_ref() {
|
||||
Self::preserve_codex_mcp_servers_from_existing_config(
|
||||
&mut effective_settings,
|
||||
existing_value,
|
||||
)?;
|
||||
Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;
|
||||
Self::preserve_codex_auth_in_backup(
|
||||
&mut effective_settings,
|
||||
existing_value,
|
||||
crate::proxy::providers::is_codex_official_provider(provider),
|
||||
)?;
|
||||
}
|
||||
|
||||
// 统一会话开关:备份是接管释放时恢复 live 的来源,官方配置的
|
||||
@@ -2156,8 +2116,14 @@ impl ProxyService {
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
|
||||
// Defense-in-depth: block official providers during proxy takeover
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
// Defense-in-depth: only the built-in Codex official provider supports
|
||||
// native OpenAI auth passthrough during takeover.
|
||||
if provider.category.as_deref() == Some("official")
|
||||
&& !crate::services::provider::official_provider_supports_proxy_takeover(
|
||||
&app_type_enum,
|
||||
&provider,
|
||||
)
|
||||
{
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
@@ -2298,17 +2264,19 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn preserve_codex_oauth_auth_in_backup(
|
||||
fn preserve_codex_auth_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
preserve_api_key: bool,
|
||||
) -> Result<(), String> {
|
||||
if !crate::settings::preserve_codex_official_auth_on_switch() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(existing_auth) = existing_backup
|
||||
.get("auth")
|
||||
.filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))
|
||||
.filter(|auth| {
|
||||
!Self::codex_auth_has_proxy_placeholder(auth)
|
||||
&& (crate::codex_config::codex_auth_has_oauth_login_material(auth)
|
||||
|| (preserve_api_key
|
||||
&& crate::codex_config::codex_auth_has_login_material(auth)))
|
||||
})
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(());
|
||||
@@ -2350,33 +2318,69 @@ impl ProxyService {
|
||||
|
||||
// ==================== Live 配置读写辅助方法 ====================
|
||||
|
||||
/// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)
|
||||
fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {
|
||||
crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url)
|
||||
.unwrap_or_else(|_| toml_str.to_string())
|
||||
}
|
||||
|
||||
/// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。
|
||||
/// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。
|
||||
fn apply_codex_proxy_toml_config_for_provider(
|
||||
toml_str: &str,
|
||||
proxy_url: &str,
|
||||
provider: Option<&Provider>,
|
||||
) -> String {
|
||||
let updated = Self::update_toml_base_url(toml_str, proxy_url);
|
||||
) -> Result<String, String> {
|
||||
if provider.is_some_and(crate::proxy::providers::is_codex_official_provider) {
|
||||
return crate::codex_config::apply_codex_official_proxy_route(toml_str, proxy_url)
|
||||
.map_err(|e| format!("生成 Codex 官方接管配置失败: {e}"));
|
||||
}
|
||||
|
||||
let updated = crate::codex_config::update_codex_toml_field(toml_str, "base_url", proxy_url)
|
||||
.map_err(|e| format!("更新 Codex 代理地址失败: {e}"))?;
|
||||
let mut updated =
|
||||
crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses")
|
||||
.unwrap_or(updated);
|
||||
.map_err(|e| format!("更新 Codex wire_api 失败: {e}"))?;
|
||||
|
||||
if let Some(upstream_model) =
|
||||
provider.and_then(crate::proxy::providers::codex_provider_upstream_model)
|
||||
{
|
||||
updated =
|
||||
crate::codex_config::update_codex_toml_field(&updated, "model", &upstream_model)
|
||||
.unwrap_or(updated);
|
||||
.map_err(|e| format!("更新 Codex 上游模型失败: {e}"))?;
|
||||
}
|
||||
|
||||
updated
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn apply_codex_takeover_auth_placeholder(settings: &mut Value, provider: Option<&Provider>) {
|
||||
if provider.is_some_and(crate::proxy::providers::is_codex_official_provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(auth) = settings.get_mut("auth").and_then(|v| v.as_object_mut()) {
|
||||
auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
} else if let Some(root) = settings.as_object_mut() {
|
||||
root.insert(
|
||||
"auth".to_string(),
|
||||
json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_codex_takeover_fields_for_provider(
|
||||
settings: &mut Value,
|
||||
proxy_base_url: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
Self::apply_codex_takeover_auth_placeholder(settings, Some(provider));
|
||||
let config_text = settings
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let projected = Self::apply_codex_proxy_toml_config_for_provider(
|
||||
&config_text,
|
||||
proxy_base_url,
|
||||
Some(provider),
|
||||
)?;
|
||||
settings["config"] = json!(projected);
|
||||
Self::attach_codex_model_catalog_from_provider(settings, Some(provider));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attach_codex_model_catalog_from_provider(
|
||||
@@ -2497,11 +2501,17 @@ impl ProxyService {
|
||||
config: &Value,
|
||||
provider: Option<&Provider>,
|
||||
) -> Result<(), String> {
|
||||
if crate::settings::preserve_codex_official_auth_on_switch() {
|
||||
if let Some(auth) = config
|
||||
let official_passthrough =
|
||||
provider.is_some_and(crate::proxy::providers::is_codex_official_provider);
|
||||
let placeholder_auth = config
|
||||
.get("auth")
|
||||
.filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))
|
||||
{
|
||||
.is_some_and(Self::codex_auth_has_proxy_placeholder);
|
||||
|
||||
// Takeover must never overwrite Codex's long-lived ChatGPT login. For
|
||||
// third-party providers the placeholder is moved into config.toml; for
|
||||
// codex-official no placeholder is needed because requires_openai_auth
|
||||
// makes Codex supply its native authorization.
|
||||
if official_passthrough || placeholder_auth {
|
||||
let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let profile = provider
|
||||
.map(crate::proxy::providers::resolve_codex_catalog_tool_profile)
|
||||
@@ -2511,14 +2521,19 @@ impl ProxyService {
|
||||
config, config_str, profile,
|
||||
)
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||
let live_config =
|
||||
crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config)
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||
let live_config = if official_passthrough {
|
||||
prepared_config
|
||||
} else {
|
||||
crate::codex_config::prepare_codex_provider_live_config(
|
||||
config.get("auth").unwrap_or(&Value::Null),
|
||||
&prepared_config,
|
||||
)
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?
|
||||
};
|
||||
crate::codex_config::write_codex_live_config_atomic(Some(&live_config))
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
self.write_codex_live_for_provider(config, provider)
|
||||
}
|
||||
@@ -2561,9 +2576,11 @@ impl ProxyService {
|
||||
|
||||
match (auth, prepared_cfg.as_deref()) {
|
||||
(Some(auth), Some(cfg)) => {
|
||||
let auth_path = get_codex_auth_path();
|
||||
if auth.as_object().is_some_and(|obj| obj.is_empty()) {
|
||||
let _ = crate::config::delete_file(&auth_path);
|
||||
// An empty provider snapshot must not destroy an existing
|
||||
// Codex login. This is especially important when takeover
|
||||
// switches from an API-key-backed official session to the
|
||||
// built-in empty `codex-official` seed.
|
||||
let config_path = get_codex_config_path();
|
||||
crate::config::write_text_file(&config_path, cfg)
|
||||
.map_err(|e| format!("写入 Codex config 失败: {e}"))?;
|
||||
@@ -3586,6 +3603,230 @@ wire_api = "responses"
|
||||
.expect("reset settings");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_takeover_hot_switches_between_builtin_official_and_third_party() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
// Exercise the default setting: takeover itself must now preserve native
|
||||
// auth regardless of the legacy compatibility toggle.
|
||||
crate::settings::update_settings(crate::settings::AppSettings::default())
|
||||
.expect("reset settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
use_ephemeral_proxy_port(&db).await;
|
||||
let service = ProxyService::new(db.clone());
|
||||
let oauth_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"id_token": "oauth-id",
|
||||
"access_token": "oauth-access"
|
||||
}
|
||||
});
|
||||
crate::codex_config::write_codex_live_atomic(&oauth_auth, Some("model = \"gpt-5.4\"\n"))
|
||||
.expect("seed official live config");
|
||||
|
||||
let mut official = Provider::with_id(
|
||||
"codex-official".to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }),
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
db.save_provider("codex", &official)
|
||||
.expect("save official provider");
|
||||
|
||||
let mut third_party = Provider::with_id(
|
||||
"rightcode".to_string(),
|
||||
"RightCode".to_string(),
|
||||
json!({
|
||||
"auth": { "OPENAI_API_KEY": "rightcode-key" },
|
||||
"config": r#"model_provider = "rightcode"
|
||||
|
||||
[model_providers.rightcode]
|
||||
name = "RightCode"
|
||||
base_url = "https://rightcode.example/v1"
|
||||
wire_api = "responses"
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
third_party.category = Some("custom".to_string());
|
||||
db.save_provider("codex", &third_party)
|
||||
.expect("save third-party provider");
|
||||
db.set_current_provider("codex", "codex-official")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Codex, Some("codex-official"))
|
||||
.expect("set local current provider");
|
||||
|
||||
service
|
||||
.set_takeover_for_app("codex", true)
|
||||
.await
|
||||
.expect("enable official takeover");
|
||||
|
||||
let read_auth = || -> Value {
|
||||
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
|
||||
.expect("read live auth")
|
||||
};
|
||||
assert_eq!(read_auth(), oauth_auth);
|
||||
let official_live = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||||
.expect("read official takeover config");
|
||||
assert!(crate::codex_config::codex_config_has_official_proxy_route(
|
||||
&official_live
|
||||
));
|
||||
assert!(official_live.contains("requires_openai_auth = true"));
|
||||
assert!(!official_live.contains(PROXY_TOKEN_PLACEHOLDER));
|
||||
|
||||
service
|
||||
.hot_switch_provider("codex", "rightcode")
|
||||
.await
|
||||
.expect("switch to third-party provider");
|
||||
assert_eq!(
|
||||
read_auth(),
|
||||
oauth_auth,
|
||||
"third-party route must preserve OAuth"
|
||||
);
|
||||
let third_party_live =
|
||||
std::fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||||
.expect("read third-party takeover config");
|
||||
assert!(third_party_live.contains(PROXY_TOKEN_PLACEHOLDER));
|
||||
assert!(!crate::codex_config::codex_config_has_official_proxy_route(
|
||||
&third_party_live
|
||||
));
|
||||
|
||||
service
|
||||
.hot_switch_provider("codex", "codex-official")
|
||||
.await
|
||||
.expect("switch back to official provider");
|
||||
assert_eq!(
|
||||
read_auth(),
|
||||
oauth_auth,
|
||||
"official switch must reuse native OAuth"
|
||||
);
|
||||
let official_live = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||||
.expect("read restored official takeover config");
|
||||
assert!(crate::codex_config::codex_config_has_official_proxy_route(
|
||||
&official_live
|
||||
));
|
||||
assert!(!official_live.contains(PROXY_TOKEN_PLACEHOLDER));
|
||||
|
||||
service
|
||||
.set_takeover_for_app("codex", false)
|
||||
.await
|
||||
.expect("disable takeover");
|
||||
assert_eq!(read_auth(), oauth_auth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_takeover_backup_preserves_api_key_login_material() {
|
||||
let mut target = json!({ "auth": {}, "config": "" });
|
||||
let existing = json!({
|
||||
"auth": { "OPENAI_API_KEY": "sk-real" },
|
||||
"config": "model = \"gpt-5.4\"\n"
|
||||
});
|
||||
|
||||
ProxyService::preserve_codex_auth_in_backup(&mut target, &existing, true)
|
||||
.expect("preserve API-key auth");
|
||||
assert_eq!(target["auth"]["OPENAI_API_KEY"], "sk-real");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_backup_rebuild_uses_live_auth_when_backup_is_missing() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
crate::codex_config::write_codex_live_atomic(
|
||||
&json!({ "OPENAI_API_KEY": "sk-real" }),
|
||||
Some("model = \"gpt-5.4\"\n"),
|
||||
)
|
||||
.expect("seed live auth");
|
||||
let mut official = Provider::with_id(
|
||||
crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({ "auth": {}, "config": "" }),
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider_inner("codex", &official)
|
||||
.await
|
||||
.expect("rebuild backup");
|
||||
let backup = db
|
||||
.get_live_backup("codex")
|
||||
.await
|
||||
.expect("read backup")
|
||||
.expect("backup exists");
|
||||
let value: Value = serde_json::from_str(&backup.original_config).expect("parse backup");
|
||||
assert_eq!(value["auth"]["OPENAI_API_KEY"], "sk-real");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn codex_empty_restore_snapshot_does_not_delete_existing_auth_json() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db);
|
||||
let auth = json!({ "OPENAI_API_KEY": "sk-real" });
|
||||
crate::codex_config::write_codex_live_atomic(&auth, Some("model = \"gpt-5.4\"\n"))
|
||||
.expect("seed auth");
|
||||
|
||||
service
|
||||
.write_codex_live_verbatim(&json!({
|
||||
"auth": {},
|
||||
"config": "model = \"gpt-5.4-mini\"\n"
|
||||
}))
|
||||
.expect("restore empty snapshot");
|
||||
|
||||
let live_auth: Value =
|
||||
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
|
||||
.expect("read auth");
|
||||
assert_eq!(live_auth, auth);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_sync_live_does_not_store_credentials_in_builtin_official_row() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
let mut official = Provider::with_id(
|
||||
crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({ "auth": {}, "config": "" }),
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
db.save_provider("codex", &official).expect("save official");
|
||||
db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID)
|
||||
.expect("set current");
|
||||
crate::settings::set_current_provider(
|
||||
&AppType::Codex,
|
||||
Some(crate::database::CODEX_OFFICIAL_PROVIDER_ID),
|
||||
)
|
||||
.expect("set local current");
|
||||
crate::codex_config::write_codex_live_atomic(
|
||||
&json!({ "OPENAI_API_KEY": "sk-real" }),
|
||||
Some("model = \"gpt-5.4\"\n"),
|
||||
)
|
||||
.expect("seed live auth");
|
||||
|
||||
service
|
||||
.sync_live_to_provider(&AppType::Codex)
|
||||
.await
|
||||
.expect("sync live");
|
||||
|
||||
let stored = db
|
||||
.get_provider_by_id(crate::database::CODEX_OFFICIAL_PROVIDER_ID, "codex")
|
||||
.expect("read official")
|
||||
.expect("official exists");
|
||||
assert_eq!(stored.settings_config["auth"], json!({}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() {
|
||||
@@ -4036,7 +4277,7 @@ wire_api = "responses"
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {
|
||||
async fn codex_takeover_preserves_native_auth_even_when_legacy_toggle_is_disabled() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
crate::settings::update_settings(crate::settings::AppSettings {
|
||||
@@ -4100,26 +4341,15 @@ wire_api = "responses"
|
||||
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
|
||||
.expect("read live auth");
|
||||
assert_eq!(
|
||||
live_auth
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER),
|
||||
"disabled preservation should keep the legacy auth.json takeover placeholder"
|
||||
);
|
||||
assert_eq!(
|
||||
live_auth
|
||||
.get("tokens")
|
||||
.and_then(|tokens| tokens.get("access_token"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("oauth-access"),
|
||||
"the new config-only takeover branch must not run when preservation is disabled"
|
||||
live_auth, oauth_auth,
|
||||
"takeover must preserve native OAuth independently of the legacy toggle"
|
||||
);
|
||||
|
||||
let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||||
.expect("read live config");
|
||||
assert!(
|
||||
!live_config.contains(PROXY_TOKEN_PLACEHOLDER),
|
||||
"disabled preservation should not move the takeover placeholder into config.toml"
|
||||
live_config.contains(PROXY_TOKEN_PLACEHOLDER),
|
||||
"third-party takeover should carry its local placeholder in config.toml"
|
||||
);
|
||||
|
||||
crate::settings::update_settings(crate::settings::AppSettings::default())
|
||||
@@ -4291,7 +4521,8 @@ requires_openai_auth = true
|
||||
"#;
|
||||
|
||||
let new_url = "http://127.0.0.1:5000/v1";
|
||||
let output = ProxyService::update_toml_base_url(input, new_url);
|
||||
let output = crate::codex_config::update_codex_toml_field(input, "base_url", new_url)
|
||||
.expect("update base_url");
|
||||
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&output).expect("updated config should be valid TOML");
|
||||
@@ -4332,7 +4563,8 @@ wire_api = "chat"
|
||||
|
||||
let proxy_url = "http://127.0.0.1:5000/v1";
|
||||
let output =
|
||||
ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);
|
||||
ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None)
|
||||
.expect("apply proxy config");
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&output).expect("updated config should be valid TOML");
|
||||
|
||||
@@ -4351,6 +4583,51 @@ wire_api = "chat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_codex_proxy_toml_config_routes_builtin_official_with_native_auth() {
|
||||
let mut provider = Provider::with_id(
|
||||
"codex-official".to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({ "auth": {}, "config": "" }),
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
let proxy_url = "http://127.0.0.1:5000/v1";
|
||||
|
||||
let output = ProxyService::apply_codex_proxy_toml_config_for_provider(
|
||||
"experimental_bearer_token = \"PROXY_MANAGED\"\n",
|
||||
proxy_url,
|
||||
Some(&provider),
|
||||
)
|
||||
.expect("apply official proxy config");
|
||||
let parsed: toml::Value = toml::from_str(&output).expect("valid official route");
|
||||
let route_id = crate::codex_config::CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID;
|
||||
let route = &parsed["model_providers"][route_id];
|
||||
|
||||
assert_eq!(parsed["model_provider"].as_str(), Some(route_id));
|
||||
assert_eq!(route["base_url"].as_str(), Some(proxy_url));
|
||||
assert_eq!(route["requires_openai_auth"].as_bool(), Some(true));
|
||||
assert!(parsed.get("experimental_bearer_token").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_codex_proxy_toml_config_fails_closed_for_invalid_official_config() {
|
||||
let mut provider = Provider::with_id(
|
||||
crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({ "auth": {}, "config": "" }),
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
|
||||
let result = ProxyService::apply_codex_proxy_toml_config_for_provider(
|
||||
"model_providers = 3\n",
|
||||
"http://127.0.0.1:5000/v1",
|
||||
Some(&provider),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {
|
||||
let input = r#"
|
||||
@@ -4380,7 +4657,8 @@ wire_api = "responses"
|
||||
input,
|
||||
proxy_url,
|
||||
Some(&provider),
|
||||
);
|
||||
)
|
||||
.expect("apply chat proxy config");
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&output).expect("updated config should be valid TOML");
|
||||
|
||||
@@ -4426,7 +4704,8 @@ wire_api = "responses"
|
||||
input,
|
||||
"http://127.0.0.1:5000/v1",
|
||||
Some(&provider),
|
||||
);
|
||||
)
|
||||
.expect("apply responses proxy config");
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&output).expect("updated config should be valid TOML");
|
||||
|
||||
@@ -4471,7 +4750,8 @@ wire_api = "responses"
|
||||
input,
|
||||
"http://127.0.0.1:5000/v1",
|
||||
Some(&provider),
|
||||
);
|
||||
)
|
||||
.expect("restore responses model");
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&output).expect("updated config should be valid TOML");
|
||||
|
||||
@@ -4488,7 +4768,8 @@ model = "gpt-5.1-codex"
|
||||
"#;
|
||||
|
||||
let new_url = "http://127.0.0.1:5000/v1";
|
||||
let output = ProxyService::update_toml_base_url(input, new_url);
|
||||
let output = crate::codex_config::update_codex_toml_field(input, "base_url", new_url)
|
||||
.expect("update top-level base_url");
|
||||
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&output).expect("updated config should be valid TOML");
|
||||
|
||||
@@ -673,8 +673,12 @@ pub fn create_tray_menu(
|
||||
|
||||
for (id, provider) in sort_providers(&providers) {
|
||||
let is_current = current_id == *id;
|
||||
let is_official_blocked =
|
||||
is_app_taken_over && provider.category.as_deref() == Some("official");
|
||||
let is_official_blocked = is_app_taken_over
|
||||
&& provider.category.as_deref() == Some("official")
|
||||
&& !crate::services::provider::official_provider_supports_proxy_takeover(
|
||||
§ion.app_type,
|
||||
provider,
|
||||
);
|
||||
let label = if is_official_blocked {
|
||||
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user