1use crate::llm::error_display;
2use crate::llm::provider::{LLMError, LLMNormalizedStream, LLMResponse, NormalizedStreamEvent};
3use crate::llm::providers::shared::{extract_data_payload, find_sse_boundary};
4use async_stream::try_stream;
5use futures::StreamExt;
6use hashbrown::{HashMap, HashSet};
7use serde_json::{Value, json};
8
9use super::{StreamAggregator, parse_cached_prompt_tokens_from_usage};
10
11pub struct ResponsesNormalizedStreamOptions {
19 pub provider_name: &'static str,
20 pub model: String,
21 pub emit_reasoning: bool,
22 pub include_cached_prompt_metrics: bool,
23}
24
25struct ResponsesNormalizedStreamProcessor<P> {
26 options: ResponsesNormalizedStreamOptions,
27 parse_final_response: P,
28 aggregator: StreamAggregator,
29 seen_tool_calls: HashSet<String>,
30 tool_call_indexes: HashMap<String, usize>,
31 tool_call_names: HashMap<String, String>,
32 next_tool_call_index: usize,
33 final_response: Option<Value>,
34 done: bool,
35}
36
37impl<P> ResponsesNormalizedStreamProcessor<P>
38where
39 P: Fn(Value) -> Result<LLMResponse, LLMError>,
40{
41 fn new(options: ResponsesNormalizedStreamOptions, parse_final_response: P) -> Self {
42 Self {
43 aggregator: StreamAggregator::new(options.model.clone()),
44 options,
45 parse_final_response,
46 seen_tool_calls: HashSet::new(),
47 tool_call_indexes: HashMap::new(),
48 tool_call_names: HashMap::new(),
49 next_tool_call_index: 0,
50 final_response: None,
51 done: false,
52 }
53 }
54
55 fn is_done(&self) -> bool {
56 self.done
57 }
58
59 fn handle_payload(&mut self, payload: Value) -> Result<Vec<NormalizedStreamEvent>, LLMError> {
60 let mut events = Vec::new();
61
62 if let Some(usage) = payload.get("usage").cloned()
63 && let Ok(usage) = serde_json::from_value(usage)
64 {
65 self.aggregator.set_usage(usage);
66 }
67
68 let event_type = payload.get("type").and_then(Value::as_str).unwrap_or("");
69 match event_type {
70 "response.output_text.delta" => {
71 let delta = payload
72 .get("delta")
73 .and_then(Value::as_str)
74 .ok_or_else(|| provider_error(self.options.provider_name, "missing delta"))?;
75 for event in self.aggregator.handle_content(delta) {
76 match event {
77 crate::llm::provider::LLMStreamEvent::Token { delta } => {
78 events.push(NormalizedStreamEvent::TextDelta { delta });
79 }
80 crate::llm::provider::LLMStreamEvent::Reasoning { delta }
81 if self.options.emit_reasoning =>
82 {
83 events.push(NormalizedStreamEvent::ReasoningDelta { delta });
84 }
85 _ => {}
86 }
87 }
88 }
89 "response.refusal.delta" => {
90 let delta = payload
91 .get("delta")
92 .and_then(Value::as_str)
93 .ok_or_else(|| provider_error(self.options.provider_name, "missing delta"))?;
94 if !delta.is_empty() {
95 self.aggregator.content.push_str(delta);
96 events.push(NormalizedStreamEvent::TextDelta {
97 delta: delta.to_string(),
98 });
99 }
100 }
101 "response.reasoning_text.delta"
102 | "response.reasoning_summary_text.delta"
103 | "response.reasoning_content.delta" => {
104 if self.options.emit_reasoning
105 && let Some(delta) = payload.get("delta").and_then(Value::as_str)
106 && let Some(delta) = self.aggregator.handle_reasoning(delta)
107 {
108 events.push(NormalizedStreamEvent::ReasoningDelta { delta });
109 }
110 }
111 "response.output_item.added" | "response.output_item.done" => {
112 if let Some(item) = payload.get("item") {
113 let tool_call = self.capture_tool_call_metadata(
114 item,
115 payload
116 .get("output_index")
117 .and_then(Value::as_u64)
118 .map(|value| value as usize),
119 );
120 if let Some((call_id, name)) = tool_call {
121 self.push_tool_call_start(&mut events, call_id, Some(name));
122 }
123 }
124 }
125 "response.function_call_arguments.delta" => {
126 let delta = payload
127 .get("delta")
128 .and_then(Value::as_str)
129 .ok_or_else(|| provider_error(self.options.provider_name, "missing delta"))?;
130 let call_id = payload
131 .get("item_id")
132 .and_then(Value::as_str)
133 .or_else(|| payload.get("call_id").and_then(Value::as_str))
134 .filter(|value| !value.is_empty())
135 .map(ToOwned::to_owned)
136 .unwrap_or_else(|| format!("tool_call_{}", self.next_tool_call_index));
137 let index = self.resolve_tool_call_index(
138 &call_id,
139 payload
140 .get("output_index")
141 .and_then(Value::as_u64)
142 .map(|value| value as usize),
143 );
144
145 let name = self.tool_call_names.get(&call_id).cloned();
146 self.push_tool_call_start(&mut events, call_id.clone(), name);
147
148 if !delta.is_empty() {
149 self.aggregator.handle_tool_calls(&[json!({
150 "index": index,
151 "id": call_id,
152 "function": {
153 "arguments": delta,
154 }
155 })]);
156 events.push(NormalizedStreamEvent::ToolCallDelta {
157 call_id,
158 delta: delta.to_string(),
159 });
160 }
161 }
162 "response.completed" => {
163 if let Some(response) = payload.get("response") {
164 self.final_response = Some(response.clone());
165 }
166 self.done = true;
167 }
168 "response.failed" | "response.incomplete" | "error" => {
169 let message = extract_error_message(&payload)
170 .unwrap_or_else(|| "unknown error from responses stream".to_string());
171 return Err(provider_error(self.options.provider_name, message));
172 }
173 _ => {}
174 }
175
176 Ok(events)
177 }
178
179 fn finish(self) -> Result<Vec<NormalizedStreamEvent>, LLMError> {
180 let streamed = self.aggregator.finalize();
181 let mut response = if let Some(final_response) = self.final_response {
182 match (self.parse_final_response)(final_response.clone()) {
183 Ok(response) => response,
184 Err(_)
185 if final_response_output_is_empty(&final_response)
186 && streamed_response_is_usable(&streamed) =>
187 {
188 let mut response = streamed.clone();
189 merge_final_response_metadata(
190 &mut response,
191 &final_response,
192 self.options.include_cached_prompt_metrics,
193 );
194 response
195 }
196 Err(err) => return Err(err),
197 }
198 } else {
199 streamed.clone()
200 };
201
202 merge_streamed_response(&mut response, streamed);
203
204 let mut events = Vec::new();
205 if let Some(usage) = response.usage.clone() {
206 events.push(NormalizedStreamEvent::Usage { usage });
207 }
208 events.push(NormalizedStreamEvent::Done {
209 response: Box::new(response),
210 });
211 Ok(events)
212 }
213
214 fn capture_tool_call_metadata(
215 &mut self,
216 item: &Value,
217 output_index: Option<usize>,
218 ) -> Option<(String, String)> {
219 let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
220 if item_type != "function_call" {
221 return None;
222 }
223
224 let call_id = item
225 .get("id")
226 .and_then(Value::as_str)
227 .or_else(|| item.get("call_id").and_then(Value::as_str))
228 .filter(|value| !value.is_empty());
229 let name = item.get("name").and_then(Value::as_str).or_else(|| {
230 item.get("function")
231 .and_then(|function| function.get("name"))
232 .and_then(Value::as_str)
233 });
234 if let (Some(call_id), Some(name)) = (call_id, name) {
235 self.tool_call_names
236 .entry(call_id.to_string())
237 .or_insert_with(|| name.to_string());
238 let index = self.resolve_tool_call_index(call_id, output_index);
239 self.aggregator.handle_tool_calls(&[json!({
240 "index": index,
241 "id": call_id,
242 "function": {
243 "name": name,
244 }
245 })]);
246 return Some((call_id.to_string(), name.to_string()));
247 }
248
249 None
250 }
251
252 fn push_tool_call_start(
253 &mut self,
254 events: &mut Vec<NormalizedStreamEvent>,
255 call_id: String,
256 name: Option<String>,
257 ) {
258 if self.seen_tool_calls.insert(call_id.clone()) {
259 events.push(NormalizedStreamEvent::ToolCallStart { call_id, name });
260 }
261 }
262
263 fn resolve_tool_call_index(&mut self, call_id: &str, output_index: Option<usize>) -> usize {
264 if let Some(index) = output_index {
265 self.tool_call_indexes.insert(call_id.to_string(), index);
266 self.next_tool_call_index = self.next_tool_call_index.max(index + 1);
267 return index;
268 }
269
270 if let Some(index) = self.tool_call_indexes.get(call_id).copied() {
271 return index;
272 }
273
274 let index = self.next_tool_call_index;
275 self.tool_call_indexes.insert(call_id.to_string(), index);
276 self.next_tool_call_index += 1;
277 index
278 }
279}
280
281pub fn create_responses_normalized_stream<P>(
282 response: reqwest::Response,
283 options: ResponsesNormalizedStreamOptions,
284 parse_final_response: P,
285) -> LLMNormalizedStream
286where
287 P: Fn(Value) -> Result<LLMResponse, LLMError> + Send + 'static,
288{
289 let stream = try_stream! {
290 let provider_name = options.provider_name;
291 let mut processor = ResponsesNormalizedStreamProcessor::new(options, parse_final_response);
292 let mut body_stream = response.bytes_stream();
293 let mut buffer = String::new();
294
295 while let Some(chunk_result) = body_stream.next().await {
296 let chunk = chunk_result.map_err(|err| provider_error(
297 provider_name,
298 format!("streaming error: {err}"),
299 ))?;
300 buffer.push_str(&String::from_utf8_lossy(&chunk));
301
302 while let Some((split_idx, delimiter_len)) = find_sse_boundary(&buffer) {
303 let event = buffer[..split_idx].to_string();
304 buffer.drain(..split_idx + delimiter_len);
305
306 if let Some(data_payload) = extract_data_payload(&event) {
307 let trimmed_payload = data_payload.trim();
308 if trimmed_payload.is_empty() || trimmed_payload == "[DONE]" {
309 continue;
310 }
311
312 let payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| {
313 provider_error(provider_name, format!("invalid stream payload: {err}"))
314 })?;
315
316 for event in processor.handle_payload(payload)? {
317 yield event;
318 }
319
320 if processor.is_done() {
321 break;
322 }
323 }
324 }
325
326 if processor.is_done() {
327 break;
328 }
329 }
330
331 for event in processor.finish()? {
332 yield event;
333 }
334 };
335
336 Box::pin(stream)
337}
338
339fn streamed_response_is_usable(response: &LLMResponse) -> bool {
340 response
341 .content
342 .as_deref()
343 .is_some_and(|content| !content.is_empty())
344 || response
345 .tool_calls
346 .as_ref()
347 .is_some_and(|tool_calls| !tool_calls.is_empty())
348 || response
349 .reasoning
350 .as_deref()
351 .is_some_and(|reasoning| !reasoning.is_empty())
352 || response
353 .reasoning_details
354 .as_ref()
355 .is_some_and(|details| !details.is_empty())
356}
357
358fn final_response_output_is_empty(final_response: &Value) -> bool {
359 final_response
360 .get("output")
361 .and_then(Value::as_array)
362 .is_some_and(Vec::is_empty)
363}
364
365fn merge_streamed_response(response: &mut LLMResponse, streamed: LLMResponse) {
366 if response.content.as_deref().unwrap_or_default().is_empty() {
367 response.content = streamed.content;
368 } else if let (Some(content), Some(streamed_content)) =
369 (&mut response.content, streamed.content)
370 && !streamed_content.is_empty()
371 && !content.contains(&streamed_content)
372 {
373 content.push_str(&streamed_content);
374 }
375
376 if response.tool_calls.is_none() {
377 response.tool_calls = streamed.tool_calls;
378 }
379
380 if response.usage.is_none() {
381 response.usage = streamed.usage;
382 }
383
384 if response.reasoning.is_none() {
385 response.reasoning = streamed.reasoning;
386 }
387
388 if response.reasoning_details.is_none() {
389 response.reasoning_details = streamed.reasoning_details;
390 }
391
392 if response.tool_references.is_empty() && !streamed.tool_references.is_empty() {
393 response.tool_references = streamed.tool_references;
394 }
395
396 if response.request_id.is_none() {
397 response.request_id = streamed.request_id;
398 }
399
400 if response.organization_id.is_none() {
401 response.organization_id = streamed.organization_id;
402 }
403}
404
405fn merge_final_response_metadata(
406 response: &mut LLMResponse,
407 final_response: &Value,
408 include_cached_prompt_metrics: bool,
409) {
410 if let Some(usage) = parse_responses_usage(final_response, include_cached_prompt_metrics) {
411 response.usage = Some(usage);
412 }
413
414 if let Some(request_id) = final_response
415 .get("id")
416 .and_then(Value::as_str)
417 .or_else(|| final_response.get("request_id").and_then(Value::as_str))
418 {
419 response.request_id = Some(request_id.to_string());
420 }
421}
422
423fn parse_responses_usage(
424 final_response: &Value,
425 include_cached_prompt_metrics: bool,
426) -> Option<crate::llm::provider::Usage> {
427 let usage_value = final_response.get("usage")?;
428 let cached_prompt_tokens =
429 parse_cached_prompt_tokens_from_usage(usage_value, include_cached_prompt_metrics);
430 Some(crate::llm::provider::Usage {
431 prompt_tokens: usage_value
432 .get("input_tokens")
433 .or_else(|| usage_value.get("prompt_tokens"))
434 .and_then(Value::as_u64)
435 .and_then(|value| u32::try_from(value).ok())
436 .unwrap_or(0),
437 completion_tokens: usage_value
438 .get("output_tokens")
439 .or_else(|| usage_value.get("completion_tokens"))
440 .and_then(Value::as_u64)
441 .and_then(|value| u32::try_from(value).ok())
442 .unwrap_or(0),
443 total_tokens: usage_value
444 .get("total_tokens")
445 .and_then(Value::as_u64)
446 .and_then(|value| u32::try_from(value).ok())
447 .unwrap_or(0),
448 cached_prompt_tokens,
449 cache_creation_tokens: None,
450 cache_read_tokens: None,
451 iterations: None,
452 })
453}
454
455fn extract_error_message(payload: &Value) -> Option<String> {
456 payload
457 .get("error")
458 .and_then(|error| error.get("message"))
459 .and_then(Value::as_str)
460 .map(ToOwned::to_owned)
461 .or_else(|| {
462 payload
463 .get("response")
464 .and_then(|response| response.get("error"))
465 .and_then(|error| error.get("message"))
466 .and_then(Value::as_str)
467 .map(ToOwned::to_owned)
468 })
469}
470
471fn provider_error(provider_name: &str, message: impl Into<String>) -> LLMError {
472 let message = error_display::format_llm_error(provider_name, &message.into());
473 LLMError::Provider {
474 message,
475 metadata: None,
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::{
482 ResponsesNormalizedStreamOptions, ResponsesNormalizedStreamProcessor, provider_error,
483 };
484 use crate::llm::provider::{FinishReason, LLMResponse, NormalizedStreamEvent, ToolCall};
485 use serde_json::{Value, json};
486
487 fn options() -> ResponsesNormalizedStreamOptions {
488 ResponsesNormalizedStreamOptions {
489 provider_name: "TestProvider",
490 model: "gpt-5".to_string(),
491 emit_reasoning: true,
492 include_cached_prompt_metrics: false,
493 }
494 }
495
496 fn parse_response(value: Value) -> Result<LLMResponse, crate::llm::provider::LLMError> {
497 let content = value
498 .get("output")
499 .and_then(Value::as_array)
500 .and_then(|items| items.first())
501 .and_then(|item| item.get("content"))
502 .and_then(Value::as_array)
503 .and_then(|content| content.first())
504 .and_then(|item| item.get("text"))
505 .and_then(Value::as_str)
506 .map(ToOwned::to_owned);
507
508 Ok(LLMResponse {
509 content,
510 model: "gpt-5".to_string(),
511 finish_reason: FinishReason::Stop,
512 ..Default::default()
513 })
514 }
515
516 #[test]
517 fn text_delta_and_completed_yield_text_then_done() {
518 let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
519
520 let events = processor
521 .handle_payload(json!({
522 "type": "response.output_text.delta",
523 "delta": "hello"
524 }))
525 .expect("text delta should parse");
526 assert!(matches!(
527 events.as_slice(),
528 [NormalizedStreamEvent::TextDelta { delta }] if delta == "hello"
529 ));
530
531 let completed_events = processor
532 .handle_payload(json!({
533 "type": "response.completed",
534 "response": {
535 "output": [{
536 "type": "message",
537 "content": [{"type": "output_text", "text": "hello"}]
538 }]
539 }
540 }))
541 .expect("completed event should parse");
542 assert!(completed_events.is_empty());
543
544 let finished = processor.finish().expect("finish should succeed");
545 assert!(matches!(
546 finished.as_slice(),
547 [NormalizedStreamEvent::Done { response }]
548 if response.content.as_deref() == Some("hello")
549 ));
550 }
551
552 #[test]
553 fn empty_final_response_uses_streamed_text_and_preserves_metadata() {
554 let mut options = options();
555 options.include_cached_prompt_metrics = true;
556 let mut processor = ResponsesNormalizedStreamProcessor::new(options, |value| {
557 let output = value
558 .get("output")
559 .and_then(Value::as_array)
560 .ok_or_else(|| provider_error("TestProvider", "missing output"))?;
561 if output.is_empty() {
562 return Err(provider_error("TestProvider", "No output in response"));
563 }
564 parse_response(value)
565 });
566
567 processor
568 .handle_payload(json!({
569 "type": "response.output_text.delta",
570 "delta": "streamed answer"
571 }))
572 .expect("text delta should parse");
573 processor
574 .handle_payload(json!({
575 "type": "response.completed",
576 "response": {
577 "id": "resp_streamed",
578 "output": [],
579 "usage": {
580 "input_tokens": 11,
581 "output_tokens": 7,
582 "total_tokens": 18,
583 "input_tokens_details": {
584 "cached_tokens": 5
585 }
586 }
587 }
588 }))
589 .expect("completed event should parse");
590
591 let finished = processor.finish().expect("finish should succeed");
592 let [
593 NormalizedStreamEvent::Usage { usage },
594 NormalizedStreamEvent::Done { response },
595 ] = finished.as_slice()
596 else {
597 panic!("expected usage then done");
598 };
599 assert_eq!(usage.prompt_tokens, 11);
600 assert_eq!(usage.completion_tokens, 7);
601 assert_eq!(usage.total_tokens, 18);
602 assert_eq!(usage.cached_prompt_tokens, Some(5));
603 assert_eq!(response.content.as_deref(), Some("streamed answer"));
604 assert_eq!(response.request_id.as_deref(), Some("resp_streamed"));
605 }
606
607 #[test]
608 fn tool_call_deltas_emit_start_and_finish_with_assembled_tool_call() {
609 let mut processor = ResponsesNormalizedStreamProcessor::new(options(), |_| {
610 Ok(LLMResponse {
611 model: "gpt-5".to_string(),
612 ..Default::default()
613 })
614 });
615
616 let started = processor
617 .handle_payload(json!({
618 "type": "response.output_item.added",
619 "output_index": 0,
620 "item": {
621 "type": "function_call",
622 "id": "call_1",
623 "name": "search_workspace"
624 }
625 }))
626 .expect("output item metadata should parse");
627 assert!(matches!(
628 started.as_slice(),
629 [NormalizedStreamEvent::ToolCallStart { call_id, name }]
630 if call_id == "call_1" && name.as_deref() == Some("search_workspace")
631 ));
632
633 let first = processor
634 .handle_payload(json!({
635 "type": "response.function_call_arguments.delta",
636 "item_id": "call_1",
637 "delta": "{\"query\":\"vt"
638 }))
639 .expect("first tool delta should parse");
640 assert!(matches!(
641 first.as_slice(),
642 [NormalizedStreamEvent::ToolCallDelta { call_id: delta_call_id, delta }]
643 if delta_call_id == "call_1"
644 && delta == "{\"query\":\"vt"
645 ));
646
647 let second = processor
648 .handle_payload(json!({
649 "type": "response.function_call_arguments.delta",
650 "item_id": "call_1",
651 "delta": "code\"}"
652 }))
653 .expect("second tool delta should parse");
654 assert!(matches!(
655 second.as_slice(),
656 [NormalizedStreamEvent::ToolCallDelta { call_id, delta }]
657 if call_id == "call_1" && delta == "code\"}"
658 ));
659
660 let finished = processor.finish().expect("finish should succeed");
661 let response = match finished.as_slice() {
662 [NormalizedStreamEvent::Done { response }] => response,
663 _ => panic!("expected done event"),
664 };
665 let tool_calls = response
666 .tool_calls
667 .as_ref()
668 .expect("tool call should be assembled");
669 assert_eq!(
670 tool_calls,
671 &vec![ToolCall::function(
672 "call_1".to_string(),
673 "search_workspace".to_string(),
674 "{\"query\":\"vtcode\"}".to_string(),
675 )]
676 );
677 }
678
679 #[test]
680 fn refusal_delta_streams_visible_output() {
681 let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
682
683 let events = processor
684 .handle_payload(json!({
685 "type": "response.refusal.delta",
686 "delta": "I can't help with that"
687 }))
688 .expect("refusal delta should parse");
689 assert!(matches!(
690 events.as_slice(),
691 [NormalizedStreamEvent::TextDelta { delta }]
692 if delta == "I can't help with that"
693 ));
694
695 let finished = processor.finish().expect("finish should succeed");
696 assert!(matches!(
697 finished.as_slice(),
698 [NormalizedStreamEvent::Done { response }]
699 if response.content.as_deref() == Some("I can't help with that")
700 ));
701 }
702
703 #[test]
704 fn failed_incomplete_and_error_events_surface_backend_message() {
705 for payload in [
706 json!({"type": "response.failed", "response": {"error": {"message": "failed"}}}),
707 json!({"type": "response.incomplete", "response": {"error": {"message": "incomplete"}}}),
708 json!({"type": "error", "error": {"message": "errored"}}),
709 ] {
710 let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
711 let error = processor
712 .handle_payload(payload)
713 .expect_err("error payload should fail");
714 assert!(
715 error.to_string().contains("failed")
716 || error.to_string().contains("incomplete")
717 || error.to_string().contains("errored")
718 );
719 }
720 }
721
722 #[test]
723 fn unknown_documented_events_are_ignored() {
724 let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
725 let events = processor
726 .handle_payload(json!({
727 "type": "response.file_search_call.searching",
728 "query": "needle"
729 }))
730 .expect("unknown documented event should be ignored");
731 assert!(events.is_empty());
732 processor
733 .handle_payload(json!({
734 "type": "response.code_interpreter_call.code.delta",
735 "delta": "print(1)"
736 }))
737 .expect("code interpreter event should be ignored");
738
739 let finished = processor.finish().expect("finish should succeed");
740 assert!(matches!(
741 finished.as_slice(),
742 [NormalizedStreamEvent::Done { .. }]
743 ));
744 }
745
746 #[test]
747 fn missing_delta_reports_provider_error() {
748 let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
749 let error = processor
750 .handle_payload(json!({"type": "response.output_text.delta"}))
751 .expect_err("missing delta should fail");
752 assert_eq!(
753 error.to_string(),
754 provider_error("TestProvider", "missing delta").to_string()
755 );
756 }
757}