Skip to main content

dynamo_protocols/types/responses/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Dynamo owns the Responses-API input-side type chain. Upstream async-openai
5// is the source for everything else (output-side types, streaming events,
6// individual tool-call payloads, etc.).
7//
8// The input chain is owned because upstream marks fields as required that
9// real-world clients (OpenAI Agents SDK, Codex, etc.) routinely omit when
10// round-tripping a prior assistant turn as input:
11//   - `OutputMessage.id` / `.status` — omitted when echoing a previous output
12//   - `OutputTextContent.annotations` — omitted when the part carried none
13// Upstream is slow to relax these (see e.g. 64bit/async-openai#535 for the
14// sibling `ReasoningItem.id` fix, still open at time of writing); OpenAI's own
15// hosted API accepts the relaxed shapes on input regardless.
16//
17// This mirrors the pattern in `crate::types::chat` where Dynamo owns the
18// request types it needs to extend or relax while re-exporting the rest of
19// upstream's type library verbatim.
20//
21// Naming: the relaxed assistant-input message is `InputOutputMessage` (and
22// `InputOutputMessageContent` / `InputOutputTextContent` for its content
23// parts) to avoid colliding with upstream's `OutputMessage`, which remains the
24// canonical type for *output-side* response construction (`OutputItem`,
25// `Response.output`). `MessageItem`, `Item`, `InputItem`, `InputParam`, and
26// `CreateResponse` are input-only and shadow upstream's same-named types
27// without conflict.
28
29use std::collections::HashMap;
30
31use serde::{Deserialize, Serialize};
32
33// Re-export all upstream response types (shared structures like ResponseUsage,
34// tool-call item types, streaming events, etc.). The types we own below
35// shadow their upstream counterparts where no dual-side conflict exists.
36pub use async_openai::types::responses::*;
37
38// Re-export upstream's pre-shadow `InputContent` under an explicit alias.
39// Needed because `FunctionCallOutput::Content` and `EasyInputContent::ContentList`
40// are non-owned upstream types that carry upstream's original `InputContent`
41// inline, so downstream consumers occasionally need to name it alongside the
42// Dynamo-owned shadow defined further down this module.
43pub use async_openai::types::responses::InputContent as UpstreamInputContent;
44
45// Re-export from parent module for backward compat.
46pub use crate::types::ImageDetail;
47pub use crate::types::ReasoningEffort;
48pub use crate::types::ResponseFormatJsonSchema;
49
50// Backward-compatible type aliases for Dynamo consumer code migration.
51pub type Input = InputParam;
52pub type PromptConfig = Prompt;
53pub type TextConfig = ResponseTextParam;
54pub type TextResponseFormat = TextResponseFormatConfiguration;
55
56/// Stream of response events.
57pub type ResponseStream = std::pin::Pin<
58    Box<dyn futures::Stream<Item = Result<ResponseStreamEvent, crate::error::OpenAIError>> + Send>,
59>;
60
61/// Fields on upstream `Response` that the OpenResponses spec requires as
62/// `T | null` but async-openai declares as `Option<T>` with
63/// `skip_serializing_if = Option::is_none` — meaning `None` disappears from
64/// the wire shape, where the spec wants an explicit `null`.
65///
66/// Colocated here (next to the upstream `Response` re-export) rather than in
67/// `lib/llm/src/protocols/openai/responses/mod.rs` so that when upstream's
68/// `Response` gains a new nullable-required field, the reviewer editing this
69/// module is looking directly at the authoritative list. Keep sorted
70/// alphabetically; entries must match serde field names on `Response` exactly.
71///
72/// Any field we unconditionally populate ourselves during response
73/// construction (e.g. `metadata`, `parallel_tool_calls`, `temperature`,
74/// `text`, `tool_choice`, `tools`, `top_p`, `top_logprobs`, `truncation`,
75/// `service_tier`, `background`) is deliberately absent — it's always
76/// present on the wire, so listing it here would be noise.
77pub const SPEC_NULLABLE_REQUIRED_RESPONSE_FIELDS: &[&str] = &[
78    "billing",
79    "completed_at",
80    "conversation",
81    "error",
82    "incomplete_details",
83    "instructions",
84    "max_output_tokens",
85    "max_tool_calls",
86    "previous_response_id",
87    "prompt",
88    "prompt_cache_key",
89    "prompt_cache_retention",
90    "reasoning",
91    "safety_identifier",
92    "usage",
93];
94
95// ---------------------------------------------------------------------------
96// Input-side assistant message (relaxed vs upstream OutputMessage)
97// ---------------------------------------------------------------------------
98
99/// Deserialize `null` or a missing field as the default empty `Vec`. Plain
100/// `#[serde(default)]` only fires when the field is absent; explicit `null`
101/// would otherwise fail `Vec::deserialize`. Clients (notably some Agents SDK
102/// variants) have been observed to send `"annotations": null`, so treat
103/// omission and explicit null the same.
104fn deserialize_null_as_empty_vec<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
105where
106    T: Deserialize<'de>,
107    D: serde::Deserializer<'de>,
108{
109    Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
110}
111
112/// Deserialize `null` or a missing field as `T::default()`. Scalar counterpart
113/// to `deserialize_null_as_empty_vec` — plain `#[serde(default)]` rejects
114/// explicit `null` because serde tries to deserialize the null into `T` and
115/// fails. Real clients emit `null` for unset enum-ish fields (e.g. OpenAI
116/// Agents SDK sending `"detail": null` on `input_image` parts).
117fn deserialize_null_as_default<'de, T, D>(deserializer: D) -> Result<T, D::Error>
118where
119    T: Deserialize<'de> + Default,
120    D: serde::Deserializer<'de>,
121{
122    Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
123}
124
125/// Relaxed counterpart to upstream `OutputTextContent` for input-side content.
126/// `annotations` tolerates both missing and explicit `null`; upstream requires
127/// it to be a present non-null array.
128#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
129pub struct InputOutputTextContent {
130    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
131    pub annotations: Vec<Annotation>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub logprobs: Option<Vec<LogProb>>,
134    pub text: String,
135}
136
137/// Content parts of a prior assistant message presented as input.
138#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
139#[serde(tag = "type", rename_all = "snake_case")]
140pub enum InputOutputMessageContent {
141    OutputText(InputOutputTextContent),
142    Refusal(RefusalContent),
143}
144
145/// An assistant message echoed back as input for a subsequent turn. Relaxed
146/// compared to upstream `OutputMessage`: `id`, `status`, and `content` are all
147/// optional. Some clients send a bare assistant shell (`{"type":"message",
148/// "role":"assistant"}`) with no `content` at all, usually on pure tool-call
149/// turns; treat absent `content` as an empty vec, same way we treat a missing
150/// `id`/`status`.
151#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
152pub struct InputOutputMessage {
153    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
154    pub content: Vec<InputOutputMessageContent>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub id: Option<String>,
157    pub role: AssistantRole,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub phase: Option<MessagePhase>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub status: Option<OutputStatus>,
162}
163
164// ---------------------------------------------------------------------------
165// Input-side image / content / message (shadow upstream, relaxed shapes)
166// ---------------------------------------------------------------------------
167
168/// Relaxed counterpart to upstream `InputImageContent`. `detail` defaults to
169/// `ImageDetail::Auto` when the client omits it — OpenAI's hosted API and the
170/// OpenResponses spec both accept this shape, but upstream's struct marks
171/// `detail` as required.
172#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
173pub struct InputImageContent {
174    #[serde(default, deserialize_with = "deserialize_null_as_default")]
175    pub detail: ImageDetail,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub file_id: Option<String>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub image_url: Option<String>,
180}
181
182/// Parts of an input message: text, image, or file. Mirrors upstream
183/// `InputContent` but routes `InputImage` through the Dynamo-owned relaxed
184/// `InputImageContent` above.
185#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
186#[serde(tag = "type", rename_all = "snake_case")]
187pub enum InputContent {
188    InputText(InputTextContent),
189    InputImage(InputImageContent),
190    InputFile(InputFileContent),
191}
192
193/// User / system / developer input message. Shadows upstream `InputMessage`
194/// so we can route through the Dynamo-owned `InputContent` chain.
195#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
196pub struct InputMessage {
197    pub content: Vec<InputContent>,
198    pub role: InputRole,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub status: Option<OutputStatus>,
201}
202
203/// Content for `EasyInputMessage`. Shadows upstream's same-named enum so the
204/// `ContentList` arm carries Dynamo's relaxed `InputContent` (with optional
205/// `detail` on `InputImageContent`) instead of upstream's strict variant.
206///
207/// Without this shadow, the `InputItem::EasyMessage` fallback in the untagged
208/// `InputItem` enum is the only path that still routes through upstream's
209/// strict types — so any spec-compliant client that omits `type: "message"`
210/// on a multimodal message (the documented default) fails with
211/// "data did not match any variant of untagged enum InputItem". See issue
212/// #9468.
213#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
214#[serde(untagged)]
215pub enum EasyInputContent {
216    /// Plain-text content. Tried first so `"content": "hi"` short-circuits.
217    Text(String),
218    /// Structured content list (text/image/file parts).
219    ContentList(Vec<InputContent>),
220}
221
222impl Default for EasyInputContent {
223    fn default() -> Self {
224        Self::Text(String::new())
225    }
226}
227
228/// A simplified message input — the spec-default shape when a client omits the
229/// `type` discriminator. Shadows upstream `EasyInputMessage` so the `content`
230/// field routes through Dynamo's relaxed `EasyInputContent` (and transitively
231/// the relaxed `InputContent` / `InputImageContent`). Field set is identical to
232/// upstream for drop-in compatibility with construction sites in lib/llm.
233#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
234pub struct EasyInputMessage {
235    /// Type discriminator. Optional with default `MessageType::Message` —
236    /// matches the OpenAI Responses spec and `openai-python`'s
237    /// `EasyInputMessageParam` (`type: Literal["message"]`, non-Required).
238    #[serde(default)]
239    pub r#type: MessageType,
240    pub role: Role,
241    pub content: EasyInputContent,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub phase: Option<MessagePhase>,
244}
245
246// ---------------------------------------------------------------------------
247// Input-side Item / Message / InputItem / InputParam (shadow upstream)
248// ---------------------------------------------------------------------------
249
250/// Message item within `Item`. Untagged; disambiguated by the `role` field:
251/// the `Output` variant requires `role: "assistant"` (via `AssistantRole`,
252/// which is a single-variant enum) and `Input` requires `role` in
253/// `"user" | "system" | "developer"` (via `InputRole`). A payload with an
254/// unknown role (e.g. `"tool"`) or a missing `role` produces the generic
255/// untagged-enum error — callers are expected to send a valid role. If you
256/// see the "data did not match any variant of untagged enum" failure on this
257/// type, it is almost always a role mismatch.
258#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
259#[serde(untagged)]
260pub enum MessageItem {
261    /// Prior assistant output echoed back (role: assistant). Tried first — its
262    /// `role` constraint excludes user/system/developer inputs.
263    Output(InputOutputMessage),
264    /// User / system / developer input message.
265    Input(InputMessage),
266}
267
268/// Structured input/output item, discriminated by `type`. Mirrors upstream
269/// `Item` variant-for-variant; only `Message` uses a Dynamo-owned type.
270#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
271#[serde(tag = "type", rename_all = "snake_case")]
272pub enum Item {
273    Message(MessageItem),
274    FileSearchCall(FileSearchToolCall),
275    ComputerCall(ComputerToolCall),
276    ComputerCallOutput(ComputerCallOutputItemParam),
277    WebSearchCall(WebSearchToolCall),
278    FunctionCall(FunctionToolCall),
279    FunctionCallOutput(FunctionCallOutputItemParam),
280    ToolSearchCall(ToolSearchCallItemParam),
281    ToolSearchOutput(ToolSearchOutputItemParam),
282    Reasoning(ReasoningItem),
283    Compaction(CompactionSummaryItemParam),
284    ImageGenerationCall(ImageGenToolCall),
285    CodeInterpreterCall(CodeInterpreterToolCall),
286    LocalShellCall(LocalShellToolCall),
287    LocalShellCallOutput(LocalShellToolCallOutput),
288    ShellCall(FunctionShellCallItemParam),
289    ShellCallOutput(FunctionShellCallOutputItemParam),
290    ApplyPatchCall(ApplyPatchToolCallItemParam),
291    ApplyPatchCallOutput(ApplyPatchToolCallOutputItemParam),
292    McpListTools(MCPListTools),
293    McpApprovalRequest(MCPApprovalRequest),
294    McpApprovalResponse(MCPApprovalResponse),
295    McpCall(MCPToolCall),
296    CustomToolCallOutput(CustomToolCallOutput),
297    CustomToolCall(CustomToolCall),
298}
299
300/// Single input item. Untagged; order matters (most specific first).
301#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
302#[serde(untagged)]
303pub enum InputItem {
304    ItemReference(ItemReference),
305    Item(Item),
306    EasyMessage(EasyInputMessage),
307}
308
309/// Input to a `POST /v1/responses` request.
310#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
311#[serde(untagged)]
312pub enum InputParam {
313    Text(String),
314    Items(Vec<InputItem>),
315}
316
317impl Default for InputParam {
318    fn default() -> Self {
319        Self::Text(String::new())
320    }
321}
322
323// ---------------------------------------------------------------------------
324// CreateResponse (owned, uses Dynamo-owned InputParam)
325// ---------------------------------------------------------------------------
326
327/// Request body for `POST /v1/responses`. Mirrors upstream `CreateResponse`
328/// field-for-field but uses Dynamo-owned `InputParam`, which transitively
329/// accepts the relaxed input shapes described in this module's header. All
330/// other fields reference upstream types verbatim.
331#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
332pub struct CreateResponse {
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub background: Option<bool>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub conversation: Option<ConversationParam>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub include: Option<Vec<IncludeEnum>>,
339    pub input: InputParam,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub instructions: Option<String>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub max_output_tokens: Option<u32>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub max_tool_calls: Option<u32>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub metadata: Option<HashMap<String, String>>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub model: Option<String>,
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub parallel_tool_calls: Option<bool>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub previous_response_id: Option<String>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub prompt: Option<Prompt>,
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub prompt_cache_key: Option<String>,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub prompt_cache_retention: Option<PromptCacheRetention>,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub reasoning: Option<Reasoning>,
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub safety_identifier: Option<String>,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub service_tier: Option<ServiceTier>,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub store: Option<bool>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub stream: Option<bool>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub stream_options: Option<ResponseStreamOptions>,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub temperature: Option<f32>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub text: Option<ResponseTextParam>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub tool_choice: Option<ToolChoiceParam>,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub tools: Option<Vec<Tool>>,
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub top_logprobs: Option<u8>,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub top_p: Option<f32>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub truncation: Option<Truncation>,
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn relaxed_assistant_message_without_id_or_status() {
394        let json = serde_json::json!({
395            "type": "message",
396            "role": "assistant",
397            "content": [{"type": "output_text", "text": "hi"}]
398        });
399        let item: InputItem = serde_json::from_value(json).unwrap();
400        match item {
401            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
402                assert_eq!(out.role, AssistantRole::Assistant);
403                assert!(out.id.is_none());
404                assert!(out.status.is_none());
405            }
406            other => panic!("expected Item::Message(Output), got {other:?}"),
407        }
408    }
409
410    #[test]
411    fn input_image_without_detail_defaults_to_auto() {
412        let json = serde_json::json!({
413            "type": "input_image",
414            "image_url": "https://example.com/cat.jpg"
415        });
416        let content: InputContent = serde_json::from_value(json).unwrap();
417        match content {
418            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
419            other => panic!("expected InputImage, got {other:?}"),
420        }
421    }
422
423    #[test]
424    fn input_image_with_explicit_null_detail_defaults_to_auto() {
425        let json = serde_json::json!({
426            "type": "input_image",
427            "image_url": "https://example.com/cat.jpg",
428            "detail": null
429        });
430        let content: InputContent = serde_json::from_value(json).unwrap();
431        match content {
432            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
433            other => panic!("expected InputImage, got {other:?}"),
434        }
435    }
436
437    #[test]
438    fn assistant_message_without_content_field_deserializes() {
439        // Bare assistant shell — no `content` field at all. Seen in real
440        // Codex/Agents-SDK traffic on pure tool-call turns. `#[serde(default)]`
441        // on `content` must accept omission and yield an empty vec.
442        let json = serde_json::json!({
443            "type": "message",
444            "role": "assistant"
445        });
446        let item: InputItem = serde_json::from_value(json).unwrap();
447        match item {
448            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
449                assert_eq!(out.role, AssistantRole::Assistant);
450                assert!(out.content.is_empty());
451                assert!(out.id.is_none());
452                assert!(out.status.is_none());
453            }
454            other => panic!("expected Item::Message(Output), got {other:?}"),
455        }
456    }
457
458    #[test]
459    fn assistant_message_with_explicit_null_content_deserializes() {
460        // Mirrors the `annotations: null` case: some serializers emit JSON null
461        // for absent fields instead of omitting them. `Vec::deserialize` rejects
462        // null, so `content` also needs `deserialize_null_as_empty_vec`.
463        let json = serde_json::json!({
464            "type": "message",
465            "role": "assistant",
466            "content": null
467        });
468        let item: InputItem = serde_json::from_value(json).unwrap();
469        match item {
470            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
471                assert!(out.content.is_empty());
472            }
473            other => panic!("expected Item::Message(Output), got {other:?}"),
474        }
475    }
476
477    #[test]
478    fn mcp_call_item_deserializes() {
479        // Guards against Item variant drift vs upstream — MCP item types were
480        // added after the initial owned `Item` chain landed.
481        let json = serde_json::json!({
482            "type": "mcp_call",
483            "id": "mcp_1",
484            "server_label": "srv",
485            "name": "t",
486            "arguments": "{}"
487        });
488        let item: InputItem = serde_json::from_value(json).unwrap();
489        assert!(matches!(item, InputItem::Item(Item::McpCall(_))));
490    }
491
492    #[test]
493    fn strict_assistant_message_still_deserializes() {
494        let json = serde_json::json!({
495            "type": "message",
496            "role": "assistant",
497            "id": "msg_1",
498            "status": "completed",
499            "content": [{"type": "output_text", "text": "hi", "annotations": []}]
500        });
501        let item: InputItem = serde_json::from_value(json).unwrap();
502        match item {
503            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
504                assert_eq!(out.id.as_deref(), Some("msg_1"));
505                assert_eq!(out.status, Some(OutputStatus::Completed));
506            }
507            other => panic!("expected Item::Message(Output), got {other:?}"),
508        }
509    }
510
511    #[test]
512    fn user_message_routes_to_input_variant() {
513        let json = serde_json::json!({
514            "type": "message",
515            "role": "user",
516            "content": [{"type": "input_text", "text": "hi"}]
517        });
518        let item: InputItem = serde_json::from_value(json).unwrap();
519        assert!(matches!(
520            item,
521            InputItem::Item(Item::Message(MessageItem::Input(_)))
522        ));
523    }
524
525    #[test]
526    fn function_call_item_still_deserializes() {
527        let json = serde_json::json!({
528            "type": "function_call",
529            "call_id": "c",
530            "name": "f",
531            "arguments": "{}"
532        });
533        let item: InputItem = serde_json::from_value(json).unwrap();
534        assert!(matches!(item, InputItem::Item(Item::FunctionCall(_))));
535    }
536
537    #[test]
538    fn easy_message_string_content_routes_to_easymessage() {
539        let json = serde_json::json!({"role": "assistant", "content": "x"});
540        let item: InputItem = serde_json::from_value(json).unwrap();
541        assert!(matches!(item, InputItem::EasyMessage(_)));
542    }
543
544    #[test]
545    fn output_text_without_annotations_defaults_empty() {
546        let json = serde_json::json!({"type": "output_text", "text": "hi"});
547        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
548        match part {
549            InputOutputMessageContent::OutputText(t) => {
550                assert!(t.annotations.is_empty());
551            }
552            _ => panic!("expected OutputText"),
553        }
554    }
555
556    #[test]
557    fn output_text_with_explicit_null_annotations_deserializes_as_empty() {
558        // Some clients serialize absent fields as JSON null instead of omitting
559        // them. `Vec::deserialize` would reject null; the custom deserializer
560        // treats explicit null identically to a missing field.
561        let json = serde_json::json!({"type": "output_text", "text": "hi", "annotations": null});
562        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
563        match part {
564            InputOutputMessageContent::OutputText(t) => {
565                assert!(t.annotations.is_empty());
566            }
567            _ => panic!("expected OutputText"),
568        }
569    }
570
571    #[test]
572    fn assistant_message_with_explicit_null_id_and_status_deserializes() {
573        // `Option<T>` natively accepts null as `None`, so these explicit-null
574        // fields should flow through without a custom deserializer. This test
575        // pins that behavior against accidental regressions (e.g. if someone
576        // switches the field type away from `Option<_>`).
577        let json = serde_json::json!({
578            "type": "message",
579            "role": "assistant",
580            "id": null,
581            "status": null,
582            "content": [{"type": "output_text", "text": "hi", "annotations": null}]
583        });
584        let item: InputItem = serde_json::from_value(json).unwrap();
585        match item {
586            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
587                assert!(out.id.is_none());
588                assert!(out.status.is_none());
589                assert_eq!(out.content.len(), 1);
590            }
591            other => panic!("expected Item::Message(Output), got {other:?}"),
592        }
593    }
594
595    #[test]
596    fn create_response_roundtrip_with_relaxed_input() {
597        let body = serde_json::json!({
598            "model": "m",
599            "input": [
600                {"type": "message", "role": "user", "content": [
601                    {"type": "input_text", "text": "hi"}
602                ]},
603                {"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
604                {"type": "message", "role": "assistant", "content": [
605                    {"type": "output_text", "text": "\n\n"}
606                ]},
607                {"type": "function_call_output", "call_id": "c", "output": "x"}
608            ]
609        });
610
611        let req: CreateResponse = serde_json::from_value(body).unwrap();
612        let items = match &req.input {
613            InputParam::Items(items) => items,
614            _ => panic!("expected Items"),
615        };
616        assert_eq!(items.len(), 4);
617        assert!(matches!(
618            items[2],
619            InputItem::Item(Item::Message(MessageItem::Output(_)))
620        ));
621    }
622
623    // ---- EasyInputMessage / multimodal-without-`type` regression coverage ----
624    // See issue #9468. Before the EasyInputMessage/EasyInputContent shadow
625    // landed, the `InputItem::EasyMessage` fallback still routed through
626    // upstream's strict `InputImageContent` (required `detail`), so any
627    // multimodal message that omitted the spec-default `type: "message"` would
628    // fail with "data did not match any variant of untagged enum InputItem".
629
630    #[test]
631    fn easy_message_multimodal_without_type_routes_to_easymessage() {
632        // AIPerf's pre-PR-931 payload shape: no top-level `type`, content is a
633        // list containing an `input_image` part with no `detail`.
634        let json = serde_json::json!({
635            "role": "user",
636            "content": [
637                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
638            ]
639        });
640        let item: InputItem = serde_json::from_value(json).unwrap();
641        match item {
642            InputItem::EasyMessage(easy) => {
643                assert_eq!(easy.role, Role::User);
644                assert_eq!(easy.r#type, MessageType::Message);
645                match easy.content {
646                    EasyInputContent::ContentList(parts) => {
647                        assert_eq!(parts.len(), 1);
648                        match &parts[0] {
649                            InputContent::InputImage(img) => {
650                                assert_eq!(img.detail, ImageDetail::Auto);
651                                assert_eq!(
652                                    img.image_url.as_deref(),
653                                    Some("data:image/png;base64,abc")
654                                );
655                            }
656                            other => panic!("expected InputImage, got {other:?}"),
657                        }
658                    }
659                    other => panic!("expected ContentList, got {other:?}"),
660                }
661            }
662            other => panic!("expected EasyMessage, got {other:?}"),
663        }
664    }
665
666    #[test]
667    fn easy_message_multimodal_with_explicit_null_detail() {
668        // Same shape as above but with `detail: null` — exercises the
669        // null-as-default path on the relaxed `InputImageContent` reached via
670        // the EasyMessage variant.
671        let json = serde_json::json!({
672            "role": "user",
673            "content": [
674                {"type": "input_image", "image_url": "data:image/png;base64,abc", "detail": null}
675            ]
676        });
677        let item: InputItem = serde_json::from_value(json).unwrap();
678        assert!(matches!(item, InputItem::EasyMessage(_)));
679    }
680
681    #[test]
682    fn easy_message_assistant_multimodal_without_type() {
683        // Mixed-turn shape AIPerf emits when the prior assistant turn carried
684        // structured (non-string) content: role=assistant, content list, no
685        // top-level `type`.
686        let json = serde_json::json!({
687            "role": "assistant",
688            "content": [
689                {"type": "input_text", "text": "ok"}
690            ]
691        });
692        let item: InputItem = serde_json::from_value(json).unwrap();
693        match item {
694            InputItem::EasyMessage(easy) => {
695                assert_eq!(easy.role, Role::Assistant);
696            }
697            other => panic!("expected EasyMessage(assistant), got {other:?}"),
698        }
699    }
700
701    #[test]
702    fn easy_message_text_only_without_type_unchanged() {
703        // Regression guard: the pre-existing text-only path was already
704        // working (no multimodal content -> never hit upstream's strict
705        // `InputImageContent`). Pin it so a future glob-shadow change can't
706        // break it.
707        let json = serde_json::json!({"role": "user", "content": "Hello"});
708        let item: InputItem = serde_json::from_value(json).unwrap();
709        match item {
710            InputItem::EasyMessage(easy) => {
711                assert_eq!(easy.role, Role::User);
712                assert!(matches!(easy.content, EasyInputContent::Text(ref s) if s == "Hello"));
713            }
714            other => panic!("expected EasyMessage(Text), got {other:?}"),
715        }
716    }
717
718    #[test]
719    fn easy_message_with_explicit_type_still_routes_to_item_message() {
720        // AIPerf's post-PR-931 payload (with `type: "message"`) should still
721        // hit the structured `Item::Message` path first — proving the existing
722        // strict path didn't regress when EasyMessage was shadowed.
723        let json = serde_json::json!({
724            "type": "message",
725            "role": "user",
726            "content": [
727                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
728            ]
729        });
730        let item: InputItem = serde_json::from_value(json).unwrap();
731        match item {
732            InputItem::Item(Item::Message(MessageItem::Input(msg))) => {
733                assert_eq!(msg.role, InputRole::User);
734                assert_eq!(msg.content.len(), 1);
735            }
736            other => panic!("expected Item::Message(Input), got {other:?}"),
737        }
738    }
739
740    #[test]
741    fn create_response_roundtrip_aiperf_pre_pr931_payload() {
742        // End-to-end shape: the exact request body AIPerf was emitting before
743        // PR-931 for a multi-turn multimodal conversation. Mirrors what the
744        // HTTP frontend receives. Must deserialize without error and preserve
745        // turn ordering.
746        let body = serde_json::json!({
747            "model": "Qwen/Qwen2-VL-2B-Instruct",
748            "input": [
749                {
750                    "role": "user",
751                    "content": [
752                        {"type": "input_text", "text": "Describe"},
753                        {"type": "input_image", "image_url": "data:image/png;base64,abc"}
754                    ]
755                },
756                {
757                    "role": "assistant",
758                    "content": [{"type": "input_text", "text": "ok"}]
759                },
760                {
761                    "role": "user",
762                    "content": [{"type": "input_text", "text": "Now describe a different one."}]
763                }
764            ]
765        });
766        let req: CreateResponse = serde_json::from_value(body).unwrap();
767        let items = match &req.input {
768            InputParam::Items(items) => items,
769            _ => panic!("expected Items"),
770        };
771        assert_eq!(items.len(), 3);
772        // All three turns must land as EasyMessage (no top-level `type`).
773        for (idx, item) in items.iter().enumerate() {
774            assert!(
775                matches!(item, InputItem::EasyMessage(_)),
776                "turn {idx} did not route to EasyMessage: {item:?}",
777            );
778        }
779    }
780}