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:
Jason
2026-05-19 10:23:48 +08:00
parent 61e68d754c
commit 1c82b8a3fa
19 changed files with 2230 additions and 35 deletions
+158
View File
@@ -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"
));
}
}