mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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:
@@ -418,6 +418,8 @@ pub fn write_codex_live_atomic_with_stable_provider(
|
||||
/// Supported fields:
|
||||
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
|
||||
/// otherwise falls back to top-level `base_url`.
|
||||
/// - `"wire_api"`: writes to `[model_providers.<current>].wire_api` if `model_provider` exists,
|
||||
/// otherwise falls back to top-level `wire_api`.
|
||||
/// - `"model"`: writes to top-level `model` field.
|
||||
///
|
||||
/// Empty value removes the field.
|
||||
@@ -429,7 +431,7 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
||||
let trimmed = value.trim();
|
||||
|
||||
match field {
|
||||
"base_url" => {
|
||||
"base_url" | "wire_api" => {
|
||||
let model_provider = doc
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
@@ -449,20 +451,20 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
||||
|
||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
||||
if trimmed.is_empty() {
|
||||
provider_table.remove("base_url");
|
||||
provider_table.remove(field);
|
||||
} else {
|
||||
provider_table["base_url"] = toml_edit::value(trimmed);
|
||||
provider_table[field] = toml_edit::value(trimmed);
|
||||
}
|
||||
return Ok(doc.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no model_provider or structure mismatch → top-level base_url
|
||||
// Fallback: no model_provider or structure mismatch → top-level field
|
||||
if trimmed.is_empty() {
|
||||
doc.as_table_mut().remove("base_url");
|
||||
doc.as_table_mut().remove(field);
|
||||
} else {
|
||||
doc["base_url"] = toml_edit::value(trimmed);
|
||||
doc[field] = toml_edit::value(trimmed);
|
||||
}
|
||||
}
|
||||
"model" => {
|
||||
@@ -798,6 +800,36 @@ wire_api = "responses"
|
||||
assert_eq!(wire_api, Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_api_writes_into_correct_model_provider_section() {
|
||||
let input = r#"model_provider = "chat_only"
|
||||
model = "gpt-5.1-codex"
|
||||
|
||||
[model_providers.chat_only]
|
||||
name = "Chat Only"
|
||||
base_url = "https://example.com/v1"
|
||||
wire_api = "chat"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "wire_api", "responses").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let provider = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("chat_only"))
|
||||
.expect("model_providers.chat_only should exist");
|
||||
|
||||
assert_eq!(
|
||||
provider.get("wire_api").and_then(|v| v.as_str()),
|
||||
Some("responses")
|
||||
);
|
||||
assert_eq!(
|
||||
provider.get("base_url").and_then(|v| v.as_str()),
|
||||
Some("https://example.com/v1")
|
||||
);
|
||||
assert!(parsed.get("wire_api").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_creates_section_when_missing() {
|
||||
let input = r#"model_provider = "custom"
|
||||
|
||||
@@ -1108,20 +1108,29 @@ impl RequestForwarder {
|
||||
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
|
||||
None => adapter.needs_transform(provider),
|
||||
};
|
||||
let (effective_endpoint, passthrough_query) =
|
||||
if needs_transform && adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body)
|
||||
} else {
|
||||
(
|
||||
endpoint.to_string(),
|
||||
split_endpoint_and_query(endpoint)
|
||||
.1
|
||||
.map(ToString::to_string),
|
||||
)
|
||||
};
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let (effective_endpoint, passthrough_query) = if codex_responses_to_chat {
|
||||
rewrite_codex_responses_endpoint_to_chat(endpoint)
|
||||
} else if needs_transform && adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body)
|
||||
} else {
|
||||
(
|
||||
endpoint.to_string(),
|
||||
split_endpoint_and_query(endpoint)
|
||||
.1
|
||||
.map(ToString::to_string),
|
||||
)
|
||||
};
|
||||
|
||||
let codex_chat_base_is_full_endpoint = codex_responses_to_chat
|
||||
&& base_url
|
||||
.trim_end_matches('/')
|
||||
.to_ascii_lowercase()
|
||||
.ends_with("/chat/completions");
|
||||
|
||||
let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) {
|
||||
super::gemini_url::resolve_gemini_native_url(
|
||||
@@ -1129,14 +1138,16 @@ impl RequestForwarder {
|
||||
&effective_endpoint,
|
||||
is_full_url,
|
||||
)
|
||||
} else if is_full_url {
|
||||
} else if is_full_url || codex_chat_base_is_full_endpoint {
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
} else {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
let request_body = if codex_responses_to_chat {
|
||||
super::providers::transform_codex_chat::responses_to_chat_completions(mapped_body)?
|
||||
} else if needs_transform {
|
||||
if adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
@@ -1169,7 +1180,8 @@ impl RequestForwarder {
|
||||
);
|
||||
let request_is_streaming =
|
||||
is_streaming_request(&effective_endpoint, &filtered_body, headers);
|
||||
let force_identity_encoding = needs_transform || request_is_streaming;
|
||||
let force_identity_encoding =
|
||||
needs_transform || codex_responses_to_chat || request_is_streaming;
|
||||
|
||||
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
@@ -2037,6 +2049,18 @@ fn is_claude_messages_path(path: &str) -> bool {
|
||||
matches!(path, "/v1/messages" | "/claude/v1/messages")
|
||||
}
|
||||
|
||||
fn rewrite_codex_responses_endpoint_to_chat(endpoint: &str) -> (String, Option<String>) {
|
||||
let (_path, query) = split_endpoint_and_query(endpoint);
|
||||
let passthrough_query = query.map(ToString::to_string);
|
||||
let target_path = "/chat/completions";
|
||||
let rewritten = match passthrough_query.as_deref() {
|
||||
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
|
||||
_ => target_path.to_string(),
|
||||
};
|
||||
|
||||
(rewritten, passthrough_query)
|
||||
}
|
||||
|
||||
fn rewrite_claude_transform_endpoint(
|
||||
endpoint: &str,
|
||||
api_format: &str,
|
||||
@@ -2760,6 +2784,15 @@ mod tests {
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_codex_responses_endpoint_to_chat_preserves_query() {
|
||||
let (endpoint, passthrough_query) =
|
||||
rewrite_codex_responses_endpoint_to_chat("/v1/responses?foo=bar");
|
||||
|
||||
assert_eq!(endpoint, "/chat/completions?foo=bar");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
|
||||
@@ -47,7 +47,7 @@ fn openai_stream_usage_event_filter(data: &str) -> bool {
|
||||
data.contains("\"usage\"")
|
||||
}
|
||||
|
||||
fn codex_stream_usage_event_filter(data: &str) -> bool {
|
||||
pub fn codex_stream_usage_event_filter(data: &str) -> bool {
|
||||
data.contains("\"response.completed\"") || data.contains("\"usage\"")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,16 @@ use super::{
|
||||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{
|
||||
claude_stream_usage_event_filter, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG,
|
||||
GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
claude_stream_usage_event_filter, codex_stream_usage_event_filter, CLAUDE_PARSER_CONFIG,
|
||||
CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
},
|
||||
handler_context::RequestContext,
|
||||
providers::{
|
||||
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
|
||||
streaming_codex_chat::create_responses_sse_stream_from_chat,
|
||||
streaming_gemini::create_anthropic_sse_stream_from_gemini,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_gemini, transform_responses,
|
||||
transform_codex_chat, transform_gemini, transform_responses,
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
@@ -587,6 +588,17 @@ pub async fn handle_responses(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
|
||||
return handle_codex_chat_to_responses_transform(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
is_stream,
|
||||
connection_guard,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
@@ -651,6 +663,17 @@ pub async fn handle_responses_compact(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
|
||||
return handle_codex_chat_to_responses_transform(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
is_stream,
|
||||
connection_guard,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
@@ -661,6 +684,162 @@ pub async fn handle_responses_compact(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_codex_chat_to_responses_transform(
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
is_stream: bool,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
return process_response(response, ctx, state, &CODEX_PARSER_CONFIG, connection_guard)
|
||||
.await;
|
||||
}
|
||||
|
||||
if is_stream || response.is_sse() {
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream = create_responses_sse_stream_from_chat(stream);
|
||||
|
||||
let usage_collector = if usage_logging_enabled(state) {
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let request_model = ctx.request_model.clone();
|
||||
let start_time = ctx.start_time;
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
Some(SseUsageCollector::new(
|
||||
start_time,
|
||||
Some(codex_stream_usage_event_filter),
|
||||
move |events, first_token_ms| {
|
||||
let usage =
|
||||
TokenUsage::from_codex_stream_events_auto(&events).unwrap_or_default();
|
||||
let model = usage.model.clone().unwrap_or_else(|| request_model.clone());
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
let session_id = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
"codex",
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true,
|
||||
status.as_u16(),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
sse_stream,
|
||||
ctx.tag,
|
||||
usage_collector,
|
||||
ctx.streaming_timeout_config(),
|
||||
connection_guard,
|
||||
);
|
||||
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"Content-Type",
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
"Cache-Control",
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
return Ok((headers, body).into_response());
|
||||
}
|
||||
|
||||
let _connection_guard = connection_guard;
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
std::time::Duration::ZERO
|
||||
};
|
||||
let (mut response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
let chat_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse upstream chat response: {e}"))
|
||||
})?;
|
||||
let responses_response = transform_codex_chat::chat_completion_to_response(chat_response)
|
||||
.map_err(|e| {
|
||||
log::error!("[Codex] Chat → Responses 响应转换失败: {e}");
|
||||
e
|
||||
})?;
|
||||
|
||||
if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response) {
|
||||
let model = responses_response
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(&ctx.request_model);
|
||||
let request_model = ctx.request_model.clone();
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let model = model.to_string();
|
||||
let session_id = ctx.session_id.clone();
|
||||
let latency_ms = ctx.latency_ms();
|
||||
async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
"codex",
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
false,
|
||||
status.as_u16(),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
for (key, value) in response_headers.iter() {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder = builder.header("content-type", "application/json");
|
||||
|
||||
let response_body = serde_json::to_vec(&responses_response).map_err(|e| {
|
||||
log::error!("[Codex] 序列化 Responses 响应失败: {e}");
|
||||
ProxyError::TransformError(format!("Failed to serialize responses response: {e}"))
|
||||
})?;
|
||||
|
||||
builder
|
||||
.body(axum::body::Body::from(response_body))
|
||||
.map_err(|e| {
|
||||
log::error!("[Codex] 构建 Responses 响应失败: {e}");
|
||||
ProxyError::Internal(format!("Failed to build response: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gemini API 处理器
|
||||
// ============================================================================
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ pub(crate) mod gemini_schema;
|
||||
pub mod gemini_shadow;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_codex_chat;
|
||||
pub mod streaming_gemini;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_codex_chat;
|
||||
pub mod transform_gemini;
|
||||
pub mod transform_responses;
|
||||
|
||||
@@ -40,6 +42,7 @@ pub use claude::{
|
||||
claude_api_format_needs_transform, get_claude_api_format,
|
||||
transform_claude_request_for_api_format, ClaudeAdapter,
|
||||
};
|
||||
pub use codex::should_convert_codex_responses_to_chat;
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
//! OpenAI Chat Completions SSE → OpenAI Responses SSE conversion.
|
||||
|
||||
use super::transform_codex_chat::{
|
||||
chat_usage_to_responses_usage, response_id_from_chat_id, response_status_from_finish_reason,
|
||||
};
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct TextItemState {
|
||||
output_index: Option<u32>,
|
||||
item_id: String,
|
||||
text: String,
|
||||
added: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ToolCallState {
|
||||
output_index: Option<u32>,
|
||||
item_id: String,
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
added: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ChatToResponsesState {
|
||||
response_started: bool,
|
||||
completed: bool,
|
||||
response_id: String,
|
||||
model: String,
|
||||
created_at: u64,
|
||||
next_output_index: u32,
|
||||
text: TextItemState,
|
||||
tools: BTreeMap<usize, ToolCallState>,
|
||||
output_items: Vec<Value>,
|
||||
latest_usage: Option<Value>,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ChatToResponsesState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
response_started: false,
|
||||
completed: false,
|
||||
response_id: "resp_ccswitch".to_string(),
|
||||
model: String::new(),
|
||||
created_at: 0,
|
||||
next_output_index: 0,
|
||||
text: TextItemState::default(),
|
||||
tools: BTreeMap::new(),
|
||||
output_items: Vec::new(),
|
||||
latest_usage: None,
|
||||
finish_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatToResponsesState {
|
||||
fn handle_chat_chunk(&mut self, chunk: &Value) -> Vec<Bytes> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(id) = chunk.get("id").and_then(|v| v.as_str()) {
|
||||
self.response_id = response_id_from_chat_id(Some(id));
|
||||
}
|
||||
if let Some(model) = chunk.get("model").and_then(|v| v.as_str()) {
|
||||
if !model.is_empty() {
|
||||
self.model = model.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(created) = chunk.get("created").and_then(|v| v.as_u64()) {
|
||||
self.created_at = created;
|
||||
}
|
||||
|
||||
events.extend(self.ensure_response_started());
|
||||
|
||||
if let Some(usage) = chunk.get("usage").filter(|v| !v.is_null()) {
|
||||
self.latest_usage = Some(chat_usage_to_responses_usage(Some(usage)));
|
||||
}
|
||||
|
||||
let Some(choice) = chunk
|
||||
.get("choices")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|choices| choices.first())
|
||||
else {
|
||||
return events;
|
||||
};
|
||||
|
||||
if let Some(delta) = choice.get("delta") {
|
||||
if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
|
||||
if !content.is_empty() {
|
||||
events.extend(self.push_text_delta(content));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for tool_call in tool_calls {
|
||||
events.extend(self.push_tool_call_delta(tool_call));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
|
||||
self.finish_reason = Some(finish_reason.to_string());
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn ensure_response_started(&mut self) -> Vec<Bytes> {
|
||||
if self.response_started {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.response_started = true;
|
||||
let response = self.base_response("in_progress", Vec::new());
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.created",
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": response
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.in_progress",
|
||||
json!({
|
||||
"type": "response.in_progress",
|
||||
"response": self.base_response("in_progress", Vec::new())
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn push_text_delta(&mut self, delta: &str) -> Vec<Bytes> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
if !self.text.added {
|
||||
let output_index = self.next_output_index();
|
||||
let item_id = format!("{}_msg", self.response_id);
|
||||
self.text.output_index = Some(output_index);
|
||||
self.text.item_id = item_id.clone();
|
||||
self.text.added = true;
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse_event(
|
||||
"response.content_part.added",
|
||||
json!({
|
||||
"type": "response.content_part.added",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
self.text.text.push_str(delta);
|
||||
let output_index = self.text.output_index.unwrap_or(0);
|
||||
events.push(sse_event(
|
||||
"response.output_text.delta",
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
));
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn push_tool_call_delta(&mut self, tool_call: &Value) -> Vec<Bytes> {
|
||||
let chat_index = tool_call.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let id_delta = tool_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let function = tool_call.get("function").unwrap_or(&Value::Null);
|
||||
let name_delta = function
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let args_delta = function
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let mut should_add = false;
|
||||
let mut output_index = None;
|
||||
let mut item_id = String::new();
|
||||
let mut pending_arguments = String::new();
|
||||
|
||||
{
|
||||
let state = self.tools.entry(chat_index).or_default();
|
||||
if let Some(id) = id_delta {
|
||||
state.call_id = id;
|
||||
}
|
||||
if let Some(name) = name_delta {
|
||||
state.name = name;
|
||||
}
|
||||
if !args_delta.is_empty() {
|
||||
state.arguments.push_str(&args_delta);
|
||||
}
|
||||
|
||||
if !state.added && (!state.call_id.is_empty() || !state.name.is_empty()) {
|
||||
should_add = true;
|
||||
pending_arguments = state.arguments.clone();
|
||||
} else if state.added {
|
||||
output_index = state.output_index;
|
||||
item_id = state.item_id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
if should_add {
|
||||
let assigned = self.next_output_index();
|
||||
let state = self.tools.get_mut(&chat_index).expect("tool state exists");
|
||||
state.added = true;
|
||||
if state.call_id.is_empty() {
|
||||
state.call_id = format!("call_{chat_index}");
|
||||
}
|
||||
if state.name.is_empty() {
|
||||
state.name = "unknown_tool".to_string();
|
||||
}
|
||||
state.output_index = Some(assigned);
|
||||
state.item_id = format!("fc_{}", state.call_id);
|
||||
item_id = state.item_id.clone();
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": assigned,
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "function_call",
|
||||
"status": "in_progress",
|
||||
"call_id": state.call_id,
|
||||
"name": state.name,
|
||||
"arguments": ""
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
if !pending_arguments.is_empty() {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": state.item_id,
|
||||
"output_index": assigned,
|
||||
"delta": pending_arguments
|
||||
}),
|
||||
));
|
||||
}
|
||||
} else if !args_delta.is_empty() {
|
||||
if let Some(output_index) = output_index {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": args_delta
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn finalize(&mut self) -> Vec<Bytes> {
|
||||
if self.completed {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut events = self.ensure_response_started();
|
||||
events.extend(self.finalize_text());
|
||||
events.extend(self.finalize_tools());
|
||||
|
||||
let status = response_status_from_finish_reason(self.finish_reason.as_deref());
|
||||
let mut response = self.base_response(status, self.output_items.clone());
|
||||
if status == "incomplete" {
|
||||
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
|
||||
}
|
||||
|
||||
events.push(sse_event(
|
||||
"response.completed",
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": response
|
||||
}),
|
||||
));
|
||||
self.completed = true;
|
||||
events
|
||||
}
|
||||
|
||||
fn finalize_text(&mut self) -> Vec<Bytes> {
|
||||
if !self.text.added || self.text.done {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let output_index = self.text.output_index.unwrap_or(0);
|
||||
let item = json!({
|
||||
"id": self.text.item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": self.text.text,
|
||||
"annotations": []
|
||||
}]
|
||||
});
|
||||
self.output_items.push(item.clone());
|
||||
self.text.done = true;
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.output_text.done",
|
||||
json!({
|
||||
"type": "response.output_text.done",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"text": self.text.text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.content_part.done",
|
||||
json!({
|
||||
"type": "response.content_part.done",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": self.text.text,
|
||||
"annotations": []
|
||||
}
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn finalize_tools(&mut self) -> Vec<Bytes> {
|
||||
let mut events = Vec::new();
|
||||
let keys: Vec<usize> = self.tools.keys().copied().collect();
|
||||
|
||||
for key in keys {
|
||||
let mut add_event: Option<Bytes> = None;
|
||||
if self.tools.get(&key).map(|state| state.done).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if self
|
||||
.tools
|
||||
.get(&key)
|
||||
.map(|state| !state.added && !state.done)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let assigned = self.next_output_index();
|
||||
let state = self.tools.get_mut(&key).expect("tool state exists");
|
||||
state.added = true;
|
||||
if state.call_id.is_empty() {
|
||||
state.call_id = format!("call_{key}");
|
||||
}
|
||||
if state.name.is_empty() {
|
||||
state.name = "unknown_tool".to_string();
|
||||
}
|
||||
state.output_index = Some(assigned);
|
||||
state.item_id = format!("fc_{}", state.call_id);
|
||||
add_event = Some(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": assigned,
|
||||
"item": {
|
||||
"id": state.item_id,
|
||||
"type": "function_call",
|
||||
"status": "in_progress",
|
||||
"call_id": state.call_id,
|
||||
"name": state.name,
|
||||
"arguments": ""
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(event) = add_event {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
let state = self.tools.get_mut(&key).expect("tool state exists");
|
||||
let output_index = state.output_index.unwrap_or(0);
|
||||
let item = json!({
|
||||
"id": state.item_id,
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"call_id": state.call_id,
|
||||
"name": state.name,
|
||||
"arguments": state.arguments
|
||||
});
|
||||
state.done = true;
|
||||
self.output_items.push(item.clone());
|
||||
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.done",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": state.item_id,
|
||||
"output_index": output_index,
|
||||
"arguments": state.arguments
|
||||
}),
|
||||
));
|
||||
events.push(sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn base_response(&self, status: &str, output: Vec<Value>) -> Value {
|
||||
json!({
|
||||
"id": self.response_id,
|
||||
"object": "response",
|
||||
"created_at": self.created_at,
|
||||
"status": status,
|
||||
"model": self.model,
|
||||
"output": output,
|
||||
"usage": self.latest_usage.clone().unwrap_or_else(|| {
|
||||
json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn next_output_index(&mut self) -> u32 {
|
||||
let index = self.next_output_index;
|
||||
self.next_output_index += 1;
|
||||
index
|
||||
}
|
||||
|
||||
fn failed_event(&mut self, message: String, error_type: Option<String>) -> Bytes {
|
||||
self.completed = true;
|
||||
let mut error = json!({ "message": message });
|
||||
if let Some(error_type) = error_type.filter(|value| !value.is_empty()) {
|
||||
error["type"] = json!(error_type);
|
||||
}
|
||||
|
||||
let mut response = self.base_response("failed", self.output_items.clone());
|
||||
response["error"] = error;
|
||||
|
||||
sse_event(
|
||||
"response.failed",
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": response
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a stream that converts Chat Completions SSE chunks into Responses SSE events.
|
||||
pub fn create_responses_sse_stream_from_chat<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut state = ChatToResponsesState::default();
|
||||
let mut stream_failed = false;
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
while let Some(block) = take_sse_block(&mut buffer) {
|
||||
if block.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut event_name: Option<String> = None;
|
||||
let mut data_parts: Vec<String> = Vec::new();
|
||||
for line in block.lines() {
|
||||
if let Some(event) = strip_sse_field(line, "event") {
|
||||
event_name = Some(event.trim().to_string());
|
||||
}
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
data_parts.push(data.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if data_parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = data_parts.join("\n");
|
||||
if data.trim() == "[DONE]" {
|
||||
for event in state.finalize() {
|
||||
yield Ok(event);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk: Value = match serde_json::from_str(&data) {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if event_name.as_deref() == Some("error") || chunk.get("error").is_some() {
|
||||
let (message, error_type) = extract_chat_sse_error(&chunk);
|
||||
yield Ok(state.failed_event(message, error_type));
|
||||
stream_failed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
for event in state.handle_chat_chunk(&chunk) {
|
||||
yield Ok(event);
|
||||
}
|
||||
}
|
||||
|
||||
if stream_failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
yield Ok(state.failed_event(
|
||||
format!("Stream error: {e}"),
|
||||
Some("stream_error".to_string()),
|
||||
));
|
||||
stream_failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !stream_failed {
|
||||
for event in state.finalize() {
|
||||
yield Ok(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_chat_sse_error(value: &Value) -> (String, Option<String>) {
|
||||
let error = value.get("error").unwrap_or(value);
|
||||
let message = error
|
||||
.as_str()
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| {
|
||||
error
|
||||
.get("message")
|
||||
.or_else(|| error.get("detail"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| error.to_string());
|
||||
let error_type = error
|
||||
.get("type")
|
||||
.or_else(|| error.get("code"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string);
|
||||
|
||||
(message, error_type)
|
||||
}
|
||||
|
||||
fn sse_event(event: &str, data: Value) -> Bytes {
|
||||
Bytes::from(format!(
|
||||
"event: {event}\ndata: {}\n\n",
|
||||
serde_json::to_string(&data).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::{stream, StreamExt};
|
||||
|
||||
async fn collect(chunks: Vec<&str>) -> String {
|
||||
let chunks: Vec<Result<Bytes, std::io::Error>> = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| Ok(Bytes::copy_from_slice(chunk.as_bytes())))
|
||||
.collect();
|
||||
let upstream = stream::iter(chunks);
|
||||
let converted = create_responses_sse_stream_from_chat(upstream);
|
||||
let bytes: Vec<Bytes> = converted.map(|item| item.unwrap()).collect().await;
|
||||
String::from_utf8(bytes.concat()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn converts_text_chat_sse_to_responses_sse() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_1\",\"created\":123,\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"created\":123,\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":2,\"total_tokens\":6}}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.created"));
|
||||
assert!(output.contains("event: response.output_text.delta"));
|
||||
assert!(output.contains("\"text\":\"Hello\""));
|
||||
assert!(output.contains("event: response.completed"));
|
||||
assert!(output.contains("\"input_tokens\":4"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn converts_tool_call_chat_sse_to_responses_sse() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.function_call_arguments.delta"));
|
||||
assert!(output.contains("event: response.function_call_arguments.done"));
|
||||
assert!(output.contains("\"type\":\"function_call\""));
|
||||
assert!(output.contains("\"call_id\":\"call_1\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_error_emits_failed_without_completed() {
|
||||
let upstream = stream::iter(vec![Err::<Bytes, std::io::Error>(std::io::Error::other(
|
||||
"boom",
|
||||
))]);
|
||||
let converted = create_responses_sse_stream_from_chat(upstream);
|
||||
let bytes: Vec<Bytes> = converted.map(|item| item.unwrap()).collect().await;
|
||||
let output = String::from_utf8(bytes.concat()).unwrap();
|
||||
|
||||
assert!(output.contains("event: response.failed"));
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_sse_error_event_emits_failed_without_completed() {
|
||||
let output = collect(vec![
|
||||
"event: error\ndata: {\"error\":{\"message\":\"bad request\",\"type\":\"invalid_request_error\"}}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.failed"));
|
||||
assert!(output.contains("bad request"));
|
||||
assert!(output.contains("invalid_request_error"));
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Codex Responses ↔ OpenAI Chat Completions conversion.
|
||||
//!
|
||||
//! This module is used when the Codex client talks to CC Switch through the
|
||||
//! Responses API, while the selected upstream provider only exposes an
|
||||
//! OpenAI-compatible Chat Completions endpoint.
|
||||
|
||||
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[
|
||||
"frequency_penalty",
|
||||
"logit_bias",
|
||||
"logprobs",
|
||||
"metadata",
|
||||
"n",
|
||||
"parallel_tool_calls",
|
||||
"presence_penalty",
|
||||
"response_format",
|
||||
"seed",
|
||||
"service_tier",
|
||||
"stop",
|
||||
"stream_options",
|
||||
"top_logprobs",
|
||||
"user",
|
||||
];
|
||||
|
||||
/// Convert an OpenAI Responses request into an OpenAI Chat Completions request.
|
||||
pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
if let Some(model) = body.get("model") {
|
||||
result["model"] = model.clone();
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(instructions) = body.get("instructions") {
|
||||
let instructions = instruction_text(instructions);
|
||||
if !instructions.is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "system",
|
||||
"content": instructions
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(input) = body.get("input") {
|
||||
append_responses_input_as_chat_messages(input, &mut messages)?;
|
||||
}
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
let model = body.get("model").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Some(max_tokens) = body.get("max_output_tokens") {
|
||||
if super::transform::is_openai_o_series(model) {
|
||||
result["max_completion_tokens"] = max_tokens.clone();
|
||||
} else {
|
||||
result["max_tokens"] = max_tokens.clone();
|
||||
}
|
||||
}
|
||||
if let Some(max_tokens) = body.get("max_tokens") {
|
||||
result["max_tokens"] = max_tokens.clone();
|
||||
}
|
||||
if let Some(max_tokens) = body.get("max_completion_tokens") {
|
||||
result["max_completion_tokens"] = max_tokens.clone();
|
||||
}
|
||||
|
||||
for key in ["temperature", "top_p", "stream"] {
|
||||
if let Some(value) = body.get(key) {
|
||||
result[key] = value.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if super::transform::supports_reasoning_effort(model) {
|
||||
if let Some(effort) = body.pointer("/reasoning/effort") {
|
||||
result["reasoning_effort"] = effort.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tools) = body.get("tools").and_then(|v| v.as_array()) {
|
||||
let tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.filter_map(responses_tool_to_chat_tool)
|
||||
.collect();
|
||||
if !tools.is_empty() {
|
||||
result["tools"] = json!(tools);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tool_choice) = body.get("tool_choice") {
|
||||
result["tool_choice"] = responses_tool_choice_to_chat(tool_choice);
|
||||
}
|
||||
|
||||
for key in EXTRA_CHAT_PASSTHROUGH_FIELDS {
|
||||
if let Some(value) = body.get(*key) {
|
||||
result[*key] = value.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn instruction_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
part.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| part.as_str())
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
other => other.as_str().unwrap_or_default().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_responses_input_as_chat_messages(
|
||||
input: &Value,
|
||||
messages: &mut Vec<Value>,
|
||||
) -> Result<(), ProxyError> {
|
||||
let mut pending_tool_calls = Vec::new();
|
||||
|
||||
match input {
|
||||
Value::String(text) => {
|
||||
messages.push(json!({
|
||||
"role": "user",
|
||||
"content": text
|
||||
}));
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
append_responses_item_as_chat_message(item, messages, &mut pending_tool_calls)?;
|
||||
}
|
||||
}
|
||||
Value::Object(_) => {
|
||||
append_responses_item_as_chat_message(input, messages, &mut pending_tool_calls)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
flush_pending_tool_calls(messages, &mut pending_tool_calls);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_responses_item_as_chat_message(
|
||||
item: &Value,
|
||||
messages: &mut Vec<Value>,
|
||||
pending_tool_calls: &mut Vec<Value>,
|
||||
) -> Result<(), ProxyError> {
|
||||
let item_type = item.get("type").and_then(|v| v.as_str());
|
||||
match item_type {
|
||||
Some("function_call") => {
|
||||
pending_tool_calls.push(responses_function_call_to_chat_tool_call(item));
|
||||
}
|
||||
Some("function_call_output") => {
|
||||
flush_pending_tool_calls(messages, pending_tool_calls);
|
||||
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let output = match item.get("output") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => String::new(),
|
||||
};
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"content": output
|
||||
}));
|
||||
}
|
||||
Some("reasoning") => {
|
||||
// Reasoning items are Responses-specific context. Chat-only providers
|
||||
// cannot consume encrypted reasoning state, so omit it.
|
||||
}
|
||||
Some("message") | None => {
|
||||
flush_pending_tool_calls(messages, pending_tool_calls);
|
||||
if item.get("role").is_some() || item.get("content").is_some() {
|
||||
messages.push(responses_message_item_to_chat_message(item));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
flush_pending_tool_calls(messages, pending_tool_calls);
|
||||
if item.get("role").is_some() || item.get("content").is_some() {
|
||||
messages.push(responses_message_item_to_chat_message(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_pending_tool_calls(messages: &mut Vec<Value>, pending_tool_calls: &mut Vec<Value>) {
|
||||
if pending_tool_calls.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push(json!({
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": std::mem::take(pending_tool_calls)
|
||||
}));
|
||||
}
|
||||
|
||||
fn responses_message_item_to_chat_message(item: &Value) -> Value {
|
||||
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user");
|
||||
let content = item
|
||||
.get("content")
|
||||
.map(|value| responses_content_to_chat_content(role, value))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
json!({
|
||||
"role": role,
|
||||
"content": content
|
||||
})
|
||||
}
|
||||
|
||||
fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value {
|
||||
if content.is_null() || content.is_string() {
|
||||
return content.clone();
|
||||
}
|
||||
|
||||
let Some(parts) = content.as_array() else {
|
||||
return content.clone();
|
||||
};
|
||||
|
||||
let mut chat_parts: Vec<Value> = Vec::new();
|
||||
let mut has_non_text_part = false;
|
||||
|
||||
for part in parts {
|
||||
let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match part_type {
|
||||
"input_text" | "output_text" | "text" => {
|
||||
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
chat_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": text
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"refusal" => {
|
||||
if let Some(text) = part.get("refusal").and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
chat_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": text
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"input_image" => {
|
||||
if let Some(image_url) = part.get("image_url") {
|
||||
let image_url = if image_url.is_object() {
|
||||
image_url.clone()
|
||||
} else {
|
||||
json!({ "url": image_url.as_str().unwrap_or_default() })
|
||||
};
|
||||
chat_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": image_url
|
||||
}));
|
||||
has_non_text_part = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_non_text_part {
|
||||
return Value::String(
|
||||
chat_parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(|v| v.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
Value::Array(chat_parts)
|
||||
}
|
||||
|
||||
fn responses_function_call_to_chat_tool_call(item: &Value) -> Value {
|
||||
let call_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let arguments = match item.get("arguments") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
|
||||
json!({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": arguments
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn responses_tool_to_chat_tool(tool: &Value) -> Option<Value> {
|
||||
if tool.get("type").and_then(|v| v.as_str()) != Some("function") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if tool.get("function").is_some() {
|
||||
let mut chat_tool = tool.clone();
|
||||
if let Some(strict) = tool.get("strict").cloned() {
|
||||
if let Some(function) = chat_tool
|
||||
.get_mut("function")
|
||||
.and_then(|value| value.as_object_mut())
|
||||
{
|
||||
function.entry("strict".to_string()).or_insert(strict);
|
||||
}
|
||||
if let Some(obj) = chat_tool.as_object_mut() {
|
||||
obj.remove("strict");
|
||||
}
|
||||
}
|
||||
return Some(chat_tool);
|
||||
}
|
||||
|
||||
let mut function = json!({
|
||||
"name": tool.get("name").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"description": tool.get("description").cloned().unwrap_or(Value::Null),
|
||||
"parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({}))
|
||||
});
|
||||
if let Some(strict) = tool.get("strict") {
|
||||
function["strict"] = strict.clone();
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"type": "function",
|
||||
"function": function
|
||||
}))
|
||||
}
|
||||
|
||||
fn responses_tool_choice_to_chat(tool_choice: &Value) -> Value {
|
||||
match tool_choice {
|
||||
Value::Object(obj) if obj.get("type").and_then(|v| v.as_str()) == Some("function") => {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": obj.get("name").and_then(|v| v.as_str()).unwrap_or("")
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => tool_choice.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a non-streaming Chat Completions response into a Responses response.
|
||||
pub fn chat_completion_to_response(body: Value) -> Result<Value, ProxyError> {
|
||||
let choices = body
|
||||
.get("choices")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ProxyError::TransformError("No choices in chat response".to_string()))?;
|
||||
let choice = choices
|
||||
.first()
|
||||
.ok_or_else(|| ProxyError::TransformError("Empty choices in chat response".to_string()))?;
|
||||
let message = choice
|
||||
.get("message")
|
||||
.ok_or_else(|| ProxyError::TransformError("No message in chat choice".to_string()))?;
|
||||
|
||||
let response_id = response_id_from_chat_id(body.get("id").and_then(|v| v.as_str()));
|
||||
let model = body.get("model").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created_at = body.get("created").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let finish_reason = choice.get("finish_reason").and_then(|v| v.as_str());
|
||||
|
||||
let mut output = Vec::new();
|
||||
if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) {
|
||||
output.push(message_item);
|
||||
}
|
||||
output.extend(chat_tool_calls_to_response_output_items(message));
|
||||
|
||||
let mut response = json!({
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"created_at": created_at,
|
||||
"status": response_status_from_finish_reason(finish_reason),
|
||||
"model": model,
|
||||
"output": output,
|
||||
"usage": chat_usage_to_responses_usage(body.get("usage"))
|
||||
});
|
||||
|
||||
if finish_reason == Some("length") {
|
||||
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> Option<Value> {
|
||||
let mut content = Vec::new();
|
||||
|
||||
if let Some(text) = message.get("content").and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
}));
|
||||
}
|
||||
} else if let Some(parts) = message.get("content").and_then(|v| v.as_array()) {
|
||||
for part in parts {
|
||||
let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match part_type {
|
||||
"text" | "output_text" => {
|
||||
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"refusal" => {
|
||||
if let Some(text) = part.get("refusal").and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "refusal",
|
||||
"refusal": text
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(refusal) = message.get("refusal").and_then(|v| v.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "refusal",
|
||||
"refusal": refusal
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"id": format!("{response_id}_msg"),
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": content
|
||||
}))
|
||||
}
|
||||
|
||||
fn chat_tool_calls_to_response_output_items(message: &Value) -> Vec<Value> {
|
||||
let mut output = Vec::new();
|
||||
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for (index, tool_call) in tool_calls.iter().enumerate() {
|
||||
output.push(chat_tool_call_to_response_item(tool_call, index));
|
||||
}
|
||||
} else if let Some(function_call) = message.get("function_call") {
|
||||
output.push(chat_legacy_function_call_to_response_item(function_call));
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn chat_tool_call_to_response_item(tool_call: &Value, index: usize) -> Value {
|
||||
let call_id = tool_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| format!("call_{index}"));
|
||||
let function = tool_call.get("function").unwrap_or(&Value::Null);
|
||||
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let arguments = match function.get("arguments") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
|
||||
json!({
|
||||
"id": format!("fc_{call_id}"),
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"arguments": arguments
|
||||
})
|
||||
}
|
||||
|
||||
fn chat_legacy_function_call_to_response_item(function_call: &Value) -> Value {
|
||||
let call_id = function_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or("call_0");
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let arguments = match function_call.get("arguments") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
|
||||
json!({
|
||||
"id": format!("fc_{call_id}"),
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"arguments": arguments
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value {
|
||||
let Some(usage) = usage.filter(|value| value.is_object() && !value.is_null()) else {
|
||||
return json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0
|
||||
});
|
||||
};
|
||||
|
||||
let input_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.or_else(|| usage.get("input_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.or_else(|| usage.get("output_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(input_tokens + output_tokens);
|
||||
|
||||
let mut result = json!({
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens
|
||||
});
|
||||
|
||||
if let Some(cached) = usage
|
||||
.pointer("/prompt_tokens_details/cached_tokens")
|
||||
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
result["input_tokens_details"] = json!({ "cached_tokens": cached });
|
||||
}
|
||||
|
||||
if let Some(details) = usage.get("completion_tokens_details") {
|
||||
result["output_tokens_details"] = details.clone();
|
||||
}
|
||||
|
||||
if let Some(cache_read) = usage.get("cache_read_input_tokens") {
|
||||
result["cache_read_input_tokens"] = cache_read.clone();
|
||||
}
|
||||
if let Some(cache_creation) = usage.get("cache_creation_input_tokens") {
|
||||
result["cache_creation_input_tokens"] = cache_creation.clone();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn response_id_from_chat_id(id: Option<&str>) -> String {
|
||||
let id = id.unwrap_or("ccswitch");
|
||||
if id.starts_with("resp_") {
|
||||
id.to_string()
|
||||
} else {
|
||||
format!("resp_{id}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn response_status_from_finish_reason(finish_reason: Option<&str>) -> &'static str {
|
||||
match finish_reason {
|
||||
Some("length") => "incomplete",
|
||||
_ => "completed",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_maps_messages_tools_and_limits() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"instructions": "You are concise.",
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Weather?"},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,abc"},
|
||||
{"type": "input_text", "text": "Use Celsius."}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"Tokyo\"}"
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "Sunny"
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object"},
|
||||
"strict": true
|
||||
}],
|
||||
"tool_choice": {"type": "function", "name": "get_weather"},
|
||||
"max_output_tokens": 100,
|
||||
"reasoning": {"effort": "high"},
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert_eq!(result["model"], "gpt-5.4");
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
assert_eq!(result["messages"][1]["content"][0]["type"], "text");
|
||||
assert_eq!(result["messages"][1]["content"][1]["type"], "image_url");
|
||||
assert_eq!(result["messages"][1]["content"][2]["type"], "text");
|
||||
assert_eq!(result["messages"][1]["content"][2]["text"], "Use Celsius.");
|
||||
assert_eq!(result["messages"][2]["tool_calls"][0]["id"], "call_1");
|
||||
assert_eq!(result["messages"][3]["role"], "tool");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
assert_eq!(result["tools"][0]["function"]["strict"], true);
|
||||
assert_eq!(result["tool_choice"]["function"]["name"], "get_weather");
|
||||
assert_eq!(result["max_tokens"], 100);
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_to_responses_maps_text_tool_calls_and_usage() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "gpt-5.4",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Let me check.",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"Tokyo\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
"prompt_tokens_details": {"cached_tokens": 3}
|
||||
}
|
||||
});
|
||||
|
||||
let result = chat_completion_to_response(input).unwrap();
|
||||
|
||||
assert_eq!(result["id"], "resp_chatcmpl_1");
|
||||
assert_eq!(result["status"], "completed");
|
||||
assert_eq!(result["output"][0]["type"], "message");
|
||||
assert_eq!(result["output"][0]["content"][0]["text"], "Let me check.");
|
||||
assert_eq!(result["output"][1]["type"], "function_call");
|
||||
assert_eq!(result["output"][1]["call_id"], "call_1");
|
||||
assert_eq!(result["usage"]["input_tokens"], 10);
|
||||
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_length_maps_to_incomplete_response() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl_2",
|
||||
"model": "gpt-5.4",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "partial"},
|
||||
"finish_reason": "length"
|
||||
}]
|
||||
});
|
||||
|
||||
let result = chat_completion_to_response(input).unwrap();
|
||||
|
||||
assert_eq!(result["status"], "incomplete");
|
||||
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
|
||||
}
|
||||
}
|
||||
@@ -1096,7 +1096,8 @@ impl ProxyService {
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);
|
||||
let updated_config =
|
||||
Self::apply_codex_proxy_toml_config(config_str, &proxy_codex_base_url);
|
||||
live_config["config"] = json!(updated_config);
|
||||
|
||||
self.write_codex_live(&live_config)?;
|
||||
@@ -1149,7 +1150,8 @@ impl ProxyService {
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);
|
||||
let updated_config =
|
||||
Self::apply_codex_proxy_toml_config(config_str, &proxy_codex_base_url);
|
||||
live_config["config"] = json!(updated_config);
|
||||
|
||||
self.write_codex_live(&live_config)?;
|
||||
@@ -1216,7 +1218,7 @@ impl ProxyService {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let updated_config =
|
||||
Self::update_toml_base_url(config_str, &proxy_codex_base_url);
|
||||
Self::apply_codex_proxy_toml_config(config_str, &proxy_codex_base_url);
|
||||
live_config["config"] = json!(updated_config);
|
||||
|
||||
let _ = self.write_codex_live(&live_config);
|
||||
@@ -1850,6 +1852,14 @@ impl ProxyService {
|
||||
.unwrap_or_else(|_| toml_str.to_string())
|
||||
}
|
||||
|
||||
/// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。
|
||||
/// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。
|
||||
fn apply_codex_proxy_toml_config(toml_str: &str, proxy_url: &str) -> String {
|
||||
let updated = Self::update_toml_base_url(toml_str, proxy_url);
|
||||
crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses")
|
||||
.unwrap_or(updated)
|
||||
}
|
||||
|
||||
fn read_claude_live(&self) -> Result<Value, String> {
|
||||
let path = get_claude_settings_path();
|
||||
if !path.exists() {
|
||||
@@ -2285,6 +2295,38 @@ requires_openai_auth = true
|
||||
assert_eq!(wire_api, "responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {
|
||||
let input = r#"
|
||||
model_provider = "chat_only"
|
||||
model = "gpt-5.1-codex"
|
||||
|
||||
[model_providers.chat_only]
|
||||
name = "Chat Only"
|
||||
base_url = "https://chat-only.example/v1"
|
||||
wire_api = "chat"
|
||||
"#;
|
||||
|
||||
let proxy_url = "http://127.0.0.1:5000/v1";
|
||||
let output = ProxyService::apply_codex_proxy_toml_config(input, proxy_url);
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&output).expect("updated config should be valid TOML");
|
||||
|
||||
let provider = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("chat_only"))
|
||||
.expect("model_providers.chat_only should exist");
|
||||
|
||||
assert_eq!(
|
||||
provider.get("base_url").and_then(|v| v.as_str()),
|
||||
Some(proxy_url)
|
||||
);
|
||||
assert_eq!(
|
||||
provider.get("wire_api").and_then(|v| v.as_str()),
|
||||
Some("responses")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_toml_base_url_falls_back_to_top_level_base_url() {
|
||||
let input = r#"
|
||||
|
||||
@@ -18,7 +18,11 @@ import { PROVIDER_TYPES } from "@/config/constants";
|
||||
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
|
||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
extractCodexWireApi,
|
||||
isCodexChatWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { useProviderHealth } from "@/lib/query/failover";
|
||||
import { useUsageQuery } from "@/lib/query/queries";
|
||||
|
||||
@@ -191,6 +195,20 @@ export function ProviderCard({
|
||||
appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig);
|
||||
const isCodexOauth =
|
||||
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
|
||||
const codexNeedsRouting = useMemo(() => {
|
||||
if (appId !== "codex" || provider.category === "official") return false;
|
||||
if (provider.meta?.apiFormat === "openai_chat") return true;
|
||||
const config = (provider.settingsConfig as Record<string, any>)?.config;
|
||||
return (
|
||||
typeof config === "string" &&
|
||||
isCodexChatWireApi(extractCodexWireApi(config))
|
||||
);
|
||||
}, [
|
||||
appId,
|
||||
provider.category,
|
||||
provider.meta?.apiFormat,
|
||||
(provider.settingsConfig as Record<string, any>)?.config,
|
||||
]);
|
||||
const isClaudeThirdParty =
|
||||
appId === "claude" && provider.category === "third_party";
|
||||
|
||||
@@ -345,6 +363,14 @@ export function ProviderCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{codexNeedsRouting && (
|
||||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||
{t("codex.needsRouting", {
|
||||
defaultValue: "需要路由",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "claude" && provider.category === "official" && (
|
||||
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
|
||||
{t("claudeCode.noRoutingSupport", {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
@@ -10,7 +18,7 @@ import {
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { CodexApiFormat, ProviderCategory } from "@/types";
|
||||
|
||||
interface EndpointCandidate {
|
||||
url: string;
|
||||
@@ -39,6 +47,10 @@ interface CodexFormFieldsProps {
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// API Format
|
||||
apiFormat: CodexApiFormat;
|
||||
onApiFormatChange: (format: CodexApiFormat) => void;
|
||||
|
||||
// Model Name
|
||||
shouldShowModelField?: boolean;
|
||||
modelName?: string;
|
||||
@@ -67,6 +79,8 @@ export function CodexFormFields({
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
shouldShowModelField = true,
|
||||
modelName = "",
|
||||
onModelNameChange,
|
||||
@@ -143,6 +157,43 @@ export function CodexFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex API 格式选择 */}
|
||||
{shouldShowSpeedTest && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="codexApiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
onApiFormatChange(value as CodexApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="codexApiFormat" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.codexApiFormatResponses", {
|
||||
defaultValue: "OpenAI Responses API (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.codexApiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.codexApiFormatHint", {
|
||||
defaultValue:
|
||||
"选择供应商真实支持的 Codex API 格式;Chat Completions 会通过本地路由自动转换为 Responses。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex Model Name 输入框 */}
|
||||
{shouldShowModelField && onModelNameChange && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ProviderMeta,
|
||||
ProviderTestConfig,
|
||||
ClaudeApiFormat,
|
||||
CodexApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
@@ -51,6 +52,10 @@ import {
|
||||
hasApiKeyField,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import {
|
||||
extractCodexWireApi,
|
||||
setCodexWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { isNonNegativeDecimalString } from "@/types/usage";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
@@ -118,6 +123,25 @@ type PresetEntry = {
|
||||
| HermesProviderPreset;
|
||||
};
|
||||
|
||||
const codexApiFormatFromWireApi = (
|
||||
wireApi: string | undefined,
|
||||
): CodexApiFormat | undefined => {
|
||||
switch (wireApi?.trim().toLowerCase()) {
|
||||
case "chat":
|
||||
case "chat_completions":
|
||||
case "chat-completions":
|
||||
case "openai_chat":
|
||||
case "openai-chat":
|
||||
return "openai_chat";
|
||||
case "responses":
|
||||
case "openai_responses":
|
||||
case "openai-responses":
|
||||
return "openai_responses";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export interface ProviderFormProps {
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
@@ -419,6 +443,7 @@ function ProviderFormFull({
|
||||
codexModelName,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
setCodexConfig,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelNameChange,
|
||||
@@ -426,17 +451,52 @@ function ProviderFormFull({
|
||||
resetCodexConfig,
|
||||
} = useCodexConfigState({ initialData });
|
||||
|
||||
const [localCodexApiFormat, setLocalCodexApiFormat] =
|
||||
useState<CodexApiFormat>(() => {
|
||||
if (initialData?.meta?.apiFormat === "openai_chat") {
|
||||
return "openai_chat";
|
||||
}
|
||||
if (initialData?.meta?.apiFormat === "openai_responses") {
|
||||
return "openai_responses";
|
||||
}
|
||||
return (
|
||||
codexApiFormatFromWireApi(
|
||||
extractCodexWireApi(
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "",
|
||||
),
|
||||
) ?? "openai_responses"
|
||||
);
|
||||
});
|
||||
|
||||
const { configError: codexConfigError, debouncedValidate } =
|
||||
useCodexTomlValidation();
|
||||
|
||||
const handleCodexConfigChange = useCallback(
|
||||
(value: string) => {
|
||||
originalHandleCodexConfigChange(value);
|
||||
const nextFormat = codexApiFormatFromWireApi(extractCodexWireApi(value));
|
||||
if (nextFormat) {
|
||||
setLocalCodexApiFormat(nextFormat);
|
||||
}
|
||||
debouncedValidate(value);
|
||||
},
|
||||
[originalHandleCodexConfigChange, debouncedValidate],
|
||||
);
|
||||
|
||||
const handleCodexApiFormatChange = useCallback(
|
||||
(format: CodexApiFormat) => {
|
||||
setLocalCodexApiFormat(format);
|
||||
setCodexConfig((prev) => {
|
||||
const updated = setCodexWireApi(prev, "responses");
|
||||
debouncedValidate(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[setCodexConfig, debouncedValidate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
|
||||
const template = getCodexCustomTemplate();
|
||||
@@ -1049,9 +1109,13 @@ function ProviderFormFull({
|
||||
if (appId === "codex") {
|
||||
try {
|
||||
const authJson = JSON.parse(codexAuth);
|
||||
const normalizedCodexConfig =
|
||||
category !== "official" && (codexConfig ?? "").trim()
|
||||
? setCodexWireApi(codexConfig ?? "", "responses")
|
||||
: (codexConfig ?? "");
|
||||
const configObj = {
|
||||
auth: authJson,
|
||||
config: codexConfig ?? "",
|
||||
config: normalizedCodexConfig,
|
||||
};
|
||||
settingsConfig = JSON.stringify(configObj);
|
||||
} catch (err) {
|
||||
@@ -1236,7 +1300,9 @@ function ProviderFormFull({
|
||||
apiFormat:
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiFormat
|
||||
: undefined,
|
||||
: appId === "codex" && category !== "official"
|
||||
? localCodexApiFormat
|
||||
: undefined,
|
||||
apiKeyField:
|
||||
appId === "claude" &&
|
||||
category !== "official" &&
|
||||
@@ -1360,6 +1426,10 @@ function ProviderFormFull({
|
||||
if (appId === "codex") {
|
||||
const template = getCodexCustomTemplate();
|
||||
resetCodexConfig(template.auth, template.config);
|
||||
setLocalCodexApiFormat(
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
|
||||
"openai_responses",
|
||||
);
|
||||
}
|
||||
if (appId === "gemini") {
|
||||
resetGeminiConfig({}, {});
|
||||
@@ -1396,6 +1466,11 @@ function ProviderFormFull({
|
||||
const config = preset.config ?? "";
|
||||
|
||||
resetCodexConfig(auth, config);
|
||||
setLocalCodexApiFormat(
|
||||
preset.apiFormat ??
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
|
||||
"openai_responses",
|
||||
);
|
||||
|
||||
form.reset({
|
||||
name: preset.nameKey ? t(preset.nameKey) : preset.name,
|
||||
@@ -1860,6 +1935,8 @@ function ProviderFormFull({
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
apiFormat={localCodexApiFormat}
|
||||
onApiFormatChange={handleCodexApiFormatChange}
|
||||
shouldShowModelField={category !== "official"}
|
||||
modelName={codexModelName}
|
||||
onModelNameChange={handleCodexModelNameChange}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Codex 预设供应商配置模板
|
||||
*/
|
||||
import { ProviderCategory } from "../types";
|
||||
import type { CodexApiFormat } from "../types";
|
||||
import type { PresetTheme } from "./claudeProviderPresets";
|
||||
|
||||
export interface CodexProviderPreset {
|
||||
@@ -24,6 +25,8 @@ export interface CodexProviderPreset {
|
||||
// 图标配置
|
||||
icon?: string; // 图标名称
|
||||
iconColor?: string; // 图标颜色
|
||||
// Codex API 格式
|
||||
apiFormat?: CodexApiFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
} from "@/lib/query";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
import {
|
||||
extractCodexWireApi,
|
||||
isCodexChatWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
/**
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
@@ -149,6 +153,16 @@ export function useProviderActions(
|
||||
const isCopilotProvider =
|
||||
activeApp === "claude" &&
|
||||
provider.meta?.providerType === "github_copilot";
|
||||
const isCodexChatFormat =
|
||||
activeApp === "codex" &&
|
||||
(provider.meta?.apiFormat === "openai_chat" ||
|
||||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
|
||||
"string" &&
|
||||
isCodexChatWireApi(
|
||||
extractCodexWireApi(
|
||||
(provider.settingsConfig as Record<string, any>).config,
|
||||
),
|
||||
)));
|
||||
|
||||
// Determine why this provider requires the proxy
|
||||
let proxyRequiredReason: string | null = null;
|
||||
@@ -171,6 +185,10 @@ export function useProviderActions(
|
||||
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
|
||||
defaultValue: "使用 OpenAI Responses 接口格式",
|
||||
});
|
||||
} else if (isCodexChatFormat) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
|
||||
defaultValue: "使用 OpenAI Chat 接口格式",
|
||||
});
|
||||
} else if (
|
||||
activeApp === "claude-desktop" &&
|
||||
provider.meta?.claudeDesktopMode === "proxy"
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"noRoutingSupport": "No Routing Support"
|
||||
},
|
||||
"codex": {
|
||||
"needsRouting": "Needs Routing",
|
||||
"noRoutingSupport": "No Routing Support"
|
||||
},
|
||||
"claudeDesktop": {
|
||||
@@ -908,6 +909,9 @@
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires routing)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires routing)",
|
||||
"apiFormatGeminiNative": "Gemini Native generateContent (Requires routing)",
|
||||
"codexApiFormatResponses": "OpenAI Responses API (Native)",
|
||||
"codexApiFormatOpenAIChat": "OpenAI Chat Completions (Requires routing)",
|
||||
"codexApiFormatHint": "Select the Codex API format actually supported by this provider; the config stays on Responses for newer Codex versions, while Chat Completions is converted through local routing.",
|
||||
"authField": "Auth Field",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)",
|
||||
"authFieldApiKey": "ANTHROPIC_API_KEY",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"noRoutingSupport": "ルーティング非対応"
|
||||
},
|
||||
"codex": {
|
||||
"needsRouting": "ルーティングが必要",
|
||||
"noRoutingSupport": "ルーティング非対応"
|
||||
},
|
||||
"claudeDesktop": {
|
||||
@@ -908,6 +909,9 @@
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(ルーティングが必要)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API(ルーティングが必要)",
|
||||
"apiFormatGeminiNative": "Gemini Native generateContent(ルーティングが必要)",
|
||||
"codexApiFormatResponses": "OpenAI Responses API(ネイティブ)",
|
||||
"codexApiFormatOpenAIChat": "OpenAI Chat Completions(ルーティングが必要)",
|
||||
"codexApiFormatHint": "このプロバイダーが実際に対応している Codex API フォーマットを選択します。新しい Codex との互換性のため設定は Responses のままにし、Chat Completions はローカルルーティングで変換します。",
|
||||
"authField": "認証フィールド",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)",
|
||||
"authFieldApiKey": "ANTHROPIC_API_KEY",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"noRoutingSupport": "不支持路由"
|
||||
},
|
||||
"codex": {
|
||||
"needsRouting": "需要路由",
|
||||
"noRoutingSupport": "不支持路由"
|
||||
},
|
||||
"claudeDesktop": {
|
||||
@@ -908,6 +909,9 @@
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启路由)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启路由)",
|
||||
"apiFormatGeminiNative": "Gemini Native generateContent (需开启路由)",
|
||||
"codexApiFormatResponses": "OpenAI Responses API (原生)",
|
||||
"codexApiFormatOpenAIChat": "OpenAI Chat Completions (需开启路由)",
|
||||
"codexApiFormatHint": "选择供应商真实支持的 Codex API 格式;配置仍保持 Responses 以兼容新版 Codex,Chat Completions 会通过本地路由自动转换。",
|
||||
"authField": "认证字段",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
"authFieldApiKey": "ANTHROPIC_API_KEY",
|
||||
|
||||
+6
-1
@@ -161,7 +161,7 @@ export interface ProviderMeta {
|
||||
costMultiplier?: string;
|
||||
// 供应商计费模式来源
|
||||
pricingModelSource?: string;
|
||||
// Claude API 格式(仅 Claude 供应商使用)
|
||||
// API 格式(Claude / Codex 供应商使用)
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
@@ -204,6 +204,11 @@ export type ClaudeApiFormat =
|
||||
| "openai_responses"
|
||||
| "gemini_native";
|
||||
|
||||
// Codex API 格式类型
|
||||
// - "openai_responses": OpenAI Responses API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要本地路由转换
|
||||
export type CodexApiFormat = "openai_responses" | "openai_chat";
|
||||
|
||||
// Claude 认证字段类型
|
||||
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||
|
||||
|
||||
@@ -418,6 +418,8 @@ const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
|
||||
const TOML_BASE_URL_PATTERN =
|
||||
/^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
|
||||
const TOML_MODEL_PATTERN = /^\s*model\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
|
||||
const TOML_WIRE_API_PATTERN =
|
||||
/^\s*wire_api\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
|
||||
const TOML_MODEL_PROVIDER_LINE_PATTERN =
|
||||
/^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
|
||||
const TOML_MODEL_PROVIDER_PATTERN =
|
||||
@@ -585,6 +587,8 @@ const getRecoverableBaseUrlAssignments = (
|
||||
!isOtherProviderSection(sectionName, targetSectionName),
|
||||
);
|
||||
|
||||
const getRecoverableCodexProviderAssignments = getRecoverableBaseUrlAssignments;
|
||||
|
||||
const getTopLevelModelProviderLineIndex = (lines: string[]): number => {
|
||||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||||
|
||||
@@ -597,6 +601,149 @@ const getTopLevelModelProviderLineIndex = (lines: string[]): number => {
|
||||
return -1;
|
||||
};
|
||||
|
||||
const CODEX_CHAT_WIRE_API_VALUES = new Set([
|
||||
"chat",
|
||||
"chat_completions",
|
||||
"chat-completions",
|
||||
"openai_chat",
|
||||
"openai-chat",
|
||||
"openai_chat_completions",
|
||||
]);
|
||||
|
||||
// 判断给定的 wire_api 字符串是否表示 Codex 的 Chat Completions 协议
|
||||
export const isCodexChatWireApi = (
|
||||
wireApi: string | undefined | null,
|
||||
): boolean =>
|
||||
CODEX_CHAT_WIRE_API_VALUES.has(
|
||||
(wireApi ?? "").trim().toLowerCase(),
|
||||
);
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 wire_api(支持单/双引号)
|
||||
export const extractCodexWireApi = (
|
||||
configText: string | undefined | null,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const raw = typeof configText === "string" ? configText : "";
|
||||
const text = normalizeTomlText(raw);
|
||||
if (!text) return undefined;
|
||||
|
||||
const lines = text.split("\n");
|
||||
const targetSectionName = getCodexProviderSectionName(text);
|
||||
|
||||
if (targetSectionName) {
|
||||
const sectionRange = getTomlSectionRange(lines, targetSectionName);
|
||||
if (sectionRange) {
|
||||
const match = findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_WIRE_API_PATTERN,
|
||||
sectionRange.bodyStartIndex,
|
||||
sectionRange.bodyEndIndex,
|
||||
targetSectionName,
|
||||
);
|
||||
if (match?.value) {
|
||||
return match.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topLevelMatch = findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_WIRE_API_PATTERN,
|
||||
0,
|
||||
getTopLevelEndIndex(lines),
|
||||
);
|
||||
if (topLevelMatch?.value) {
|
||||
return topLevelMatch.value;
|
||||
}
|
||||
|
||||
const fallbackAssignments = getRecoverableCodexProviderAssignments(
|
||||
findTomlAssignments(lines, TOML_WIRE_API_PATTERN),
|
||||
targetSectionName,
|
||||
);
|
||||
return fallbackAssignments.length === 1
|
||||
? fallbackAssignments[0].value
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 在 Codex 的 TOML 配置文本中写入或更新 wire_api 字段
|
||||
export const setCodexWireApi = (
|
||||
configText: string,
|
||||
wireApi: "responses" | "chat",
|
||||
): string => {
|
||||
const normalizedText = normalizeTomlText(configText);
|
||||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||||
const targetSectionName = getCodexProviderSectionName(normalizedText);
|
||||
const replacementLine = `wire_api = "${wireApi}"`;
|
||||
const allAssignments = findTomlAssignments(lines, TOML_WIRE_API_PATTERN);
|
||||
const recoverableAssignments = getRecoverableCodexProviderAssignments(
|
||||
allAssignments,
|
||||
targetSectionName,
|
||||
);
|
||||
|
||||
if (targetSectionName) {
|
||||
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||||
const targetMatch = targetSectionRange
|
||||
? findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_WIRE_API_PATTERN,
|
||||
targetSectionRange.bodyStartIndex,
|
||||
targetSectionRange.bodyEndIndex,
|
||||
targetSectionName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (targetMatch) {
|
||||
lines[targetMatch.index] = replacementLine;
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
if (recoverableAssignments.length === 1) {
|
||||
lines.splice(recoverableAssignments[0].index, 1);
|
||||
targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||||
}
|
||||
|
||||
if (targetSectionRange) {
|
||||
const insertIndex = getTomlSectionInsertIndex(lines, targetSectionRange);
|
||||
lines.splice(insertIndex, 0, replacementLine);
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(`[${targetSectionName}]`, replacementLine);
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||||
const topLevelMatch = findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_WIRE_API_PATTERN,
|
||||
0,
|
||||
topLevelEndIndex,
|
||||
);
|
||||
if (topLevelMatch) {
|
||||
lines[topLevelMatch.index] = replacementLine;
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
|
||||
if (modelProviderIndex !== -1) {
|
||||
lines.splice(modelProviderIndex + 1, 0, replacementLine);
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return `${replacementLine}\n`;
|
||||
}
|
||||
|
||||
lines.splice(topLevelEndIndex, 0, replacementLine);
|
||||
return finalizeTomlText(lines);
|
||||
};
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||
export const extractCodexBaseUrl = (
|
||||
configText: string | undefined | null,
|
||||
|
||||
Reference in New Issue
Block a user