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