mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
- Add Codex provider API format selection and model mapping for Chat-only upstreams. - Convert Codex Responses requests to Chat Completions and rebuild Chat responses as Responses output. - Preserve reasoning_content across non-streaming, streaming, tool calls, and previous_response_id follow-ups. - Add a bounded Codex Chat history cache for restoring tool calls before tool outputs. - Cover Chat bridge, compact routing, streaming, and history recovery with focused tests.
583 lines
18 KiB
Rust
583 lines
18 KiB
Rust
//! Codex (OpenAI) Provider Adapter
|
||
//!
|
||
//! 仅透传模式,支持直连 OpenAI API
|
||
//!
|
||
//! ## 客户端检测
|
||
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
|
||
|
||
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||
use crate::provider::Provider;
|
||
use crate::proxy::error::ProxyError;
|
||
use regex::Regex;
|
||
use serde_json::Value as JsonValue;
|
||
use std::sync::LazyLock;
|
||
use toml::Value as TomlValue;
|
||
|
||
/// 官方 Codex 客户端 User-Agent 正则
|
||
#[allow(dead_code)]
|
||
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
|
||
|
||
/// Codex 适配器
|
||
pub struct CodexAdapter;
|
||
|
||
/// Local model written into Codex config.toml for Chat-only upstreams.
|
||
///
|
||
/// Codex itself must see a model it can load metadata for; the real provider
|
||
/// model is restored inside the proxy immediately before Chat Completions
|
||
/// conversion.
|
||
pub const CODEX_CHAT_CLIENT_MODEL: &str = "gpt-5.4";
|
||
|
||
/// Whether this Codex provider's real upstream should be called through
|
||
/// OpenAI Chat Completions, even if the local Codex client is talking to CC
|
||
/// Switch through the Responses API.
|
||
pub fn codex_provider_uses_chat_completions(provider: &Provider) -> bool {
|
||
if let Some(api_format) = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.api_format.as_deref())
|
||
.or_else(|| {
|
||
provider
|
||
.settings_config
|
||
.get("api_format")
|
||
.and_then(|v| v.as_str())
|
||
})
|
||
.or_else(|| {
|
||
provider
|
||
.settings_config
|
||
.get("apiFormat")
|
||
.and_then(|v| v.as_str())
|
||
})
|
||
{
|
||
return is_chat_wire_api(api_format);
|
||
}
|
||
|
||
if let Some(wire_api) = provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.and_then(extract_codex_wire_api_from_toml)
|
||
{
|
||
return is_chat_wire_api(&wire_api);
|
||
}
|
||
|
||
if let Some(base_url) = provider
|
||
.settings_config
|
||
.get("base_url")
|
||
.or_else(|| provider.settings_config.get("baseURL"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return is_chat_completions_url(base_url);
|
||
}
|
||
|
||
provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.and_then(extract_codex_base_url_from_toml)
|
||
.map(|url| is_chat_completions_url(&url))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &str) -> bool {
|
||
let path = endpoint
|
||
.split_once('?')
|
||
.map_or(endpoint, |(path, _query)| path);
|
||
|
||
matches!(
|
||
path,
|
||
"/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact"
|
||
) && codex_provider_uses_chat_completions(provider)
|
||
}
|
||
|
||
/// Extract the real upstream model configured for a Codex provider.
|
||
pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||
provider
|
||
.settings_config
|
||
.get("model")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|model| !model.is_empty())
|
||
.map(ToString::to_string)
|
||
.or_else(|| {
|
||
provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.and_then(extract_codex_model_from_toml)
|
||
})
|
||
}
|
||
|
||
/// For Codex Chat providers, replace the local Codex-safe model with the real
|
||
/// upstream model before converting the request to Chat Completions.
|
||
pub fn apply_codex_chat_upstream_model(
|
||
provider: &Provider,
|
||
body: &mut JsonValue,
|
||
) -> Option<String> {
|
||
if !codex_provider_uses_chat_completions(provider) {
|
||
return None;
|
||
}
|
||
|
||
let upstream_model = codex_provider_upstream_model(provider)?;
|
||
body["model"] = JsonValue::String(upstream_model.clone());
|
||
Some(upstream_model)
|
||
}
|
||
|
||
fn is_chat_wire_api(value: &str) -> bool {
|
||
matches!(
|
||
value.trim().to_ascii_lowercase().as_str(),
|
||
"chat"
|
||
| "chat_completions"
|
||
| "chat-completions"
|
||
| "openai_chat"
|
||
| "openai-chat"
|
||
| "openai_chat_completions"
|
||
)
|
||
}
|
||
|
||
fn is_chat_completions_url(value: &str) -> bool {
|
||
value
|
||
.trim_end_matches('/')
|
||
.to_ascii_lowercase()
|
||
.ends_with("/chat/completions")
|
||
}
|
||
|
||
fn extract_codex_wire_api_from_toml(config_text: &str) -> Option<String> {
|
||
let doc = config_text.parse::<TomlValue>().ok()?;
|
||
|
||
if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) {
|
||
if let Some(wire_api) = doc
|
||
.get("model_providers")
|
||
.and_then(|providers| providers.get(active_provider))
|
||
.and_then(|provider| provider.get("wire_api"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Some(wire_api.to_string());
|
||
}
|
||
}
|
||
|
||
doc.get("wire_api")
|
||
.and_then(|v| v.as_str())
|
||
.map(ToString::to_string)
|
||
}
|
||
|
||
fn extract_codex_model_from_toml(config_text: &str) -> Option<String> {
|
||
let doc = config_text.parse::<TomlValue>().ok()?;
|
||
|
||
doc.get("model")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|model| !model.is_empty())
|
||
.map(ToString::to_string)
|
||
}
|
||
|
||
fn extract_codex_base_url_from_toml(config_text: &str) -> Option<String> {
|
||
let doc = config_text.parse::<TomlValue>().ok()?;
|
||
|
||
if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) {
|
||
if let Some(base_url) = doc
|
||
.get("model_providers")
|
||
.and_then(|providers| providers.get(active_provider))
|
||
.and_then(|provider| provider.get("base_url"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Some(base_url.to_string());
|
||
}
|
||
}
|
||
|
||
doc.get("base_url")
|
||
.and_then(|v| v.as_str())
|
||
.map(ToString::to_string)
|
||
}
|
||
|
||
impl CodexAdapter {
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
/// 检测是否为官方 Codex 客户端
|
||
///
|
||
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
|
||
#[allow(dead_code)]
|
||
pub fn is_official_client(user_agent: &str) -> bool {
|
||
CODEX_CLIENT_REGEX.is_match(user_agent)
|
||
}
|
||
|
||
/// 从 Provider 配置中提取 API Key
|
||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||
// 1. 尝试从 env 中获取
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||
return Some(key.to_string());
|
||
}
|
||
}
|
||
|
||
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
|
||
if let Some(auth) = provider.settings_config.get("auth") {
|
||
if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||
return Some(key.to_string());
|
||
}
|
||
}
|
||
|
||
// 3. 尝试直接获取
|
||
if let Some(key) = provider
|
||
.settings_config
|
||
.get("apiKey")
|
||
.or_else(|| provider.settings_config.get("api_key"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Some(key.to_string());
|
||
}
|
||
|
||
// 4. 尝试从 config 对象中获取
|
||
if let Some(config) = provider.settings_config.get("config") {
|
||
if let Some(key) = config
|
||
.get("api_key")
|
||
.or_else(|| config.get("apiKey"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Some(key.to_string());
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
}
|
||
|
||
impl Default for CodexAdapter {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl ProviderAdapter for CodexAdapter {
|
||
fn name(&self) -> &'static str {
|
||
"Codex"
|
||
}
|
||
|
||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||
// 1. 尝试直接获取 base_url 字段
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("base_url")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
// 2. 尝试 baseURL
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("baseURL")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
// 3. 尝试从 config 对象中获取
|
||
if let Some(config) = provider.settings_config.get("config") {
|
||
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
// 尝试解析 TOML 字符串格式
|
||
if let Some(config_str) = config.as_str() {
|
||
if let Some(start) = config_str.find("base_url = \"") {
|
||
let rest = &config_str[start + 12..];
|
||
if let Some(end) = rest.find('"') {
|
||
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||
}
|
||
}
|
||
if let Some(start) = config_str.find("base_url = '") {
|
||
let rest = &config_str[start + 12..];
|
||
if let Some(end) = rest.find('\'') {
|
||
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(ProxyError::ConfigError(
|
||
"Codex Provider 缺少 base_url 配置".to_string(),
|
||
))
|
||
}
|
||
|
||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||
self.extract_key(provider)
|
||
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
|
||
}
|
||
|
||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||
let base_trimmed = base_url.trim_end_matches('/');
|
||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||
|
||
// OpenAI/Codex 的 base_url 可能是:
|
||
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
|
||
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
|
||
// - 自定义前缀: https://xxx/openai (不添加 /v1,直接拼接)
|
||
|
||
// 检查 base_url 是否已经包含 /v1
|
||
let already_has_v1 = base_trimmed.ends_with("/v1");
|
||
|
||
// 检查是否是纯 origin(没有路径部分)
|
||
let origin_only = match base_trimmed.split_once("://") {
|
||
Some((_scheme, rest)) => !rest.contains('/'),
|
||
None => !base_trimmed.contains('/'),
|
||
};
|
||
|
||
let mut url = if already_has_v1 {
|
||
// 已经有 /v1,直接拼接
|
||
format!("{base_trimmed}/{endpoint_trimmed}")
|
||
} else if origin_only {
|
||
// 纯 origin,添加 /v1
|
||
format!("{base_trimmed}/v1/{endpoint_trimmed}")
|
||
} else {
|
||
// 自定义前缀,不添加 /v1,直接拼接
|
||
format!("{base_trimmed}/{endpoint_trimmed}")
|
||
};
|
||
|
||
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||
while url.contains("/v1/v1") {
|
||
url = url.replace("/v1/v1", "/v1");
|
||
}
|
||
|
||
url
|
||
}
|
||
|
||
fn get_auth_headers(
|
||
&self,
|
||
auth: &AuthInfo,
|
||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
|
||
use super::adapter::auth_header_value;
|
||
let bearer = format!("Bearer {}", auth.api_key);
|
||
Ok(vec![(
|
||
http::HeaderName::from_static("authorization"),
|
||
auth_header_value(&bearer)?,
|
||
)])
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
fn create_provider(config: serde_json::Value) -> Provider {
|
||
Provider {
|
||
id: "test".to_string(),
|
||
name: "Test Codex".to_string(),
|
||
settings_config: config,
|
||
website_url: None,
|
||
category: Some("codex".to_string()),
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_base_url_direct() {
|
||
let adapter = CodexAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"base_url": "https://api.openai.com/v1"
|
||
}));
|
||
|
||
let url = adapter.extract_base_url(&provider).unwrap();
|
||
assert_eq!(url, "https://api.openai.com/v1");
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_from_auth_field() {
|
||
let adapter = CodexAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"auth": {
|
||
"OPENAI_API_KEY": "sk-test-key-12345678"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-test-key-12345678");
|
||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_from_env() {
|
||
let adapter = CodexAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"OPENAI_API_KEY": "sk-env-key-12345678"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-env-key-12345678");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url() {
|
||
let adapter = CodexAdapter::new();
|
||
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
|
||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_origin_adds_v1() {
|
||
let adapter = CodexAdapter::new();
|
||
let url = adapter.build_url("https://api.openai.com", "/responses");
|
||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_custom_prefix_no_v1() {
|
||
let adapter = CodexAdapter::new();
|
||
let url = adapter.build_url("https://example.com/openai", "/responses");
|
||
assert_eq!(url, "https://example.com/openai/responses");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_dedup_v1() {
|
||
let adapter = CodexAdapter::new();
|
||
// base_url 已包含 /v1,endpoint 也包含 /v1
|
||
let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses");
|
||
assert_eq!(url, "https://www.packyapi.com/v1/responses");
|
||
}
|
||
|
||
// 官方客户端检测测试
|
||
#[test]
|
||
fn test_is_official_client_vscode() {
|
||
assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0"));
|
||
assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4"));
|
||
assert!(CodexAdapter::is_official_client("codex_vscode/0.1"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_official_client_cli() {
|
||
assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0"));
|
||
assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_not_official_client() {
|
||
assert!(!CodexAdapter::is_official_client("Mozilla/5.0"));
|
||
assert!(!CodexAdapter::is_official_client("curl/7.68.0"));
|
||
assert!(!CodexAdapter::is_official_client("python-requests/2.25.1"));
|
||
assert!(!CodexAdapter::is_official_client("codex_other/1.0.0"));
|
||
assert!(!CodexAdapter::is_official_client(""));
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_official_client_partial_match() {
|
||
// 必须从开头匹配
|
||
assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0"));
|
||
assert!(!CodexAdapter::is_official_client(
|
||
"prefix_codex_cli_rs/1.0.0"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_provider_uses_chat_completions_from_active_wire_api() {
|
||
let provider = create_provider(json!({
|
||
"config": r#"
|
||
model_provider = "chat_only"
|
||
model = "gpt-5"
|
||
|
||
[model_providers.chat_only]
|
||
name = "Chat Only"
|
||
base_url = "https://example.com/v1"
|
||
wire_api = "chat"
|
||
"#
|
||
}));
|
||
|
||
assert!(codex_provider_uses_chat_completions(&provider));
|
||
assert!(should_convert_codex_responses_to_chat(
|
||
&provider,
|
||
"/responses?stream=true"
|
||
));
|
||
assert!(!should_convert_codex_responses_to_chat(
|
||
&provider,
|
||
"/chat/completions"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_provider_uses_chat_completions_from_full_chat_url() {
|
||
let provider = create_provider(json!({
|
||
"base_url": "https://example.com/v1/chat/completions"
|
||
}));
|
||
|
||
assert!(codex_provider_uses_chat_completions(&provider));
|
||
assert!(should_convert_codex_responses_to_chat(
|
||
&provider,
|
||
"/v1/responses/compact"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_compact() {
|
||
let mut provider = create_provider(json!({
|
||
"base_url": "https://example.com/v1"
|
||
}));
|
||
provider.meta = Some(crate::provider::ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
});
|
||
|
||
assert!(codex_provider_uses_chat_completions(&provider));
|
||
assert!(should_convert_codex_responses_to_chat(
|
||
&provider,
|
||
"/responses/compact?stream=true"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_responses() {
|
||
let mut provider = create_provider(json!({
|
||
"base_url": "https://api.deepseek.com/v1"
|
||
}));
|
||
provider.meta = Some(crate::provider::ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
});
|
||
|
||
assert!(should_convert_codex_responses_to_chat(
|
||
&provider,
|
||
"/v1/responses"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn test_apply_codex_chat_upstream_model_uses_provider_config_model() {
|
||
let mut provider = create_provider(json!({
|
||
"config": r#"
|
||
model_provider = "deepseek"
|
||
model = "deepseek-v4-flash"
|
||
|
||
[model_providers.deepseek]
|
||
name = "DeepSeek"
|
||
base_url = "https://api.deepseek.com/v1"
|
||
wire_api = "responses"
|
||
"#
|
||
}));
|
||
provider.meta = Some(crate::provider::ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
});
|
||
let mut body = json!({
|
||
"model": CODEX_CHAT_CLIENT_MODEL,
|
||
"input": "ping"
|
||
});
|
||
|
||
let upstream_model = apply_codex_chat_upstream_model(&provider, &mut body);
|
||
|
||
assert_eq!(upstream_model.as_deref(), Some("deepseek-v4-flash"));
|
||
assert_eq!(
|
||
body.get("model").and_then(|v| v.as_str()),
|
||
Some("deepseek-v4-flash")
|
||
);
|
||
}
|
||
}
|