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