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
+38 -6
View File
@@ -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"
+50 -17
View File
@@ -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(
+1 -1
View File
@@ -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\"")
}
+182 -3
View File
@@ -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 处理器
// ============================================================================
+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"
));
}
}
+3
View File
@@ -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");
}
}
+45 -3
View File
@@ -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#"
+27 -1
View File
@@ -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}
+3
View File
@@ -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;
}
/**
+18
View File
@@ -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"
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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 以兼容新版 CodexChat Completions 会通过本地路由自动转换。",
"authField": "认证字段",
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)",
"authFieldApiKey": "ANTHROPIC_API_KEY",
+6 -1
View File
@@ -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";
+147
View File
@@ -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,