Skip to main content

nemo_relay/codec/
openai_responses.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Built-in codec for the OpenAI Responses API.
5//!
6//! Implements [`LlmCodec`] (request decode/encode) and [`LlmResponseCodec`]
7//! (response decode) for the OpenAI Responses API format.
8//!
9//! The Responses API differs significantly from Chat Completions:
10//! - **Response**: Heterogeneous `output` array (message, function_call, reasoning)
11//!   instead of `choices[0].message`.
12//! - **Finish reason**: Derived from `status` + `incomplete_details.reason`
13//!   instead of `finish_reason` field.
14//! - **Request**: Uses `input` (string or array) instead of `messages`, and
15//!   `instructions` (top-level) instead of system message.
16//! - **Max tokens**: `max_output_tokens` instead of `max_tokens`.
17
18use serde::Deserialize;
19
20use crate::api::llm::LlmRequest;
21use crate::error::{FlowError, Result};
22use crate::json::Json;
23
24use super::request::{
25    AnnotatedLlmRequest, GenerationParams, Message, MessageContent, ToolChoice, ToolChoiceFunction,
26    ToolChoiceFunctionName, ToolDefinition,
27};
28use super::response::{
29    AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage,
30    estimate_cost_for_provider, infer_model_provider, provider_reported_cost,
31};
32use super::traits::{LlmCodec, LlmResponseCodec};
33
34// ---------------------------------------------------------------------------
35// Public codec struct
36// ---------------------------------------------------------------------------
37
38/// Built-in codec for the OpenAI Responses API.
39pub struct OpenAIResponsesCodec;
40
41// ---------------------------------------------------------------------------
42// Private intermediate serde structs for response decode
43// ---------------------------------------------------------------------------
44
45#[derive(Deserialize)]
46struct RawResponsesResponse {
47    id: Option<String>,
48    model: Option<String>,
49    status: Option<String>,
50    output: Option<Vec<Json>>,
51    usage: Option<RawResponsesUsage>,
52    incomplete_details: Option<Json>,
53    previous_response_id: Option<String>,
54    store: Option<bool>,
55    service_tier: Option<String>,
56    truncation: Option<Json>,
57    reasoning: Option<Json>,
58    #[serde(flatten)]
59    extra: serde_json::Map<String, Json>,
60}
61
62#[derive(Deserialize)]
63struct RawResponsesUsage {
64    input_tokens: Option<u64>,
65    output_tokens: Option<u64>,
66    total_tokens: Option<u64>,
67    input_tokens_details: Option<RawInputTokensDetails>,
68    output_tokens_details: Option<RawOutputTokensDetails>,
69    #[serde(rename = "cost_usd")]
70    provider_cost: Option<f64>,
71    cost: Option<RawUsageCost>,
72}
73
74#[derive(Deserialize, Clone)]
75struct RawInputTokensDetails {
76    cached_tokens: Option<u64>,
77    #[serde(flatten)]
78    extra: serde_json::Map<String, Json>,
79}
80
81#[derive(Deserialize, Clone)]
82struct RawOutputTokensDetails {
83    reasoning_tokens: Option<u64>,
84    #[serde(flatten)]
85    extra: serde_json::Map<String, Json>,
86}
87
88// ---------------------------------------------------------------------------
89// Helper functions
90// ---------------------------------------------------------------------------
91
92/// Map Responses API `status` + `incomplete_details` to normalized [`FinishReason`].
93fn map_responses_finish_reason(
94    status: Option<&str>,
95    incomplete_details: Option<&Json>,
96) -> Option<FinishReason> {
97    let incomplete_reason = incomplete_details
98        .and_then(|d| d.get("reason"))
99        .and_then(|r| r.as_str());
100
101    match status {
102        Some("completed") => Some(FinishReason::Complete),
103        Some("incomplete") => match incomplete_reason {
104            Some("max_output_tokens") => Some(FinishReason::Length),
105            Some("content_filter") => Some(FinishReason::ContentFilter),
106            Some(other) => Some(FinishReason::Unknown(other.to_string())),
107            None => Some(FinishReason::Unknown("incomplete".to_string())),
108        },
109        Some(other) => Some(FinishReason::Unknown(other.to_string())),
110        None => None,
111    }
112}
113
114/// Parse OpenAI tool call arguments from JSON string to [`Json`] value.
115///
116/// Falls back to [`Json::String`] if parsing fails (malformed model output).
117fn parse_arguments(arguments: &str) -> Json {
118    serde_json::from_str(arguments).unwrap_or_else(|_| Json::String(arguments.to_string()))
119}
120
121fn input_tokens_details_to_json(details: &RawInputTokensDetails) -> Json {
122    let mut obj = serde_json::Map::new();
123    if let Some(cached_tokens) = details.cached_tokens {
124        obj.insert("cached_tokens".into(), Json::from(cached_tokens));
125    }
126    obj.extend(details.extra.clone());
127    Json::Object(obj)
128}
129
130fn output_tokens_details_to_json(details: &RawOutputTokensDetails) -> Json {
131    let mut obj = serde_json::Map::new();
132    if let Some(reasoning_tokens) = details.reasoning_tokens {
133        obj.insert("reasoning_tokens".into(), Json::from(reasoning_tokens));
134    }
135    obj.extend(details.extra.clone());
136    Json::Object(obj)
137}
138
139/// Keys that are modeled in [`AnnotatedLlmRequest`] and should NOT go into `extra`.
140const MODELED_REQUEST_KEYS: &[&str] = &[
141    "input",
142    "instructions",
143    "model",
144    "max_output_tokens",
145    "temperature",
146    "top_p",
147    "tools",
148    "tool_choice",
149    "store",
150    "previous_response_id",
151    "truncation",
152    "reasoning",
153    "include",
154    "user",
155    "metadata",
156    "service_tier",
157    "parallel_tool_calls",
158    "max_tool_calls",
159    "top_logprobs",
160    "stream",
161];
162const UNPARSED_INPUT_ITEMS_KEY: &str = "_openai_responses_unparsed_input_items";
163
164/// Helper to construct a [`Json`] number from an `f64`.
165fn json_f64(v: f64) -> Json {
166    serde_json::Number::from_f64(v)
167        .map(Json::Number)
168        .unwrap_or(Json::Null)
169}
170
171fn collect_output_parts(items: Option<&[Json]>) -> (Vec<String>, Vec<ResponseToolCall>) {
172    let mut text_parts = Vec::new();
173    let mut tool_calls = Vec::new();
174
175    if let Some(items) = items {
176        for item in items {
177            collect_output_item(item, &mut text_parts, &mut tool_calls);
178        }
179    }
180
181    (text_parts, tool_calls)
182}
183
184fn collect_output_item(
185    item: &Json,
186    text_parts: &mut Vec<String>,
187    tool_calls: &mut Vec<ResponseToolCall>,
188) {
189    match item
190        .get("type")
191        .and_then(|value| value.as_str())
192        .unwrap_or("")
193    {
194        "message" => collect_message_text_parts(item, text_parts),
195        "function_call" => tool_calls.push(parse_function_call(item)),
196        _ => {}
197    }
198}
199
200fn collect_message_text_parts(item: &Json, text_parts: &mut Vec<String>) {
201    let Some(content) = item.get("content").and_then(|value| value.as_array()) else {
202        return;
203    };
204
205    for block in content {
206        if let Some(text) = output_text_block(block) {
207            text_parts.push(text);
208        }
209    }
210}
211
212fn output_text_block(block: &Json) -> Option<String> {
213    (block.get("type").and_then(|value| value.as_str()) == Some("output_text"))
214        .then(|| block.get("text").and_then(|value| value.as_str()))
215        .flatten()
216        .map(str::to_string)
217}
218
219fn parse_function_call(item: &Json) -> ResponseToolCall {
220    ResponseToolCall {
221        id: item
222            .get("call_id")
223            .and_then(|value| value.as_str())
224            .unwrap_or("")
225            .to_string(),
226        name: item
227            .get("name")
228            .and_then(|value| value.as_str())
229            .unwrap_or("")
230            .to_string(),
231        arguments: item
232            .get("arguments")
233            .and_then(|value| value.as_str())
234            .map(parse_arguments)
235            .unwrap_or(Json::Object(serde_json::Map::new())),
236    }
237}
238
239fn message_from_text_parts(text_parts: Vec<String>) -> Option<MessageContent> {
240    match text_parts.as_slice() {
241        [] => None,
242        [text] => Some(MessageContent::Text(text.clone())),
243        _ => Some(MessageContent::Text(text_parts.join("\n"))),
244    }
245}
246
247fn optional_vec<T>(items: Vec<T>) -> Option<Vec<T>> {
248    (!items.is_empty()).then_some(items)
249}
250
251fn split_system_and_input_messages(messages: &[Message]) -> (Option<String>, Vec<&Message>) {
252    let mut system_text = None;
253    let mut input_messages = Vec::new();
254
255    for msg in messages {
256        match msg {
257            Message::System { content, .. } => {
258                if let MessageContent::Text(text) = content {
259                    system_text = Some(text.clone());
260                }
261            }
262            other => input_messages.push(other),
263        }
264    }
265
266    (system_text, input_messages)
267}
268
269fn set_or_remove_string(obj: &mut serde_json::Map<String, Json>, key: &str, value: Option<String>) {
270    if let Some(value) = value {
271        obj.insert(key.into(), Json::String(value));
272    } else {
273        obj.remove(key);
274    }
275}
276
277fn insert_serialized<T: serde::Serialize>(
278    obj: &mut serde_json::Map<String, Json>,
279    key: &str,
280    value: &T,
281    context: &str,
282) -> Result<()> {
283    let json = serde_json::to_value(value)
284        .map_err(|e| FlowError::Internal(format!("OpenAI Responses {context} encode: {e}")))?;
285    obj.insert(key.into(), json);
286    Ok(())
287}
288
289fn overlay_generation_params(obj: &mut serde_json::Map<String, Json>, params: &GenerationParams) {
290    if let Some(temp) = params.temperature {
291        obj.insert("temperature".into(), json_f64(temp));
292    }
293    if let Some(top_p) = params.top_p {
294        obj.insert("top_p".into(), json_f64(top_p));
295    }
296    if let Some(max_tokens) = params.max_tokens {
297        obj.insert("max_output_tokens".into(), Json::from(max_tokens));
298        obj.remove("max_tokens");
299    }
300}
301
302fn encode_openai_responses_input(
303    obj: &mut serde_json::Map<String, Json>,
304    annotated: &AnnotatedLlmRequest,
305) -> Result<()> {
306    let (system_text, input_messages) = split_system_and_input_messages(&annotated.messages);
307    set_or_remove_string(obj, "instructions", system_text);
308    if let Some(raw_input_items) = annotated.extra.get(UNPARSED_INPUT_ITEMS_KEY) {
309        obj.insert("input".into(), raw_input_items.clone());
310    } else {
311        insert_serialized(obj, "input", &input_messages, "input")?;
312    }
313    Ok(())
314}
315
316fn encode_openai_responses_tools(
317    obj: &mut serde_json::Map<String, Json>,
318    annotated: &AnnotatedLlmRequest,
319) -> Result<()> {
320    if let Some(ref tools) = annotated.tools {
321        insert_serialized(obj, "tools", tools, "tools")?;
322    }
323    if let Some(ref tool_choice) = annotated.tool_choice {
324        insert_serialized(obj, "tool_choice", tool_choice, "tool_choice")?;
325    }
326    Ok(())
327}
328
329fn overlay_openai_responses_fields(
330    obj: &mut serde_json::Map<String, Json>,
331    annotated: &AnnotatedLlmRequest,
332) {
333    if let Some(ref model) = annotated.model {
334        obj.insert("model".into(), Json::String(model.clone()));
335    }
336    overlay_openai_responses_json_fields(obj, annotated);
337    overlay_openai_responses_string_fields(obj, annotated);
338    overlay_openai_responses_bool_fields(obj, annotated);
339    overlay_openai_responses_u64_fields(obj, annotated);
340}
341
342fn overlay_openai_responses_json_fields(
343    obj: &mut serde_json::Map<String, Json>,
344    annotated: &AnnotatedLlmRequest,
345) {
346    for (key, value) in [
347        ("truncation", &annotated.truncation),
348        ("reasoning", &annotated.reasoning),
349        ("include", &annotated.include),
350        ("metadata", &annotated.metadata),
351    ] {
352        if let Some(value) = value {
353            obj.insert(key.into(), value.clone());
354        }
355    }
356}
357
358fn overlay_openai_responses_string_fields(
359    obj: &mut serde_json::Map<String, Json>,
360    annotated: &AnnotatedLlmRequest,
361) {
362    for (key, value) in [
363        ("previous_response_id", &annotated.previous_response_id),
364        ("user", &annotated.user),
365        ("service_tier", &annotated.service_tier),
366    ] {
367        if let Some(value) = value {
368            obj.insert(key.into(), Json::String(value.clone()));
369        }
370    }
371}
372
373fn overlay_openai_responses_bool_fields(
374    obj: &mut serde_json::Map<String, Json>,
375    annotated: &AnnotatedLlmRequest,
376) {
377    for (key, value) in [
378        ("store", annotated.store),
379        ("parallel_tool_calls", annotated.parallel_tool_calls),
380        ("stream", annotated.stream),
381    ] {
382        if let Some(value) = value {
383            obj.insert(key.into(), Json::Bool(value));
384        }
385    }
386}
387
388fn overlay_openai_responses_u64_fields(
389    obj: &mut serde_json::Map<String, Json>,
390    annotated: &AnnotatedLlmRequest,
391) {
392    for (key, value) in [
393        ("max_output_tokens", annotated.max_output_tokens),
394        ("max_tool_calls", annotated.max_tool_calls),
395        ("top_logprobs", annotated.top_logprobs),
396    ] {
397        if let Some(value) = value {
398            obj.insert(key.into(), Json::from(value));
399        }
400    }
401}
402
403fn merge_openai_responses_extra_fields(
404    obj: &mut serde_json::Map<String, Json>,
405    extra: &serde_json::Map<String, Json>,
406) {
407    for (k, v) in extra {
408        if k != UNPARSED_INPUT_ITEMS_KEY {
409            obj.insert(k.clone(), v.clone());
410        }
411    }
412}
413
414fn decode_openai_or_anthropic_tool_choice(value: &Json) -> Option<ToolChoice> {
415    if let Ok(parsed) = serde_json::from_value::<ToolChoice>(value.clone()) {
416        return Some(parsed);
417    }
418
419    let obj = value.as_object()?;
420    match obj.get("type").and_then(|v| v.as_str()) {
421        Some("auto") => Some(ToolChoice::Auto),
422        Some("any") => Some(ToolChoice::Required),
423        Some("none") => Some(ToolChoice::None),
424        Some("tool") => {
425            let name = obj.get("name").and_then(|v| v.as_str())?.to_string();
426            Some(ToolChoice::Specific(ToolChoiceFunction {
427                choice_type: "function".to_string(),
428                function: ToolChoiceFunctionName { name },
429            }))
430        }
431        _ => None,
432    }
433}
434
435fn decode_openai_or_anthropic_parallel_tool_calls(
436    obj: &serde_json::Map<String, Json>,
437) -> Option<bool> {
438    if let Some(value) = obj.get("parallel_tool_calls").and_then(|v| v.as_bool()) {
439        return Some(value);
440    }
441    let tool_choice = obj.get("tool_choice")?.as_object()?;
442    tool_choice
443        .get("disable_parallel_tool_use")
444        .and_then(|v| v.as_bool())
445        .map(|disabled| !disabled)
446}
447
448// ---------------------------------------------------------------------------
449// LlmResponseCodec implementation
450// ---------------------------------------------------------------------------
451
452impl LlmResponseCodec for OpenAIResponsesCodec {
453    fn decode_response(&self, response: &Json) -> Result<AnnotatedLlmResponse> {
454        let raw: RawResponsesResponse = serde_json::from_value(response.clone())
455            .map_err(|e| FlowError::Internal(format!("OpenAI Responses response decode: {e}")))?;
456
457        let all_output_items = raw.output.clone();
458        let (text_parts, tool_calls) = collect_output_parts(raw.output.as_deref());
459        let message = message_from_text_parts(text_parts);
460        let tool_calls = optional_vec(tool_calls);
461
462        // Map finish reason from status + incomplete_details.
463        let finish_reason =
464            map_responses_finish_reason(raw.status.as_deref(), raw.incomplete_details.as_ref());
465
466        let input_tokens_details = raw.usage.as_ref().and_then(|u| {
467            u.input_tokens_details
468                .as_ref()
469                .map(input_tokens_details_to_json)
470        });
471        let output_tokens_details = raw.usage.as_ref().and_then(|u| {
472            u.output_tokens_details
473                .as_ref()
474                .map(output_tokens_details_to_json)
475        });
476
477        // Map usage.
478        let model_for_pricing = raw.model.as_deref();
479        let model_provider = infer_model_provider("openai", model_for_pricing);
480        let usage = raw.usage.map(|u| {
481            let mut usage = Usage {
482                prompt_tokens: u.input_tokens,
483                completion_tokens: u.output_tokens,
484                total_tokens: u.total_tokens,
485                cache_read_tokens: u
486                    .input_tokens_details
487                    .as_ref()
488                    .and_then(|d| d.cached_tokens),
489                cache_write_tokens: None,
490                cost: provider_reported_cost(u.provider_cost, u.cost),
491            };
492            if usage.cost.is_none() {
493                usage.cost = model_for_pricing.and_then(|model| {
494                    estimate_cost_for_provider(model_provider.as_deref(), model, &usage)
495                });
496            }
497            usage
498        });
499
500        // Build API-specific fields.
501        let api_specific = Some(ApiSpecificResponse::OpenAIResponses {
502            output_items: all_output_items,
503            status: raw.status,
504            incomplete_details: raw.incomplete_details,
505            previous_response_id: raw.previous_response_id,
506            store: raw.store,
507            service_tier: raw.service_tier,
508            truncation: raw.truncation,
509            reasoning: raw.reasoning,
510            input_tokens_details,
511            output_tokens_details,
512        });
513
514        Ok(AnnotatedLlmResponse {
515            id: raw.id,
516            model: raw.model,
517            message,
518            tool_calls,
519            finish_reason,
520            usage,
521            api_specific,
522            extra: raw.extra,
523        })
524    }
525}
526
527// ---------------------------------------------------------------------------
528// LlmCodec implementation
529// ---------------------------------------------------------------------------
530
531impl LlmCodec for OpenAIResponsesCodec {
532    fn decode(&self, request: &LlmRequest) -> Result<AnnotatedLlmRequest> {
533        let obj = request
534            .content
535            .as_object()
536            .ok_or_else(|| FlowError::Internal("request content is not an object".into()))?;
537
538        let mut messages: Vec<Message> = Vec::new();
539        let mut preserved_unparsed_input: Option<Json> = None;
540
541        // Extract instructions -> system message (first).
542        if let Some(instructions) = obj.get("instructions").and_then(|v| v.as_str()) {
543            messages.push(Message::System {
544                content: MessageContent::Text(instructions.to_string()),
545                name: None,
546            });
547        }
548
549        // Extract input.
550        if let Some(input) = obj.get("input") {
551            if let Some(s) = input.as_str() {
552                // Input is a simple string -> single User message.
553                messages.push(Message::User {
554                    content: MessageContent::Text(s.to_string()),
555                    name: None,
556                });
557            } else if input.is_array() {
558                // Strict-first parse to avoid partial normalized state.
559                match serde_json::from_value::<Vec<Message>>(input.clone()) {
560                    Ok(input_messages) => messages.extend(input_messages),
561                    Err(_) => {
562                        // Preserve full original array for lossless handling.
563                        preserved_unparsed_input = Some(input.clone());
564                    }
565                }
566            }
567        }
568
569        // Extract model.
570        let model = obj.get("model").and_then(|v| v.as_str()).map(String::from);
571
572        // Extract generation params.
573        let temperature = obj.get("temperature").and_then(|v| v.as_f64());
574        let top_p = obj.get("top_p").and_then(|v| v.as_f64());
575        let max_tokens = obj.get("max_output_tokens").and_then(|v| v.as_u64());
576        // Responses API does not support stop sequences.
577
578        let params = if temperature.is_some() || max_tokens.is_some() || top_p.is_some() {
579            Some(GenerationParams {
580                temperature,
581                max_tokens,
582                top_p,
583                stop: None,
584            })
585        } else {
586            None
587        };
588
589        // Extract tools.
590        let tools: Option<Vec<ToolDefinition>> = obj
591            .get("tools")
592            .map(|v| serde_json::from_value(v.clone()))
593            .transpose()
594            .map_err(|e| FlowError::Internal(format!("OpenAI Responses tools decode: {e}")))?;
595
596        // Extract tool_choice.
597        let tool_choice: Option<ToolChoice> = obj
598            .get("tool_choice")
599            .and_then(decode_openai_or_anthropic_tool_choice);
600
601        // Collect extra fields (keys not in MODELED_REQUEST_KEYS).
602        let mut extra: serde_json::Map<String, Json> = obj
603            .iter()
604            .filter(|(k, _)| !MODELED_REQUEST_KEYS.contains(&k.as_str()))
605            .map(|(k, v)| (k.clone(), v.clone()))
606            .collect();
607        if let Some(input_items) = preserved_unparsed_input {
608            extra.insert(UNPARSED_INPUT_ITEMS_KEY.into(), input_items);
609        }
610
611        Ok(AnnotatedLlmRequest {
612            messages,
613            model,
614            params,
615            tools,
616            tool_choice,
617            store: obj.get("store").and_then(|v| v.as_bool()),
618            previous_response_id: obj
619                .get("previous_response_id")
620                .and_then(|v| v.as_str())
621                .map(String::from),
622            truncation: obj.get("truncation").cloned(),
623            reasoning: obj.get("reasoning").cloned(),
624            include: obj.get("include").cloned(),
625            user: obj.get("user").and_then(|v| v.as_str()).map(String::from),
626            metadata: obj.get("metadata").cloned(),
627            service_tier: obj
628                .get("service_tier")
629                .and_then(|v| v.as_str())
630                .map(String::from),
631            parallel_tool_calls: decode_openai_or_anthropic_parallel_tool_calls(obj),
632            max_output_tokens: obj.get("max_output_tokens").and_then(|v| v.as_u64()),
633            max_tool_calls: obj.get("max_tool_calls").and_then(|v| v.as_u64()),
634            top_logprobs: obj.get("top_logprobs").and_then(|v| v.as_u64()),
635            stream: obj.get("stream").and_then(|v| v.as_bool()),
636            extra,
637        })
638    }
639
640    fn encode(&self, annotated: &AnnotatedLlmRequest, original: &LlmRequest) -> Result<LlmRequest> {
641        let mut content = original.content.clone();
642        let obj = content
643            .as_object_mut()
644            .ok_or_else(|| FlowError::Internal("original content is not an object".into()))?;
645
646        encode_openai_responses_input(obj, annotated)?;
647        if let Some(ref params) = annotated.params {
648            overlay_generation_params(obj, params);
649        }
650        encode_openai_responses_tools(obj, annotated)?;
651        overlay_openai_responses_fields(obj, annotated);
652        merge_openai_responses_extra_fields(obj, &annotated.extra);
653
654        Ok(LlmRequest {
655            headers: original.headers.clone(),
656            content,
657        })
658    }
659}
660
661// ---------------------------------------------------------------------------
662// Streaming codec
663// ---------------------------------------------------------------------------
664
665/// Streaming counterpart to [`OpenAIResponsesCodec`].
666///
667/// Replays the OpenAI Responses SSE event sequence into the same JSON shape the API returns for a
668/// non-streaming request (`{id, model, status, output, usage, incomplete_details, ...}`). Once
669/// finalized, the assembled JSON can be fed back through [`OpenAIResponsesCodec::decode_response`]
670/// to produce the canonical [`AnnotatedLlmResponse`].
671///
672/// # Strategy
673///
674/// The Responses API is a relatively forgiving streaming target because every event carries
675/// either the full `response` snapshot (`response.created`, `response.in_progress`,
676/// `response.completed`, `response.failed`, `response.incomplete`) or the final-state output item
677/// (`response.output_item.done`). We:
678///
679/// 1. Track the latest `response` snapshot — terminal events (`completed`/`failed`/`incomplete`)
680///    typically carry the complete state including `output`, so we prefer those when present.
681/// 2. Track output items by `output_index` — `output_item.done` events deliver the final per-item
682///    state, used as a fallback when the terminal `response.output` is missing or empty.
683/// 3. Per-token `output_text.delta` and `function_call_arguments.delta` events are ignored
684///    because their content is redelivered in the matching `output_item.done` event. Skipping
685///    deltas keeps the codec resilient to schema additions and avoids double-accumulation.
686///
687/// Internal state lives behind `Arc<Mutex<...>>` so the `&self`-produced collector and finalizer
688/// closures share access. Each instance is single-use because [`LlmFinalizerFn`] consumes the
689/// finalize step.
690///
691/// [`AnnotatedLlmResponse`]: crate::codec::response::AnnotatedLlmResponse
692/// [`LlmFinalizerFn`]: crate::api::runtime::LlmFinalizerFn
693pub struct OpenAIResponsesStreamingCodec {
694    state: std::sync::Arc<std::sync::Mutex<OpenAIResponsesStreamingState>>,
695}
696
697impl OpenAIResponsesStreamingCodec {
698    /// Creates a fresh streaming codec with empty accumulator state.
699    pub fn new() -> Self {
700        Self {
701            state: std::sync::Arc::new(std::sync::Mutex::new(
702                OpenAIResponsesStreamingState::default(),
703            )),
704        }
705    }
706}
707
708impl Default for OpenAIResponsesStreamingCodec {
709    fn default() -> Self {
710        Self::new()
711    }
712}
713
714impl super::streaming::StreamingCodec for OpenAIResponsesStreamingCodec {
715    fn collector(&self) -> crate::api::runtime::LlmCollectorFn {
716        let state = std::sync::Arc::clone(&self.state);
717        Box::new(move |event: Json| -> Result<()> {
718            let mut guard = state
719                .lock()
720                .unwrap_or_else(|poisoned| poisoned.into_inner());
721            guard.observe(&event);
722            Ok(())
723        })
724    }
725
726    fn finalizer(&self) -> crate::api::runtime::LlmFinalizerFn {
727        let state = std::sync::Arc::clone(&self.state);
728        Box::new(move || -> Json {
729            let mut guard = state
730                .lock()
731                .unwrap_or_else(|poisoned| poisoned.into_inner());
732            std::mem::take(&mut *guard).finalize()
733        })
734    }
735}
736
737#[derive(Debug, Default)]
738struct OpenAIResponsesStreamingState {
739    /// Latest `response` snapshot from any event that carries one. Last write wins, so terminal
740    /// events with the complete state will end up here when they fire.
741    response: Option<serde_json::Map<String, Json>>,
742    /// Items keyed by `output_index`. Captured from `response.output_item.added` (initial) and
743    /// replaced on `response.output_item.done` (final). Used as a fallback for `output` when the
744    /// terminal `response` snapshot lacks it.
745    items: std::collections::BTreeMap<usize, Json>,
746}
747
748impl OpenAIResponsesStreamingState {
749    fn observe(&mut self, event: &Json) {
750        let event_type = event.get("type").and_then(Json::as_str).unwrap_or("");
751        match event_type {
752            "response.created"
753            | "response.in_progress"
754            | "response.completed"
755            | "response.failed"
756            | "response.incomplete" => self.observe_response_snapshot(event),
757            "response.output_item.added" | "response.output_item.done" => {
758                self.observe_output_item(event);
759            }
760            // response.output_text.delta, response.function_call_arguments.delta,
761            // response.content_part.added/done — content is redelivered in output_item.done, so we
762            // don't accumulate deltas. Unknown events are ignored.
763            _ => {}
764        }
765    }
766
767    fn observe_response_snapshot(&mut self, event: &Json) {
768        let Some(response) = event.get("response") else {
769            return;
770        };
771        if let Json::Object(map) = response {
772            self.response = Some(map.clone());
773        }
774    }
775
776    fn observe_output_item(&mut self, event: &Json) {
777        let Some(index) = event.get("output_index").and_then(Json::as_u64) else {
778            return;
779        };
780        let Some(item) = event.get("item") else {
781            return;
782        };
783        self.items.insert(index as usize, item.clone());
784    }
785
786    fn finalize(self) -> Json {
787        let mut output = self.response.unwrap_or_default();
788        // If the latest snapshot lacked `output` (or has an empty array because it came from an
789        // early `response.created` event), backfill from per-item accumulator. Terminal events
790        // typically carry the complete output, so this branch is a safety net for truncated
791        // streams or schemas that drop output from terminal events.
792        let snapshot_output_empty = output
793            .get("output")
794            .and_then(Json::as_array)
795            .map(|arr| arr.is_empty())
796            .unwrap_or(true);
797        if snapshot_output_empty && !self.items.is_empty() {
798            let items: Vec<Json> = self.items.into_values().collect();
799            output.insert("output".to_string(), Json::Array(items));
800        }
801        Json::Object(output)
802    }
803}
804
805// ---------------------------------------------------------------------------
806// Tests
807// ---------------------------------------------------------------------------
808
809#[cfg(test)]
810#[path = "../../tests/unit/codec/openai_responses_tests.rs"]
811mod tests;