diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index af02c1b3c..dfadb5609 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -418,6 +418,8 @@ pub fn write_codex_live_atomic_with_stable_provider( /// Supported fields: /// - `"base_url"`: writes to `[model_providers.].base_url` if `model_provider` exists, /// otherwise falls back to top-level `base_url`. +/// - `"wire_api"`: writes to `[model_providers.].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" diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index d9dabfd76..3e8f2a20e 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -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 = 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) { + 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( diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs index 887b550fd..14ddaab70 100644 --- a/src-tauri/src/proxy/handler_config.rs +++ b/src-tauri/src/proxy/handler_config.rs @@ -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\"") } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 101f2f1de..956fc0bbb 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -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, +) -> Result { + 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 处理器 // ============================================================================ diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index bc5f36b4c..a46670b92 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -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 = /// 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 { + let doc = config_text.parse::().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 { + let doc = config_text.parse::().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" + )); + } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 174268fdb..24300286f 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -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; diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs new file mode 100644 index 000000000..b479d7cb4 --- /dev/null +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -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, + item_id: String, + text: String, + added: bool, + done: bool, +} + +#[derive(Debug, Default)] +struct ToolCallState { + output_index: Option, + 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, + output_items: Vec, + latest_usage: Option, + finish_reason: Option, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let mut events = Vec::new(); + let keys: Vec = self.tools.keys().copied().collect(); + + for key in keys { + let mut add_event: Option = 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 { + 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) -> 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( + stream: impl Stream> + Send + 'static, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut utf8_remainder: Vec = 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 = None; + let mut data_parts: Vec = 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) { + 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> = 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 = 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::(std::io::Error::other( + "boom", + ))]); + let converted = create_responses_sse_stream_from_chat(upstream); + let bytes: Vec = 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")); + } +} diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs new file mode 100644 index 000000000..de241a1d7 --- /dev/null +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -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 { + 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 = 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::>() + .join("\n\n"), + other => other.as_str().unwrap_or_default().to_string(), + } +} + +fn append_responses_input_as_chat_messages( + input: &Value, + messages: &mut Vec, +) -> 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, + pending_tool_calls: &mut Vec, +) -> 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, pending_tool_calls: &mut Vec) { + 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 = 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::>() + .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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 2d91c805e..da4d01c84 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -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 { 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#" diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index cfa46231d..9d131deb7 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -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)?.config; + return ( + typeof config === "string" && + isCodexChatWireApi(extractCodexWireApi(config)) + ); + }, [ + appId, + provider.category, + provider.meta?.apiFormat, + (provider.settingsConfig as Record)?.config, + ]); const isClaudeThirdParty = appId === "claude" && provider.category === "third_party"; @@ -345,6 +363,14 @@ export function ProviderCard({ )} + {codexNeedsRouting && ( + + {t("codex.needsRouting", { + defaultValue: "需要路由", + })} + + )} + {appId === "claude" && provider.category === "official" && ( {t("claudeCode.noRoutingSupport", { diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 55faec6ca..6690e7cdd 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -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 && ( +
+ + {t("providerForm.apiFormat", { defaultValue: "API 格式" })} + + +

+ {t("providerForm.codexApiFormatHint", { + defaultValue: + "选择供应商真实支持的 Codex API 格式;Chat Completions 会通过本地路由自动转换为 Responses。", + })} +

+
+ )} + {/* Codex Model Name 输入框 */} {shouldShowModelField && onModelNameChange && (
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index cc0b252f1..44ee06c21 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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(() => { + 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} diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 93928e591..6be4c6200 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -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; } /** diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 295609f06..e345fdf8a 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -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)?.config === + "string" && + isCodexChatWireApi( + extractCodexWireApi( + (provider.settingsConfig as Record).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" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ffda7a496..52ead9983 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 023b6bd50..bad19d1ed 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cf9144fad..e1ad3ef38 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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", diff --git a/src/types.ts b/src/types.ts index bdecd38f0..92958463d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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"; diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 20984a9fc..0d2f5b261 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -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,