mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Improve Codex Chat reasoning conversion
- Convert Chat reasoning_content and reasoning fields into Responses reasoning output items. - Emit Responses reasoning summary SSE events for streamed Chat reasoning deltas. - Preserve output ordering when reasoning, text, and tool calls are streamed together. - Add regression coverage for data-only Chat SSE errors, multiple tool calls, and compact routing.
This commit is contained in:
@@ -2793,6 +2793,15 @@ mod tests {
|
||||
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() {
|
||||
let (endpoint, passthrough_query) =
|
||||
rewrite_codex_responses_endpoint_to_chat("/v1/responses/compact?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(
|
||||
|
||||
@@ -464,4 +464,21 @@ wire_api = "chat"
|
||||
"/v1/responses/compact"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_compact() {
|
||||
let mut provider = create_provider(json!({
|
||||
"base_url": "https://example.com/v1"
|
||||
}));
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(codex_provider_uses_chat_completions(&provider));
|
||||
assert!(should_convert_codex_responses_to_chat(
|
||||
&provider,
|
||||
"/responses/compact?stream=true"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ struct TextItemState {
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ReasoningItemState {
|
||||
output_index: Option<u32>,
|
||||
item_id: String,
|
||||
text: String,
|
||||
added: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ToolCallState {
|
||||
output_index: Option<u32>,
|
||||
@@ -38,8 +47,9 @@ struct ChatToResponsesState {
|
||||
created_at: u64,
|
||||
next_output_index: u32,
|
||||
text: TextItemState,
|
||||
reasoning: ReasoningItemState,
|
||||
tools: BTreeMap<usize, ToolCallState>,
|
||||
output_items: Vec<Value>,
|
||||
output_items: Vec<(u32, Value)>,
|
||||
latest_usage: Option<Value>,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
@@ -54,6 +64,7 @@ impl Default for ChatToResponsesState {
|
||||
created_at: 0,
|
||||
next_output_index: 0,
|
||||
text: TextItemState::default(),
|
||||
reasoning: ReasoningItemState::default(),
|
||||
tools: BTreeMap::new(),
|
||||
output_items: Vec::new(),
|
||||
latest_usage: None,
|
||||
@@ -93,13 +104,19 @@ impl ChatToResponsesState {
|
||||
};
|
||||
|
||||
if let Some(delta) = choice.get("delta") {
|
||||
if let Some(reasoning) = chat_delta_reasoning_text(delta) {
|
||||
events.extend(self.push_reasoning_delta(&reasoning));
|
||||
}
|
||||
|
||||
if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
|
||||
if !content.is_empty() {
|
||||
events.extend(self.finalize_reasoning());
|
||||
events.extend(self.push_text_delta(content));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
events.extend(self.finalize_reasoning());
|
||||
for tool_call in tool_calls {
|
||||
events.extend(self.push_tool_call_delta(tool_call));
|
||||
}
|
||||
@@ -139,6 +156,60 @@ impl ChatToResponsesState {
|
||||
]
|
||||
}
|
||||
|
||||
fn push_reasoning_delta(&mut self, delta: &str) -> Vec<Bytes> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
if !self.reasoning.added {
|
||||
let output_index = self.next_output_index();
|
||||
let item_id = format!("rs_{}", self.response_id);
|
||||
self.reasoning.output_index = Some(output_index);
|
||||
self.reasoning.item_id = item_id.clone();
|
||||
self.reasoning.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": "reasoning",
|
||||
"status": "in_progress",
|
||||
"summary": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse_event(
|
||||
"response.reasoning_summary_part.added",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.added",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": {
|
||||
"type": "summary_text",
|
||||
"text": ""
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
self.reasoning.text.push_str(delta);
|
||||
let output_index = self.reasoning.output_index.unwrap_or(0);
|
||||
events.push(sse_event(
|
||||
"response.reasoning_summary_text.delta",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
));
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn push_text_delta(&mut self, delta: &str) -> Vec<Bytes> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
@@ -304,11 +375,12 @@ impl ChatToResponsesState {
|
||||
}
|
||||
|
||||
let mut events = self.ensure_response_started();
|
||||
events.extend(self.finalize_reasoning());
|
||||
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());
|
||||
let mut response = self.base_response(status, self.completed_output_items());
|
||||
if status == "incomplete" {
|
||||
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
|
||||
}
|
||||
@@ -324,6 +396,60 @@ impl ChatToResponsesState {
|
||||
events
|
||||
}
|
||||
|
||||
fn finalize_reasoning(&mut self) -> Vec<Bytes> {
|
||||
if !self.reasoning.added || self.reasoning.done {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let output_index = self.reasoning.output_index.unwrap_or(0);
|
||||
let item_id = self.reasoning.item_id.clone();
|
||||
let text = self.reasoning.text.clone();
|
||||
let item = json!({
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": text
|
||||
}]
|
||||
});
|
||||
self.output_items.push((output_index, item.clone()));
|
||||
self.reasoning.done = true;
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.reasoning_summary_text.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.done",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"text": self.reasoning.text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.reasoning_summary_part.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.done",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": {
|
||||
"type": "summary_text",
|
||||
"text": self.reasoning.text
|
||||
}
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn finalize_text(&mut self) -> Vec<Bytes> {
|
||||
if !self.text.added || self.text.done {
|
||||
return Vec::new();
|
||||
@@ -341,7 +467,7 @@ impl ChatToResponsesState {
|
||||
"annotations": []
|
||||
}]
|
||||
});
|
||||
self.output_items.push(item.clone());
|
||||
self.output_items.push((output_index, item.clone()));
|
||||
self.text.done = true;
|
||||
|
||||
vec![
|
||||
@@ -439,7 +565,7 @@ impl ChatToResponsesState {
|
||||
"arguments": state.arguments
|
||||
});
|
||||
state.done = true;
|
||||
self.output_items.push(item.clone());
|
||||
self.output_items.push((output_index, item.clone()));
|
||||
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.done",
|
||||
@@ -463,6 +589,15 @@ impl ChatToResponsesState {
|
||||
events
|
||||
}
|
||||
|
||||
fn completed_output_items(&self) -> Vec<Value> {
|
||||
let mut output_items = self.output_items.clone();
|
||||
output_items.sort_by_key(|(output_index, _)| *output_index);
|
||||
output_items
|
||||
.into_iter()
|
||||
.map(|(_, item)| item)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn base_response(&self, status: &str, output: Vec<Value>) -> Value {
|
||||
json!({
|
||||
"id": self.response_id,
|
||||
@@ -494,7 +629,7 @@ impl ChatToResponsesState {
|
||||
error["type"] = json!(error_type);
|
||||
}
|
||||
|
||||
let mut response = self.base_response("failed", self.output_items.clone());
|
||||
let mut response = self.base_response("failed", self.completed_output_items());
|
||||
response["error"] = error;
|
||||
|
||||
sse_event(
|
||||
@@ -507,6 +642,27 @@ impl ChatToResponsesState {
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_delta_reasoning_text(delta: &Value) -> Option<String> {
|
||||
for key in ["reasoning_content", "reasoning"] {
|
||||
if let Some(text) = delta.get(key).and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reasoning = delta.get("reasoning")?;
|
||||
for key in ["content", "text", "summary"] {
|
||||
if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -653,6 +809,29 @@ mod tests {
|
||||
assert!(output.contains("\"input_tokens\":4"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn converts_reasoning_content_chat_sse_to_responses_reasoning_events() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_reason\",\"created\":123,\"model\":\"deepseek-reasoner\",\"choices\":[{\"delta\":{\"reasoning_content\":\"Need context. \"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_reason\",\"created\":123,\"model\":\"deepseek-reasoner\",\"choices\":[{\"delta\":{\"reasoning\":\"Now answer. \"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_reason\",\"created\":123,\"model\":\"deepseek-reasoner\",\"choices\":[{\"delta\":{\"content\":\"Done\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":6,\"total_tokens\":10,\"completion_tokens_details\":{\"reasoning_tokens\":3}}}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.reasoning_summary_part.added"));
|
||||
assert!(output.contains("event: response.reasoning_summary_text.delta"));
|
||||
assert!(output.contains("event: response.reasoning_summary_text.done"));
|
||||
assert!(output.contains("Need context. Now answer. "));
|
||||
assert!(output.contains("\"type\":\"reasoning\""));
|
||||
assert!(output.contains("\"text\":\"Done\""));
|
||||
assert!(output.contains("\"reasoning_tokens\":3"));
|
||||
|
||||
let reasoning_pos = output.find("\"type\":\"reasoning\"").unwrap();
|
||||
let message_pos = output.find("\"type\":\"message\"").unwrap();
|
||||
assert!(reasoning_pos < message_pos);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn converts_tool_call_chat_sse_to_responses_sse() {
|
||||
let output = collect(vec![
|
||||
@@ -694,4 +873,18 @@ mod tests {
|
||||
assert!(output.contains("invalid_request_error"));
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_sse_data_only_error_emits_failed_without_completed() {
|
||||
let output = collect(vec![
|
||||
"data: {\"error\":{\"message\":\"quota exceeded\",\"code\":\"rate_limit_exceeded\"}}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.failed"));
|
||||
assert!(output.contains("quota exceeded"));
|
||||
assert!(output.contains("rate_limit_exceeded"));
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +371,9 @@ pub fn chat_completion_to_response(body: Value) -> Result<Value, ProxyError> {
|
||||
let finish_reason = choice.get("finish_reason").and_then(|v| v.as_str());
|
||||
|
||||
let mut output = Vec::new();
|
||||
if let Some(reasoning_item) = chat_reasoning_to_response_output_item(message, &response_id) {
|
||||
output.push(reasoning_item);
|
||||
}
|
||||
if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) {
|
||||
output.push(message_item);
|
||||
}
|
||||
@@ -393,6 +396,43 @@ pub fn chat_completion_to_response(body: Value) -> Result<Value, ProxyError> {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn chat_reasoning_to_response_output_item(message: &Value, response_id: &str) -> Option<Value> {
|
||||
let reasoning = chat_reasoning_text(message)?;
|
||||
if reasoning.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"id": format!("rs_{response_id}"),
|
||||
"type": "reasoning",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": reasoning
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
fn chat_reasoning_text(message: &Value) -> Option<String> {
|
||||
for key in ["reasoning_content", "reasoning"] {
|
||||
if let Some(text) = message.get(key).and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reasoning = message.get("reasoning")?;
|
||||
for key in ["content", "text", "summary"] {
|
||||
if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> Option<Value> {
|
||||
let mut content = Vec::new();
|
||||
|
||||
@@ -650,6 +690,55 @@ mod tests {
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_keeps_multiple_tool_calls_adjacent_to_outputs() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\":\"README.md\"}"
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_2",
|
||||
"name": "list_files",
|
||||
"arguments": "{\"path\":\"src\"}"
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "Readme content"
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_2",
|
||||
"output": ["main.rs", "lib.rs"]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Continue"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
let messages = result["messages"].as_array().unwrap();
|
||||
|
||||
assert_eq!(messages.len(), 4);
|
||||
assert_eq!(messages[0]["role"], "assistant");
|
||||
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
|
||||
assert_eq!(messages[0]["tool_calls"][1]["id"], "call_2");
|
||||
assert_eq!(messages[1]["role"], "tool");
|
||||
assert_eq!(messages[1]["tool_call_id"], "call_1");
|
||||
assert_eq!(messages[2]["role"], "tool");
|
||||
assert_eq!(messages[2]["tool_call_id"], "call_2");
|
||||
assert_eq!(messages[2]["content"], "[\"main.rs\",\"lib.rs\"]");
|
||||
assert_eq!(messages[3]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_to_responses_maps_text_tool_calls_and_usage() {
|
||||
let input = json!({
|
||||
@@ -660,6 +749,7 @@ mod tests {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"reasoning_content": "I should check the weather before answering.",
|
||||
"content": "Let me check.",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
@@ -684,10 +774,15 @@ mod tests {
|
||||
|
||||
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["output"][0]["type"], "reasoning");
|
||||
assert_eq!(
|
||||
result["output"][0]["summary"][0]["text"],
|
||||
"I should check the weather before answering."
|
||||
);
|
||||
assert_eq!(result["output"][1]["type"], "message");
|
||||
assert_eq!(result["output"][1]["content"][0]["text"], "Let me check.");
|
||||
assert_eq!(result["output"][2]["type"], "function_call");
|
||||
assert_eq!(result["output"][2]["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);
|
||||
|
||||
Reference in New Issue
Block a user