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, de};
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/// Private Codex wire shape, normalized to an existing user message.
340#[derive(Deserialize)]
341struct CodexAgentMessage {
342    #[serde(default)]
343    content: Option<CodexAgentMessageContent>,
344}
345
346#[derive(Deserialize)]
347#[serde(untagged)]
348enum CodexAgentMessageContent {
349    Text(String),
350    Parts(Vec<CodexAgentMessageInputContent>),
351}
352
353#[derive(Deserialize)]
354#[serde(tag = "type", rename_all = "snake_case")]
355enum CodexAgentMessageInputContent {
356    InputText(InputTextContent),
357    EncryptedContent { encrypted_content: String },
358}
359
360/// Structured input/output item, discriminated by `type`. Mirrors upstream
361/// variant-for-variant; only `Message` and `Reasoning` use owned types.
362#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
363#[serde(tag = "type", rename_all = "snake_case")]
364pub enum Item {
365    Message(MessageItem),
366    FileSearchCall(FileSearchToolCall),
367    ComputerCall(ComputerToolCall),
368    ComputerCallOutput(ComputerCallOutputItemParam),
369    WebSearchCall(WebSearchToolCall),
370    FunctionCall(FunctionToolCall),
371    FunctionCallOutput(FunctionCallOutputItemParam),
372    ToolSearchCall(ToolSearchCallItemParam),
373    ToolSearchOutput(ToolSearchOutputItemParam),
374    Reasoning(InputReasoningItem),
375    Compaction(CompactionSummaryItemParam),
376    ImageGenerationCall(ImageGenToolCall),
377    CodeInterpreterCall(CodeInterpreterToolCall),
378    LocalShellCall(LocalShellToolCall),
379    LocalShellCallOutput(LocalShellToolCallOutput),
380    ShellCall(FunctionShellCallItemParam),
381    ShellCallOutput(FunctionShellCallOutputItemParam),
382    ApplyPatchCall(ApplyPatchToolCallItemParam),
383    ApplyPatchCallOutput(ApplyPatchToolCallOutputItemParam),
384    McpListTools(MCPListTools),
385    McpApprovalRequest(MCPApprovalRequest),
386    McpApprovalResponse(MCPApprovalResponse),
387    McpCall(MCPToolCall),
388    CustomToolCallOutput(CustomToolCallOutput),
389    CustomToolCall(CustomToolCall),
390}
391
392/// Single input item. Untagged; order matters (most specific first).
393#[derive(Debug, Serialize, Clone, PartialEq)]
394#[serde(untagged)]
395pub enum InputItem {
396    ItemReference(ItemReference),
397    Item(Item),
398    EasyMessage(EasyInputMessage),
399}
400
401#[derive(Deserialize)]
402#[serde(untagged)]
403enum InputItemWire {
404    ItemReference(ItemReference),
405    Item(Item),
406    EasyMessage(EasyInputMessage),
407}
408
409impl<'de> Deserialize<'de> for InputItem {
410    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
411    where
412        D: serde::Deserializer<'de>,
413    {
414        let value = serde_json::Value::deserialize(deserializer)?;
415        if value.get("type").and_then(serde_json::Value::as_str) == Some("agent_message") {
416            let message = CodexAgentMessage::deserialize(value).map_err(de::Error::custom)?;
417            return Ok(normalize_codex_agent_message(message));
418        }
419
420        match InputItemWire::deserialize(value).map_err(de::Error::custom)? {
421            InputItemWire::ItemReference(item) => Ok(Self::ItemReference(item)),
422            InputItemWire::Item(item) => Ok(Self::Item(item)),
423            InputItemWire::EasyMessage(message) => Ok(Self::EasyMessage(message)),
424        }
425    }
426}
427
428fn normalize_codex_agent_message(message: CodexAgentMessage) -> InputItem {
429    let content = match message.content {
430        None => String::new(),
431        Some(CodexAgentMessageContent::Text(text)) => text,
432        Some(CodexAgentMessageContent::Parts(parts)) => parts
433            .into_iter()
434            .map(|part| match part {
435                CodexAgentMessageInputContent::InputText(part) => part.text,
436                CodexAgentMessageInputContent::EncryptedContent { encrypted_content } => {
437                    encrypted_content
438                }
439            })
440            .collect::<Vec<_>>()
441            .join("\n"),
442    };
443    InputItem::EasyMessage(EasyInputMessage {
444        r#type: MessageType::Message,
445        role: Role::User,
446        content: EasyInputContent::Text(content),
447        phase: None,
448    })
449}
450
451/// Input to a `POST /v1/responses` request.
452#[derive(Debug, Serialize, Clone, PartialEq)]
453#[serde(untagged)]
454pub enum InputParam {
455    Text(String),
456    Items(Vec<InputItem>),
457}
458
459impl<'de> Deserialize<'de> for InputParam {
460    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
461    where
462        D: serde::Deserializer<'de>,
463    {
464        match serde_json::Value::deserialize(deserializer)? {
465            serde_json::Value::String(text) => Ok(Self::Text(text)),
466            serde_json::Value::Array(items) => {
467                serde_json::from_value(serde_json::Value::Array(items))
468                    .map(Self::Items)
469                    .map_err(de::Error::custom)
470            }
471            _ => Err(de::Error::custom(
472                "input must be a string or an array of input items",
473            )),
474        }
475    }
476}
477
478impl Default for InputParam {
479    fn default() -> Self {
480        Self::Text(String::new())
481    }
482}
483
484// ---------------------------------------------------------------------------
485// CreateResponse (owned, uses Dynamo-owned InputParam)
486// ---------------------------------------------------------------------------
487
488/// Request body for `POST /v1/responses`. Mirrors upstream `CreateResponse`
489/// field-for-field but uses Dynamo-owned `InputParam`, which transitively
490/// accepts the relaxed input shapes described in this module's header. All
491/// other fields reference upstream types verbatim.
492#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
493pub struct CreateResponse {
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub background: Option<bool>,
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub conversation: Option<ConversationParam>,
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub include: Option<Vec<IncludeEnum>>,
500    pub input: InputParam,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub instructions: Option<String>,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub max_output_tokens: Option<u32>,
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub max_tool_calls: Option<u32>,
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub metadata: Option<HashMap<String, String>>,
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub model: Option<String>,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub parallel_tool_calls: Option<bool>,
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub previous_response_id: Option<String>,
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub prompt: Option<Prompt>,
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub prompt_cache_key: Option<String>,
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub prompt_cache_retention: Option<PromptCacheRetention>,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub reasoning: Option<Reasoning>,
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub safety_identifier: Option<String>,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub service_tier: Option<ServiceTier>,
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub store: Option<bool>,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub stream: Option<bool>,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub stream_options: Option<ResponseStreamOptions>,
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub temperature: Option<f32>,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub text: Option<ResponseTextParam>,
537    #[serde(
538        default,
539        deserialize_with = "deserialize_tool_choice",
540        skip_serializing_if = "Option::is_none"
541    )]
542    pub tool_choice: Option<ToolChoiceParam>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub tools: Option<Vec<Tool>>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub top_logprobs: Option<u8>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub top_p: Option<f32>,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub truncation: Option<Truncation>,
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    // ---- tool_choice object form (ai-dynamo/dynamo#10963 CASE 1) ----
558
559    fn tool_choice_of(json: serde_json::Value) -> Option<ToolChoiceParam> {
560        let req: CreateResponse = serde_json::from_value(serde_json::json!({
561            "input": "hi",
562            "tool_choice": json,
563        }))
564        .expect("CreateResponse should deserialize");
565        req.tool_choice
566    }
567
568    #[test]
569    fn tool_choice_mode_object_coerces_to_mode() {
570        // Anthropic-style / litellm shape: a mode expressed as an object with
571        // extra keys. Must coerce to the corresponding `Mode`, ignoring extras.
572        assert_eq!(
573            tool_choice_of(serde_json::json!({"type": "auto", "disable_parallel_tool_use": true})),
574            Some(ToolChoiceParam::Mode(ToolChoiceOptions::Auto)),
575        );
576        assert_eq!(
577            tool_choice_of(serde_json::json!({"type": "none"})),
578            Some(ToolChoiceParam::Mode(ToolChoiceOptions::None)),
579        );
580        assert_eq!(
581            tool_choice_of(serde_json::json!({"type": "required"})),
582            Some(ToolChoiceParam::Mode(ToolChoiceOptions::Required)),
583        );
584    }
585
586    #[test]
587    fn tool_choice_bare_string_still_works() {
588        assert_eq!(
589            tool_choice_of(serde_json::json!("auto")),
590            Some(ToolChoiceParam::Mode(ToolChoiceOptions::Auto)),
591        );
592    }
593
594    #[test]
595    fn tool_choice_specific_function_object_still_works() {
596        // The object form naming a specific tool must NOT be swallowed by the
597        // mode coercion — `type: "function"` is not a mode.
598        match tool_choice_of(serde_json::json!({"type": "function", "name": "get_weather"})) {
599            Some(ToolChoiceParam::Function(f)) => assert_eq!(f.name, "get_weather"),
600            other => panic!("expected Function tool choice, got {other:?}"),
601        }
602    }
603
604    #[test]
605    fn tool_choice_absent_is_none() {
606        let req: CreateResponse =
607            serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
608        assert!(req.tool_choice.is_none());
609    }
610
611    // ---- reasoning item echoed back without id/summary (#10963 CASE 2) ----
612
613    #[test]
614    fn reasoning_input_without_id_deserializes() {
615        // Codex / OpenCode / agent SDKs echo a reasoning item with no `id`.
616        let json = serde_json::json!({
617            "type": "reasoning",
618            "summary": [{"type": "summary_text", "text": "thinking"}],
619        });
620        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
621            InputItem::Item(Item::Reasoning(r)) => {
622                assert!(r.id.is_none());
623                assert_eq!(r.summary.len(), 1);
624            }
625            other => panic!("expected Item::Reasoning, got {other:?}"),
626        }
627    }
628
629    #[test]
630    fn reasoning_input_encrypted_without_id_or_summary_deserializes() {
631        let json = serde_json::json!({
632            "type": "reasoning",
633            "encrypted_content": "AB==",
634        });
635        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
636            InputItem::Item(Item::Reasoning(r)) => {
637                assert!(r.id.is_none());
638                assert!(r.summary.is_empty());
639                assert_eq!(r.encrypted_content.as_deref(), Some("AB=="));
640            }
641            other => panic!("expected Item::Reasoning, got {other:?}"),
642        }
643    }
644
645    #[test]
646    fn reasoning_input_with_id_still_works() {
647        let json = serde_json::json!({
648            "type": "reasoning",
649            "id": "rs_1",
650            "summary": [{"type": "summary_text", "text": "x"}],
651            "status": "completed",
652        });
653        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
654            InputItem::Item(Item::Reasoning(r)) => assert_eq!(r.id.as_deref(), Some("rs_1")),
655            other => panic!("expected Item::Reasoning, got {other:?}"),
656        }
657    }
658
659    #[test]
660    fn full_request_with_idless_reasoning_item_deserializes() {
661        // The exact failure mode reported in #10963: a turn-2 `input` list
662        // containing an echoed reasoning item that lost its `id`.
663        let req: Result<CreateResponse, _> = serde_json::from_value(serde_json::json!({
664            "model": "m",
665            "input": [
666                {"role": "user", "content": "hi"},
667                {"type": "reasoning", "summary": [{"type": "summary_text", "text": "x"}]},
668            ],
669        }));
670        assert!(
671            req.is_ok(),
672            "idless reasoning input should deserialize: {req:?}"
673        );
674    }
675
676    #[test]
677    fn codex_agent_message_normalizes_to_user_message() {
678        let req: CreateResponse = serde_json::from_value(serde_json::json!({
679            "input": [{
680                "type": "agent_message",
681                "author": "/root",
682                "recipient": "/root/worker",
683                "content": [
684                    {"type": "input_text", "text": "First."},
685                    {"type": "input_text", "text": "Second."},
686                ],
687            }],
688        }))
689        .expect("Codex agent message should deserialize");
690
691        let InputParam::Items(items) = req.input else {
692            panic!("expected items");
693        };
694        assert!(matches!(
695            &items[0],
696            InputItem::EasyMessage(EasyInputMessage {
697                role: Role::User,
698                content: EasyInputContent::Text(text),
699                ..
700            }) if text == "First.\nSecond."
701        ));
702    }
703
704    #[test]
705    fn codex_agent_message_string_content_normalizes_to_user_message() {
706        let item: InputItem = serde_json::from_value(serde_json::json!({
707            "type": "agent_message",
708            "author": "/root",
709            "recipient": "/root/worker",
710            "content": "Return exactly OK.",
711        }))
712        .expect("Codex agent message with string content should deserialize");
713
714        assert!(matches!(
715            item,
716            InputItem::EasyMessage(EasyInputMessage {
717                content: EasyInputContent::Text(text),
718                ..
719            }) if text == "Return exactly OK."
720        ));
721    }
722
723    #[test]
724    fn codex_agent_message_normalizes_encrypted_content() {
725        let req: CreateResponse = serde_json::from_value(serde_json::json!({
726            "input": [{
727                "type": "agent_message",
728                "content": [
729                    {"type": "input_text", "text": "Payload:"},
730                    {"type": "encrypted_content", "encrypted_content": "Return exactly OK."},
731                ],
732            }],
733        }))
734        .expect("Codex agent message with encrypted content should deserialize");
735
736        let InputParam::Items(items) = req.input else {
737            panic!("expected items");
738        };
739        assert!(matches!(
740            &items[0],
741            InputItem::EasyMessage(EasyInputMessage {
742                content: EasyInputContent::Text(text),
743                ..
744            }) if text == "Payload:\nReturn exactly OK."
745        ));
746    }
747
748    #[test]
749    fn codex_agent_message_missing_content_normalizes_empty() {
750        let item: InputItem = serde_json::from_value(serde_json::json!({
751            "type": "agent_message",
752            "author": "/root",
753            "recipient": "/root/worker",
754        }))
755        .expect("Codex agent message without content should deserialize");
756        assert!(matches!(
757            item,
758            InputItem::EasyMessage(EasyInputMessage {
759                content: EasyInputContent::Text(text),
760                ..
761            }) if text.is_empty()
762        ));
763    }
764
765    #[test]
766    fn codex_agent_message_null_content_normalizes_empty() {
767        let item: InputItem = serde_json::from_value(serde_json::json!({
768            "type": "agent_message",
769            "author": "/root",
770            "recipient": "/root/worker",
771            "content": null,
772        }))
773        .expect("Codex agent message with null content should deserialize");
774        assert!(matches!(
775            item,
776            InputItem::EasyMessage(EasyInputMessage {
777                content: EasyInputContent::Text(text),
778                ..
779            }) if text.is_empty()
780        ));
781    }
782
783    #[test]
784    fn relaxed_assistant_message_without_id_or_status() {
785        let json = serde_json::json!({
786            "type": "message",
787            "role": "assistant",
788            "content": [{"type": "output_text", "text": "hi"}]
789        });
790        let item: InputItem = serde_json::from_value(json).unwrap();
791        match item {
792            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
793                assert_eq!(out.role, AssistantRole::Assistant);
794                assert!(out.id.is_none());
795                assert!(out.status.is_none());
796            }
797            other => panic!("expected Item::Message(Output), got {other:?}"),
798        }
799    }
800
801    #[test]
802    fn input_image_without_detail_defaults_to_auto() {
803        let json = serde_json::json!({
804            "type": "input_image",
805            "image_url": "https://example.com/cat.jpg"
806        });
807        let content: InputContent = serde_json::from_value(json).unwrap();
808        match content {
809            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
810            other => panic!("expected InputImage, got {other:?}"),
811        }
812    }
813
814    #[test]
815    fn input_image_with_explicit_null_detail_defaults_to_auto() {
816        let json = serde_json::json!({
817            "type": "input_image",
818            "image_url": "https://example.com/cat.jpg",
819            "detail": null
820        });
821        let content: InputContent = serde_json::from_value(json).unwrap();
822        match content {
823            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
824            other => panic!("expected InputImage, got {other:?}"),
825        }
826    }
827
828    #[test]
829    fn assistant_message_without_content_field_deserializes() {
830        // Bare assistant shell — no `content` field at all. Seen in real
831        // Codex/Agents-SDK traffic on pure tool-call turns. `#[serde(default)]`
832        // on `content` must accept omission and yield an empty vec.
833        let json = serde_json::json!({
834            "type": "message",
835            "role": "assistant"
836        });
837        let item: InputItem = serde_json::from_value(json).unwrap();
838        match item {
839            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
840                assert_eq!(out.role, AssistantRole::Assistant);
841                assert!(out.content.is_empty());
842                assert!(out.id.is_none());
843                assert!(out.status.is_none());
844            }
845            other => panic!("expected Item::Message(Output), got {other:?}"),
846        }
847    }
848
849    #[test]
850    fn assistant_message_with_explicit_null_content_deserializes() {
851        // Mirrors the `annotations: null` case: some serializers emit JSON null
852        // for absent fields instead of omitting them. `Vec::deserialize` rejects
853        // null, so `content` also needs `deserialize_null_as_empty_vec`.
854        let json = serde_json::json!({
855            "type": "message",
856            "role": "assistant",
857            "content": null
858        });
859        let item: InputItem = serde_json::from_value(json).unwrap();
860        match item {
861            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
862                assert!(out.content.is_empty());
863            }
864            other => panic!("expected Item::Message(Output), got {other:?}"),
865        }
866    }
867
868    #[test]
869    fn mcp_call_item_deserializes() {
870        // Guards against Item variant drift vs upstream — MCP item types were
871        // added after the initial owned `Item` chain landed.
872        let json = serde_json::json!({
873            "type": "mcp_call",
874            "id": "mcp_1",
875            "server_label": "srv",
876            "name": "t",
877            "arguments": "{}"
878        });
879        let item: InputItem = serde_json::from_value(json).unwrap();
880        assert!(matches!(item, InputItem::Item(Item::McpCall(_))));
881    }
882
883    #[test]
884    fn strict_assistant_message_still_deserializes() {
885        let json = serde_json::json!({
886            "type": "message",
887            "role": "assistant",
888            "id": "msg_1",
889            "status": "completed",
890            "content": [{"type": "output_text", "text": "hi", "annotations": []}]
891        });
892        let item: InputItem = serde_json::from_value(json).unwrap();
893        match item {
894            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
895                assert_eq!(out.id.as_deref(), Some("msg_1"));
896                assert_eq!(out.status, Some(OutputStatus::Completed));
897            }
898            other => panic!("expected Item::Message(Output), got {other:?}"),
899        }
900    }
901
902    #[test]
903    fn user_message_routes_to_input_variant() {
904        let json = serde_json::json!({
905            "type": "message",
906            "role": "user",
907            "content": [{"type": "input_text", "text": "hi"}]
908        });
909        let item: InputItem = serde_json::from_value(json).unwrap();
910        assert!(matches!(
911            item,
912            InputItem::Item(Item::Message(MessageItem::Input(_)))
913        ));
914    }
915
916    #[test]
917    fn function_call_item_still_deserializes() {
918        let json = serde_json::json!({
919            "type": "function_call",
920            "call_id": "c",
921            "name": "f",
922            "arguments": "{}"
923        });
924        let item: InputItem = serde_json::from_value(json).unwrap();
925        assert!(matches!(item, InputItem::Item(Item::FunctionCall(_))));
926    }
927
928    #[test]
929    fn easy_message_string_content_routes_to_easymessage() {
930        let json = serde_json::json!({"role": "assistant", "content": "x"});
931        let item: InputItem = serde_json::from_value(json).unwrap();
932        assert!(matches!(item, InputItem::EasyMessage(_)));
933    }
934
935    #[test]
936    fn output_text_without_annotations_defaults_empty() {
937        let json = serde_json::json!({"type": "output_text", "text": "hi"});
938        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
939        match part {
940            InputOutputMessageContent::OutputText(t) => {
941                assert!(t.annotations.is_empty());
942            }
943            _ => panic!("expected OutputText"),
944        }
945    }
946
947    #[test]
948    fn output_text_with_explicit_null_annotations_deserializes_as_empty() {
949        // Some clients serialize absent fields as JSON null instead of omitting
950        // them. `Vec::deserialize` would reject null; the custom deserializer
951        // treats explicit null identically to a missing field.
952        let json = serde_json::json!({"type": "output_text", "text": "hi", "annotations": null});
953        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
954        match part {
955            InputOutputMessageContent::OutputText(t) => {
956                assert!(t.annotations.is_empty());
957            }
958            _ => panic!("expected OutputText"),
959        }
960    }
961
962    #[test]
963    fn assistant_message_with_explicit_null_id_and_status_deserializes() {
964        // `Option<T>` natively accepts null as `None`, so these explicit-null
965        // fields should flow through without a custom deserializer. This test
966        // pins that behavior against accidental regressions (e.g. if someone
967        // switches the field type away from `Option<_>`).
968        let json = serde_json::json!({
969            "type": "message",
970            "role": "assistant",
971            "id": null,
972            "status": null,
973            "content": [{"type": "output_text", "text": "hi", "annotations": null}]
974        });
975        let item: InputItem = serde_json::from_value(json).unwrap();
976        match item {
977            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
978                assert!(out.id.is_none());
979                assert!(out.status.is_none());
980                assert_eq!(out.content.len(), 1);
981            }
982            other => panic!("expected Item::Message(Output), got {other:?}"),
983        }
984    }
985
986    #[test]
987    fn create_response_roundtrip_with_relaxed_input() {
988        let body = serde_json::json!({
989            "model": "m",
990            "input": [
991                {"type": "message", "role": "user", "content": [
992                    {"type": "input_text", "text": "hi"}
993                ]},
994                {"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
995                {"type": "message", "role": "assistant", "content": [
996                    {"type": "output_text", "text": "\n\n"}
997                ]},
998                {"type": "function_call_output", "call_id": "c", "output": "x"}
999            ]
1000        });
1001
1002        let req: CreateResponse = serde_json::from_value(body).unwrap();
1003        let items = match &req.input {
1004            InputParam::Items(items) => items,
1005            _ => panic!("expected Items"),
1006        };
1007        assert_eq!(items.len(), 4);
1008        assert!(matches!(
1009            items[2],
1010            InputItem::Item(Item::Message(MessageItem::Output(_)))
1011        ));
1012    }
1013
1014    // ---- EasyInputMessage / multimodal-without-`type` regression coverage ----
1015    // See issue #9468. Before the EasyInputMessage/EasyInputContent shadow
1016    // landed, the `InputItem::EasyMessage` fallback still routed through
1017    // upstream's strict `InputImageContent` (required `detail`), so any
1018    // multimodal message that omitted the spec-default `type: "message"` would
1019    // fail with "data did not match any variant of untagged enum InputItem".
1020
1021    #[test]
1022    fn easy_message_multimodal_without_type_routes_to_easymessage() {
1023        // AIPerf's pre-PR-931 payload shape: no top-level `type`, content is a
1024        // list containing an `input_image` part with no `detail`.
1025        let json = serde_json::json!({
1026            "role": "user",
1027            "content": [
1028                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1029            ]
1030        });
1031        let item: InputItem = serde_json::from_value(json).unwrap();
1032        match item {
1033            InputItem::EasyMessage(easy) => {
1034                assert_eq!(easy.role, Role::User);
1035                assert_eq!(easy.r#type, MessageType::Message);
1036                match easy.content {
1037                    EasyInputContent::ContentList(parts) => {
1038                        assert_eq!(parts.len(), 1);
1039                        match &parts[0] {
1040                            InputContent::InputImage(img) => {
1041                                assert_eq!(img.detail, ImageDetail::Auto);
1042                                assert_eq!(
1043                                    img.image_url.as_deref(),
1044                                    Some("data:image/png;base64,abc")
1045                                );
1046                            }
1047                            other => panic!("expected InputImage, got {other:?}"),
1048                        }
1049                    }
1050                    other => panic!("expected ContentList, got {other:?}"),
1051                }
1052            }
1053            other => panic!("expected EasyMessage, got {other:?}"),
1054        }
1055    }
1056
1057    #[test]
1058    fn easy_message_multimodal_with_explicit_null_detail() {
1059        // Same shape as above but with `detail: null` — exercises the
1060        // null-as-default path on the relaxed `InputImageContent` reached via
1061        // the EasyMessage variant.
1062        let json = serde_json::json!({
1063            "role": "user",
1064            "content": [
1065                {"type": "input_image", "image_url": "data:image/png;base64,abc", "detail": null}
1066            ]
1067        });
1068        let item: InputItem = serde_json::from_value(json).unwrap();
1069        assert!(matches!(item, InputItem::EasyMessage(_)));
1070    }
1071
1072    #[test]
1073    fn easy_message_assistant_multimodal_without_type() {
1074        // Mixed-turn shape AIPerf emits when the prior assistant turn carried
1075        // structured (non-string) content: role=assistant, content list, no
1076        // top-level `type`.
1077        let json = serde_json::json!({
1078            "role": "assistant",
1079            "content": [
1080                {"type": "input_text", "text": "ok"}
1081            ]
1082        });
1083        let item: InputItem = serde_json::from_value(json).unwrap();
1084        match item {
1085            InputItem::EasyMessage(easy) => {
1086                assert_eq!(easy.role, Role::Assistant);
1087            }
1088            other => panic!("expected EasyMessage(assistant), got {other:?}"),
1089        }
1090    }
1091
1092    #[test]
1093    fn easy_message_text_only_without_type_unchanged() {
1094        // Regression guard: the pre-existing text-only path was already
1095        // working (no multimodal content -> never hit upstream's strict
1096        // `InputImageContent`). Pin it so a future glob-shadow change can't
1097        // break it.
1098        let json = serde_json::json!({"role": "user", "content": "Hello"});
1099        let item: InputItem = serde_json::from_value(json).unwrap();
1100        match item {
1101            InputItem::EasyMessage(easy) => {
1102                assert_eq!(easy.role, Role::User);
1103                assert!(matches!(easy.content, EasyInputContent::Text(ref s) if s == "Hello"));
1104            }
1105            other => panic!("expected EasyMessage(Text), got {other:?}"),
1106        }
1107    }
1108
1109    #[test]
1110    fn easy_message_with_explicit_type_still_routes_to_item_message() {
1111        // AIPerf's post-PR-931 payload (with `type: "message"`) should still
1112        // hit the structured `Item::Message` path first — proving the existing
1113        // strict path didn't regress when EasyMessage was shadowed.
1114        let json = serde_json::json!({
1115            "type": "message",
1116            "role": "user",
1117            "content": [
1118                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1119            ]
1120        });
1121        let item: InputItem = serde_json::from_value(json).unwrap();
1122        match item {
1123            InputItem::Item(Item::Message(MessageItem::Input(msg))) => {
1124                assert_eq!(msg.role, InputRole::User);
1125                assert_eq!(msg.content.len(), 1);
1126            }
1127            other => panic!("expected Item::Message(Input), got {other:?}"),
1128        }
1129    }
1130
1131    #[test]
1132    fn create_response_roundtrip_aiperf_pre_pr931_payload() {
1133        // End-to-end shape: the exact request body AIPerf was emitting before
1134        // PR-931 for a multi-turn multimodal conversation. Mirrors what the
1135        // HTTP frontend receives. Must deserialize without error and preserve
1136        // turn ordering.
1137        let body = serde_json::json!({
1138            "model": "Qwen/Qwen2-VL-2B-Instruct",
1139            "input": [
1140                {
1141                    "role": "user",
1142                    "content": [
1143                        {"type": "input_text", "text": "Describe"},
1144                        {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1145                    ]
1146                },
1147                {
1148                    "role": "assistant",
1149                    "content": [{"type": "input_text", "text": "ok"}]
1150                },
1151                {
1152                    "role": "user",
1153                    "content": [{"type": "input_text", "text": "Now describe a different one."}]
1154                }
1155            ]
1156        });
1157        let req: CreateResponse = serde_json::from_value(body).unwrap();
1158        let items = match &req.input {
1159            InputParam::Items(items) => items,
1160            _ => panic!("expected Items"),
1161        };
1162        assert_eq!(items.len(), 3);
1163        // All three turns must land as EasyMessage (no top-level `type`).
1164        for (idx, item) in items.iter().enumerate() {
1165            assert!(
1166                matches!(item, InputItem::EasyMessage(_)),
1167                "turn {idx} did not route to EasyMessage: {item:?}",
1168            );
1169        }
1170    }
1171}