From b1a089d21d6334fa0e80f31a0794dbd72d7eab0e Mon Sep 17 00:00:00 2001 From: Nikketryhard Date: Sat, 14 Feb 2026 19:57:52 -0600 Subject: [PATCH] feat: emit streaming reasoning events per OpenAI spec Adds proper streaming SSE events for reasoning content: - response.output_item.added (reasoning) - response.reasoning_summary_part.added - response.reasoning_summary_text.delta - response.reasoning_summary_text.done - response.reasoning_summary_part.done - response.output_item.done (reasoning) These are emitted before the message events, matching the format that OpenAI-compatible clients expect for displaying thinking content. --- src/api/responses.rs | 183 +++++++++++++++++++++++++++++++------------ 1 file changed, 135 insertions(+), 48 deletions(-) diff --git a/src/api/responses.rs b/src/api/responses.rs index 01e4417..38ccd1f 100644 --- a/src/api/responses.rs +++ b/src/api/responses.rs @@ -620,11 +620,97 @@ fn completion_events( // Build output array: [reasoning (if present), message] let mut output_items: Vec = Vec::new(); + let mut events: Vec = Vec::new(); + if let Some(ref thinking_text) = thinking { - output_items.push(build_reasoning_output(thinking_text)); + let reasoning_item = build_reasoning_output(thinking_text); + let reasoning_id = reasoning_item["id"].as_str().unwrap_or("rs_0").to_string(); + output_items.push(reasoning_item.clone()); + + // ── Reasoning streaming events (OpenAI spec) ── + // 1. response.output_item.added (reasoning item) + events.push(responses_sse_event( + "response.output_item.added", + serde_json::json!({ + "type": "response.output_item.added", + "sequence_number": next_seq(), + "output_index": 0, + "item": { + "id": reasoning_id, + "type": "reasoning", + "summary": [], + }, + }), + )); + // 2. response.reasoning_summary_part.added + events.push(responses_sse_event( + "response.reasoning_summary_part.added", + serde_json::json!({ + "type": "response.reasoning_summary_part.added", + "sequence_number": next_seq(), + "item_id": reasoning_id, + "output_index": 0, + "summary_index": 0, + "part": { + "type": "summary_text", + "text": "", + }, + }), + )); + // 3. response.reasoning_summary_text.delta + events.push(responses_sse_event( + "response.reasoning_summary_text.delta", + serde_json::json!({ + "type": "response.reasoning_summary_text.delta", + "sequence_number": next_seq(), + "item_id": reasoning_id, + "output_index": 0, + "summary_index": 0, + "delta": thinking_text, + }), + )); + // 4. response.reasoning_summary_text.done + events.push(responses_sse_event( + "response.reasoning_summary_text.done", + serde_json::json!({ + "type": "response.reasoning_summary_text.done", + "sequence_number": next_seq(), + "item_id": reasoning_id, + "output_index": 0, + "summary_index": 0, + "text": thinking_text, + }), + )); + // 5. response.reasoning_summary_part.done + events.push(responses_sse_event( + "response.reasoning_summary_part.done", + serde_json::json!({ + "type": "response.reasoning_summary_part.done", + "sequence_number": next_seq(), + "item_id": reasoning_id, + "output_index": 0, + "summary_index": 0, + "part": { + "type": "summary_text", + "text": thinking_text, + }, + }), + )); + // 6. response.output_item.done (reasoning item complete) + events.push(responses_sse_event( + "response.output_item.done", + serde_json::json!({ + "type": "response.output_item.done", + "sequence_number": next_seq(), + "output_index": 0, + "item": reasoning_item, + }), + )); } output_items.push(build_message_output(msg_id, text)); + let msg_output_index = if thinking.is_some() { 1 } else { 0 }; + let completed_resp = build_response_object( ResponseData { id: resp_id.to_string(), @@ -639,52 +725,53 @@ fn completion_events( params, ); - vec![ - // 1. response.output_text.done - responses_sse_event( - "response.output_text.done", - serde_json::json!({ - "type": "response.output_text.done", - "sequence_number": next_seq(), - "item_id": msg_id, - "output_index": out_idx, - "content_index": content_idx, + // ── Message streaming events ── + // 1. response.output_text.done + events.push(responses_sse_event( + "response.output_text.done", + serde_json::json!({ + "type": "response.output_text.done", + "sequence_number": next_seq(), + "item_id": msg_id, + "output_index": msg_output_index, + "content_index": content_idx, + "text": text, + }), + )); + // 2. response.content_part.done + events.push(responses_sse_event( + "response.content_part.done", + serde_json::json!({ + "type": "response.content_part.done", + "sequence_number": next_seq(), + "output_index": msg_output_index, + "content_index": content_idx, + "part": { + "type": "output_text", "text": text, - }), - ), - // 2. response.content_part.done - responses_sse_event( - "response.content_part.done", - serde_json::json!({ - "type": "response.content_part.done", - "sequence_number": next_seq(), - "output_index": out_idx, - "content_index": content_idx, - "part": { - "type": "output_text", - "text": text, - "annotations": [], - }, - }), - ), - // 3. response.output_item.done - responses_sse_event( - "response.output_item.done", - serde_json::json!({ - "type": "response.output_item.done", - "sequence_number": next_seq(), - "output_index": out_idx, - "item": output_item, - }), - ), - // 4. response.completed - responses_sse_event( - "response.completed", - serde_json::json!({ - "type": "response.completed", - "sequence_number": next_seq(), - "response": response_to_json(&completed_resp), - }), - ), - ] + "annotations": [], + }, + }), + )); + // 3. response.output_item.done + events.push(responses_sse_event( + "response.output_item.done", + serde_json::json!({ + "type": "response.output_item.done", + "sequence_number": next_seq(), + "output_index": msg_output_index, + "item": output_item, + }), + )); + // 4. response.completed + events.push(responses_sse_event( + "response.completed", + serde_json::json!({ + "type": "response.completed", + "sequence_number": next_seq(), + "response": response_to_json(&completed_resp), + }), + )); + + events }