mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 02:51:21 +08:00
Add Chat Completions routing for Codex providers
- Add a Codex API format selector and routing badge for Chat Completions providers. - Convert Codex Responses requests to upstream Chat Completions when routing is required. - Convert Chat Completions JSON and SSE responses back to Responses format. - Keep generated Codex wire_api values on Responses for Codex compatibility. - Add i18n labels, provider metadata handling, and focused conversion tests.
This commit is contained in:
@@ -10,6 +10,7 @@ use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// 官方 Codex 客户端 User-Agent 正则
|
||||
#[allow(dead_code)]
|
||||
@@ -19,6 +20,125 @@ static CODEX_CLIENT_REGEX: LazyLock<Regex> =
|
||||
/// Codex 适配器
|
||||
pub struct CodexAdapter;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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_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
|
||||
@@ -306,4 +426,42 @@ mod tests {
|
||||
"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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user