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 from parent module for backward compat.
41pub use crate::types::ImageDetail;
42pub use crate::types::ReasoningEffort;
43pub use crate::types::ResponseFormatJsonSchema;
44
45// Backward-compatible type aliases for Dynamo consumer code migration.
46pub type Input = InputParam;
47pub type PromptConfig = Prompt;
48pub type TextConfig = ResponseTextParam;
49pub type TextResponseFormat = TextResponseFormatConfiguration;
50
51/// Stream of response events.
52pub type ResponseStream = std::pin::Pin<
53    Box<dyn futures::Stream<Item = Result<ResponseStreamEvent, crate::error::OpenAIError>> + Send>,
54>;
55
56/// Fields on upstream `Response` that the OpenResponses spec requires as
57/// `T | null` but async-openai declares as `Option<T>` with
58/// `skip_serializing_if = Option::is_none` — meaning `None` disappears from
59/// the wire shape, where the spec wants an explicit `null`.
60///
61/// Colocated here (next to the upstream `Response` re-export) rather than in
62/// `lib/llm/src/protocols/openai/responses/mod.rs` so that when upstream's
63/// `Response` gains a new nullable-required field, the reviewer editing this
64/// module is looking directly at the authoritative list. Keep sorted
65/// alphabetically; entries must match serde field names on `Response` exactly.
66///
67/// Any field we unconditionally populate ourselves during response
68/// construction (e.g. `metadata`, `parallel_tool_calls`, `temperature`,
69/// `text`, `tool_choice`, `tools`, `top_p`, `top_logprobs`, `truncation`,
70/// `service_tier`, `background`) is deliberately absent — it's always
71/// present on the wire, so listing it here would be noise.
72pub const SPEC_NULLABLE_REQUIRED_RESPONSE_FIELDS: &[&str] = &[
73    "billing",
74    "completed_at",
75    "conversation",
76    "error",
77    "incomplete_details",
78    "instructions",
79    "max_output_tokens",
80    "max_tool_calls",
81    "previous_response_id",
82    "prompt",
83    "prompt_cache_key",
84    "prompt_cache_retention",
85    "reasoning",
86    "safety_identifier",
87    "usage",
88];
89
90// ---------------------------------------------------------------------------
91// Input-side assistant message (relaxed vs upstream OutputMessage)
92// ---------------------------------------------------------------------------
93
94/// Deserialize `null` or a missing field as the default empty `Vec`. Plain
95/// `#[serde(default)]` only fires when the field is absent; explicit `null`
96/// would otherwise fail `Vec::deserialize`. Clients (notably some Agents SDK
97/// variants) have been observed to send `"annotations": null`, so treat
98/// omission and explicit null the same.
99fn deserialize_null_as_empty_vec<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
100where
101    T: Deserialize<'de>,
102    D: serde::Deserializer<'de>,
103{
104    Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
105}
106
107/// Deserialize `null` or a missing field as `T::default()`. Scalar counterpart
108/// to `deserialize_null_as_empty_vec` — plain `#[serde(default)]` rejects
109/// explicit `null` because serde tries to deserialize the null into `T` and
110/// fails. Real clients emit `null` for unset enum-ish fields (e.g. OpenAI
111/// Agents SDK sending `"detail": null` on `input_image` parts).
112fn deserialize_null_as_default<'de, T, D>(deserializer: D) -> Result<T, D::Error>
113where
114    T: Deserialize<'de> + Default,
115    D: serde::Deserializer<'de>,
116{
117    Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
118}
119
120/// Deserialize `tool_choice`, coercing the object form `{"type": "auto" |
121/// "none" | "required", ...}` into the upstream `Option` variant.
122///
123/// Upstream `ToolChoiceParam` only accepts `auto`/`none`/`required` as a bare
124/// string; the object form is reserved for naming a *specific* tool
125/// (`{"type": "function", "name": ...}`). But Anthropic-style clients (and
126/// litellm forwarding them verbatim) express the mode as an object, e.g.
127/// `{"type": "auto", "disable_parallel_tool_use": true}`. OpenAI's hosted API
128/// treats `{"type": "auto"}` and the bare `"auto"` identically; we do the same.
129/// Extra keys (e.g. `disable_parallel_tool_use`) are accepted and ignored —
130/// there is no per-call parallel-tool-use toggle to honor.
131///
132/// Any value that is not a mode-typed object falls through to standard
133/// `ToolChoiceParam` deserialization, so bare strings and specific-tool /
134/// hosted-tool objects keep working unchanged.
135fn deserialize_tool_choice<'de, D>(deserializer: D) -> Result<Option<ToolChoiceParam>, D::Error>
136where
137    D: serde::Deserializer<'de>,
138{
139    let Some(value) = Option::<serde_json::Value>::deserialize(deserializer)? else {
140        return Ok(None);
141    };
142    if let Some(serde_json::Value::String(t)) = value.get("type") {
143        let mode = match t.as_str() {
144            "auto" => Some(ToolChoiceOptions::Auto),
145            "none" => Some(ToolChoiceOptions::None),
146            "required" => Some(ToolChoiceOptions::Required),
147            _ => None,
148        };
149        if let Some(mode) = mode {
150            return Ok(Some(ToolChoiceParam::Option(mode)));
151        }
152    }
153    ToolChoiceParam::deserialize(value)
154        .map(Some)
155        .map_err(serde::de::Error::custom)
156}
157
158/// Relaxed counterpart to upstream `OutputTextContent` for input-side content.
159/// `annotations` tolerates both missing and explicit `null`; upstream requires
160/// it to be a present non-null array.
161#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
162pub struct InputOutputTextContent {
163    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
164    pub annotations: Vec<Annotation>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub logprobs: Option<Vec<LogProb>>,
167    pub text: String,
168}
169
170/// Content parts of a prior assistant message presented as input.
171#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
172#[serde(tag = "type", rename_all = "snake_case")]
173pub enum InputOutputMessageContent {
174    OutputText(InputOutputTextContent),
175    Refusal(RefusalContent),
176}
177
178/// An assistant message echoed back as input for a subsequent turn. Relaxed
179/// compared to upstream `OutputMessage`: `id`, `status`, and `content` are all
180/// optional. Some clients send a bare assistant shell (`{"type":"message",
181/// "role":"assistant"}`) with no `content` at all, usually on pure tool-call
182/// turns; treat absent `content` as an empty vec, same way we treat a missing
183/// `id`/`status`.
184#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
185pub struct InputOutputMessage {
186    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
187    pub content: Vec<InputOutputMessageContent>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub id: Option<String>,
190    pub role: AssistantRole,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub phase: Option<MessagePhase>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub status: Option<OutputStatus>,
195}
196
197// Re-export upstream's pre-shadow `InputContent` under an explicit alias. Kept
198// for downstream compatibility: since `FunctionCallOutput` is crate-owned, no
199// type in this module carries upstream's `InputContent` any more, so in-crate
200// code should use the Dynamo `InputContent` shadow defined below.
201pub use async_openai::types::responses::InputContent as UpstreamInputContent;
202
203// ---------------------------------------------------------------------------
204// Input-side image / content / message (shadow upstream, relaxed shapes)
205// ---------------------------------------------------------------------------
206
207/// Relaxed counterpart to upstream `InputImageContent`. `detail` defaults to
208/// `ImageDetail::Auto` when the client omits it — OpenAI's hosted API and the
209/// OpenResponses spec both accept this shape, but upstream's struct marks
210/// `detail` as required.
211#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
212pub struct InputImageContent {
213    #[serde(default, deserialize_with = "deserialize_null_as_default")]
214    pub detail: ImageDetail,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub file_id: Option<String>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub image_url: Option<String>,
219}
220
221/// Parts of an input message: text, image, or file. Mirrors upstream
222/// `InputContent` but routes `InputImage` through the Dynamo-owned relaxed
223/// `InputImageContent` above.
224#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
225#[serde(tag = "type", rename_all = "snake_case")]
226pub enum InputContent {
227    InputText(InputTextContent),
228    InputImage(InputImageContent),
229    InputFile(InputFileContent),
230}
231
232/// User / system / developer input message. Shadows upstream `InputMessage`
233/// so we can route through the Dynamo-owned `InputContent` chain.
234#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
235pub struct InputMessage {
236    pub content: Vec<InputContent>,
237    pub role: InputRole,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub status: Option<OutputStatus>,
240}
241
242/// Content for `EasyInputMessage`. Shadows upstream's same-named enum so the
243/// `ContentList` arm carries Dynamo's relaxed `InputContent` (with optional
244/// `detail` on `InputImageContent`) instead of upstream's strict variant.
245///
246/// Without this shadow, the `InputItem::EasyMessage` fallback in the untagged
247/// `InputItem` enum is the only path that still routes through upstream's
248/// strict types — so any spec-compliant client that omits `type: "message"`
249/// on a multimodal message (the documented default) fails with
250/// "data did not match any variant of untagged enum InputItem". See issue
251/// #9468.
252#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
253#[serde(untagged)]
254pub enum EasyInputContent {
255    /// Plain-text content. Tried first so `"content": "hi"` short-circuits.
256    Text(String),
257    /// Structured content list (text/image/file parts).
258    ContentList(Vec<InputContent>),
259}
260
261impl Default for EasyInputContent {
262    fn default() -> Self {
263        Self::Text(String::new())
264    }
265}
266
267/// Output of a `function_call_output` item. Shadows upstream `FunctionCallOutput`
268/// so the `Content` arm carries the crate's `InputContent` (and so the relaxed
269/// `InputImageContent`), giving tool outputs the same part semantics as message
270/// content. Upstream's arm carries its own `InputContent`, which accepts an
271/// omitted `detail` since async-openai 0.38 but still rejects an explicit
272/// `"detail": null` that message content accepts.
273#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
274#[serde(untagged)]
275pub enum FunctionCallOutput {
276    /// A JSON string of the output of the function tool call.
277    Text(String),
278    /// Text / image / file parts.
279    Content(Vec<InputContent>),
280}
281
282// Same conversions upstream's `FunctionCallOutput` provides, so `output: "…".into()`
283// and `output: parts.into()` keep compiling.
284impl From<&str> for FunctionCallOutput {
285    fn from(text: &str) -> Self {
286        FunctionCallOutput::Text(text.to_string())
287    }
288}
289
290impl From<String> for FunctionCallOutput {
291    fn from(text: String) -> Self {
292        FunctionCallOutput::Text(text)
293    }
294}
295
296impl From<Vec<InputContent>> for FunctionCallOutput {
297    fn from(content: Vec<InputContent>) -> Self {
298        FunctionCallOutput::Content(content)
299    }
300}
301
302/// `function_call_output` input item. Shadows upstream so `output` routes through
303/// the crate-owned `FunctionCallOutput`; field set identical to upstream.
304#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
305pub struct FunctionCallOutputItemParam {
306    pub call_id: String,
307    pub output: FunctionCallOutput,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub id: Option<String>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub status: Option<OutputStatus>,
312}
313
314/// A simplified message input — the spec-default shape when a client omits the
315/// `type` discriminator. Shadows upstream `EasyInputMessage` so the `content`
316/// field routes through Dynamo's relaxed `EasyInputContent` (and transitively
317/// the relaxed `InputContent` / `InputImageContent`). Field set is identical to
318/// upstream for drop-in compatibility with construction sites in lib/llm.
319#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
320pub struct EasyInputMessage {
321    /// Type discriminator. Optional with default `MessageType::Message` —
322    /// matches the OpenAI Responses spec and `openai-python`'s
323    /// `EasyInputMessageParam` (`type: Literal["message"]`, non-Required).
324    #[serde(default)]
325    pub r#type: MessageType,
326    pub role: Role,
327    pub content: EasyInputContent,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub phase: Option<MessagePhase>,
330}
331
332// ---------------------------------------------------------------------------
333// Input-side Item / Message / InputItem / InputParam (shadow upstream)
334// ---------------------------------------------------------------------------
335
336/// Message item within `Item`. Untagged; disambiguated by the `role` field:
337/// the `Output` variant requires `role: "assistant"` (via `AssistantRole`,
338/// which is a single-variant enum) and `Input` requires `role` in
339/// `"user" | "system" | "developer"` (via `InputRole`). A payload with an
340/// unknown role (e.g. `"tool"`) or a missing `role` produces the generic
341/// untagged-enum error — callers are expected to send a valid role. If you
342/// see the "data did not match any variant of untagged enum" failure on this
343/// type, it is almost always a role mismatch.
344#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
345#[serde(untagged)]
346pub enum MessageItem {
347    /// Prior assistant output echoed back (role: assistant). Tried first — its
348    /// `role` constraint excludes user/system/developer inputs.
349    Output(InputOutputMessage),
350    /// User / system / developer input message.
351    Input(InputMessage),
352}
353
354/// A reasoning item echoed back as input for a subsequent turn. Relaxed
355/// compared to upstream `ReasoningItem`: `id` and `summary` are both optional.
356///
357/// Upstream marks `id` (and a present `summary` array) as required, but real
358/// clients omit them when round-tripping a prior reasoning turn as input:
359/// Codex / OpenCode / agent SDKs send `reasoning` items carrying only
360/// `encrypted_content` (and sometimes a `summary`) with no `id`. OpenAI's own
361/// hosted API accepts this; the OpenAPI spec is wrong. Upstream fixed `id` in
362/// `64bit/async-openai#535` (merged after our pinned async-openai), so we
363/// mirror that one-line relaxation here rather than chase a crate bump.
364///
365/// Named `InputReasoningItem` (not `ReasoningItem`) because upstream's
366/// `ReasoningItem` is dual-side: it is the canonical output-side type in
367/// `OutputItem::Reasoning(..)` / `Response.output`, which must stay strict.
368/// Same naming discipline as `InputOutputMessage` vs `OutputMessage`.
369#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
370pub struct InputReasoningItem {
371    /// Optional on input — upstream requires it; clients drop it on echo.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub id: Option<String>,
374    /// Defaults to empty when absent — upstream requires a present array.
375    #[serde(default)]
376    pub summary: Vec<SummaryPart>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub content: Option<Vec<ReasoningTextContent>>,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub encrypted_content: Option<String>,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub status: Option<OutputStatus>,
383}
384
385/// Private Codex wire shape, normalized to an existing user message.
386#[derive(Deserialize)]
387struct CodexAgentMessage {
388    #[serde(default)]
389    content: Option<CodexAgentMessageContent>,
390}
391
392#[derive(Deserialize)]
393#[serde(untagged)]
394enum CodexAgentMessageContent {
395    Text(String),
396    Parts(Vec<CodexAgentMessageInputContent>),
397}
398
399#[derive(Deserialize)]
400#[serde(tag = "type", rename_all = "snake_case")]
401enum CodexAgentMessageInputContent {
402    InputText(InputTextContent),
403    EncryptedContent { encrypted_content: String },
404}
405
406/// Structured input/output item, discriminated by `type`. Mirrors upstream
407/// variant-for-variant; only `Message` and `Reasoning` use owned types.
408#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
409#[serde(tag = "type", rename_all = "snake_case")]
410pub enum Item {
411    Message(MessageItem),
412    FileSearchCall(FileSearchToolCall),
413    ComputerCall(ComputerToolCall),
414    ComputerCallOutput(ComputerCallOutputItemParam),
415    WebSearchCall(WebSearchToolCall),
416    FunctionCall(FunctionToolCall),
417    FunctionCallOutput(FunctionCallOutputItemParam),
418    ToolSearchCall(ToolSearchCallItemParam),
419    ToolSearchOutput(ToolSearchOutputItemParam),
420    Reasoning(InputReasoningItem),
421    Compaction(CompactionSummaryItemParam),
422    ImageGenerationCall(ImageGenToolCall),
423    CodeInterpreterCall(CodeInterpreterToolCall),
424    LocalShellCall(LocalShellToolCall),
425    LocalShellCallOutput(LocalShellToolCallOutput),
426    ShellCall(FunctionShellCallItemParam),
427    ShellCallOutput(FunctionShellCallOutputItemParam),
428    ApplyPatchCall(ApplyPatchToolCallItemParam),
429    ApplyPatchCallOutput(ApplyPatchToolCallOutputItemParam),
430    McpListTools(MCPListTools),
431    McpApprovalRequest(MCPApprovalRequest),
432    McpApprovalResponse(MCPApprovalResponse),
433    McpCall(MCPToolCall),
434    CustomToolCallOutput(CustomToolCallOutput),
435    CustomToolCall(CustomToolCall),
436}
437
438/// Single input item. Untagged; order matters (most specific first).
439#[derive(Debug, Serialize, Clone, PartialEq)]
440#[serde(untagged)]
441pub enum InputItem {
442    ItemReference(ItemReference),
443    Item(Item),
444    EasyMessage(EasyInputMessage),
445}
446
447#[derive(Deserialize)]
448#[serde(untagged)]
449enum InputItemWire {
450    ItemReference(ItemReference),
451    Item(Item),
452    EasyMessage(EasyInputMessage),
453}
454
455impl<'de> Deserialize<'de> for InputItem {
456    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
457    where
458        D: serde::Deserializer<'de>,
459    {
460        let value = serde_json::Value::deserialize(deserializer)?;
461        if value.get("type").and_then(serde_json::Value::as_str) == Some("agent_message") {
462            let message = CodexAgentMessage::deserialize(value).map_err(de::Error::custom)?;
463            return Ok(normalize_codex_agent_message(message));
464        }
465
466        match InputItemWire::deserialize(value).map_err(de::Error::custom)? {
467            InputItemWire::ItemReference(item) => Ok(Self::ItemReference(item)),
468            InputItemWire::Item(item) => Ok(Self::Item(item)),
469            InputItemWire::EasyMessage(message) => Ok(Self::EasyMessage(message)),
470        }
471    }
472}
473
474fn normalize_codex_agent_message(message: CodexAgentMessage) -> InputItem {
475    let content = match message.content {
476        None => String::new(),
477        Some(CodexAgentMessageContent::Text(text)) => text,
478        Some(CodexAgentMessageContent::Parts(parts)) => parts
479            .into_iter()
480            .map(|part| match part {
481                CodexAgentMessageInputContent::InputText(part) => part.text,
482                CodexAgentMessageInputContent::EncryptedContent { encrypted_content } => {
483                    encrypted_content
484                }
485            })
486            .collect::<Vec<_>>()
487            .join("\n"),
488    };
489    InputItem::EasyMessage(EasyInputMessage {
490        r#type: MessageType::Message,
491        role: Role::User,
492        content: EasyInputContent::Text(content),
493        phase: None,
494    })
495}
496
497/// Input to a `POST /v1/responses` request.
498#[derive(Debug, Serialize, Clone, PartialEq)]
499#[serde(untagged)]
500pub enum InputParam {
501    Text(String),
502    Items(Vec<InputItem>),
503}
504
505impl<'de> Deserialize<'de> for InputParam {
506    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
507    where
508        D: serde::Deserializer<'de>,
509    {
510        match serde_json::Value::deserialize(deserializer)? {
511            serde_json::Value::String(text) => Ok(Self::Text(text)),
512            serde_json::Value::Array(items) => {
513                serde_json::from_value(serde_json::Value::Array(items))
514                    .map(Self::Items)
515                    .map_err(de::Error::custom)
516            }
517            _ => Err(de::Error::custom(
518                "input must be a string or an array of input items",
519            )),
520        }
521    }
522}
523
524impl Default for InputParam {
525    fn default() -> Self {
526        Self::Text(String::new())
527    }
528}
529
530// ---------------------------------------------------------------------------
531// CreateResponse (owned, uses Dynamo-owned InputParam)
532// ---------------------------------------------------------------------------
533
534/// Request body for `POST /v1/responses`. Mirrors upstream `CreateResponse`
535/// field-for-field but uses Dynamo-owned `InputParam`, which transitively
536/// accepts the relaxed input shapes described in this module's header. All
537/// other fields reference upstream types verbatim.
538#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
539pub struct CreateResponse {
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub background: Option<bool>,
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub conversation: Option<ConversationParam>,
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub include: Option<Vec<IncludeEnum>>,
546    pub input: InputParam,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub instructions: Option<String>,
549    /// Output cap. Also accepts the Chat Completions spelling `max_tokens`
550    /// as an alias so a caller's cap is honored instead of silently dropped;
551    /// serialization always emits `max_output_tokens`.
552    #[serde(alias = "max_tokens", skip_serializing_if = "Option::is_none")]
553    pub max_output_tokens: Option<u32>,
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub max_tool_calls: Option<u32>,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub metadata: Option<HashMap<String, String>>,
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub model: Option<String>,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub parallel_tool_calls: Option<bool>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub previous_response_id: Option<String>,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub prompt: Option<Prompt>,
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub prompt_cache_key: Option<String>,
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub prompt_cache_retention: Option<PromptCacheRetention>,
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub reasoning: Option<Reasoning>,
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub safety_identifier: Option<String>,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub service_tier: Option<ServiceTierResponses>,
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub store: Option<bool>,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub stream: Option<bool>,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub stream_options: Option<ResponseStreamOptions>,
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub temperature: Option<f32>,
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub text: Option<ResponseTextParam>,
586    #[serde(
587        default,
588        deserialize_with = "deserialize_tool_choice",
589        skip_serializing_if = "Option::is_none"
590    )]
591    pub tool_choice: Option<ToolChoiceParam>,
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub tools: Option<Vec<Tool>>,
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub top_logprobs: Option<u8>,
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub top_p: Option<f32>,
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub truncation: Option<Truncation>,
600}
601
602// ---------------------------------------------------------------------------
603// CountInputTokens (`POST /v1/responses/input_tokens`)
604// ---------------------------------------------------------------------------
605
606/// The `object` discriminator on a [`CountInputTokensResponse`].
607pub const RESPONSE_INPUT_TOKENS_OBJECT: &str = "response.input_tokens";
608
609/// Request body for `POST /v1/responses/input_tokens`.
610///
611/// A subset of [`CreateResponse`] — only the fields that reach the rendered
612/// prompt. This mirrors `AnthropicCountTokensRequest`, which is the same
613/// subset-of-the-create-request shape for `POST /v1/messages/count_tokens`.
614///
615/// Two deliberate differences from `CreateResponse`: `input` defaults (the
616/// count endpoint accepts a body without one, whereas creating a response
617/// requires it), and unknown fields are ignored, so stateful parameters
618/// Dynamo does not serve (`conversation`, `previous_response_id`) are accepted
619/// and disregarded rather than rejected. This endpoint reports a pre-flight
620/// estimate; it never generates, so there is nothing for them to affect.
621///
622/// Deserialization is forgiving in two further places — an explicit
623/// `"input": null` and unrecognized tool shapes — for the same reason
624/// `AnthropicTool` keeps every field but `name` optional: a pre-flight
625/// estimate that rejects a body it could have scored is strictly worse than
626/// one that scores it approximately. See [`deserialize_lenient_tools`].
627#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
628pub struct CountInputTokensRequest {
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub model: Option<String>,
631    /// `#[serde(default)]` alone covers an absent `input`, but not an explicit
632    /// `"input": null` — serde still hands that null to `InputParam`, whose
633    /// deserializer rejects it. Here both mean "nothing to count".
634    #[serde(default, deserialize_with = "deserialize_null_default_input")]
635    pub input: InputParam,
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub instructions: Option<String>,
638    #[serde(
639        default,
640        skip_serializing_if = "Option::is_none",
641        deserialize_with = "deserialize_lenient_tools"
642    )]
643    pub tools: Option<Vec<Tool>>,
644}
645
646fn deserialize_null_default_input<'de, D>(deserializer: D) -> Result<InputParam, D::Error>
647where
648    D: serde::Deserializer<'de>,
649{
650    Ok(Option::<InputParam>::deserialize(deserializer)?.unwrap_or_default())
651}
652
653/// Drop tool entries that do not deserialize into a known [`Tool`], rather than
654/// failing the whole request.
655///
656/// `Tool` is upstream's `#[serde(tag = "type")]` enum, so it models only the
657/// tool types the pinned `async-openai` knows. A caller that forwards a tool in
658/// a shape upstream does not model — a Chat-Completions-style `{"type":
659/// "custom", "custom": {...}}`, or a tool type newer than the pin — would
660/// otherwise get a 400 for a field that contributes almost nothing to the
661/// estimate.
662///
663/// Dropping costs nothing: [`estimate_tool_len`] already scores every
664/// non-function tool as 0, because `convert_tools` forwards only function tools
665/// to the backend. An unparseable tool was going to be worth 0 either way; this
666/// only decides whether the rest of the body still gets counted. That is the
667/// same trade `estimate_tool_len` documents when it wildcards where
668/// `measure_item` is exhaustive — `Tool` is upstream's type, and we carry
669/// no obligation to mirror its variants.
670fn deserialize_lenient_tools<'de, D>(deserializer: D) -> Result<Option<Vec<Tool>>, D::Error>
671where
672    D: serde::Deserializer<'de>,
673{
674    let Some(raw) = Option::<Vec<serde_json::Value>>::deserialize(deserializer)? else {
675        return Ok(None);
676    };
677    Ok(Some(
678        raw.into_iter()
679            .filter_map(|tool| serde_json::from_value::<Tool>(tool).ok())
680            .collect(),
681    ))
682}
683
684/// Response body for `POST /v1/responses/input_tokens`.
685///
686/// `Deserialize` is derived where the Anthropic count response is
687/// serialize-only: this body is round-tripped by the frontend's integration
688/// tests, which assert on the parsed shape rather than on raw JSON.
689#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
690pub struct CountInputTokensResponse {
691    /// Always [`RESPONSE_INPUT_TOKENS_OBJECT`]. Required by the OpenAI spec.
692    pub object: String,
693    pub input_tokens: u32,
694}
695
696impl CountInputTokensResponse {
697    pub fn new(input_tokens: u32) -> Self {
698        Self {
699            object: RESPONSE_INPUT_TOKENS_OBJECT.to_string(),
700            input_tokens,
701        }
702    }
703}
704
705impl CountInputTokensRequest {
706    /// Estimate input token count using a `len/3` heuristic.
707    ///
708    /// Same contract as `AnthropicCountTokensRequest::estimate_tokens`: sum the
709    /// character lengths of everything that reaches the prompt, divide by three,
710    /// and never report zero for input that carried content.
711    ///
712    /// This is an estimate, not a tokenization. A frontend serving a
713    /// backend that tokenizes for itself has no tokenizer loaded, so this
714    /// endpoint has to be able to answer without one.
715    pub fn estimate_tokens(&self) -> u32 {
716        let mut total_len: usize = 0;
717
718        // `instructions` and a top-level string `input` are not free-floating
719        // text: the converter turns them into a system and a user chat message
720        // respectively, exactly like the item messages below. Charge them the
721        // same role markers, or the identical prompt scores differently
722        // depending on which shape the caller used to express it.
723        //
724        // Both are skipped when empty, because an absent field is not a
725        // message. `InputParam::default()` is `Text("")`, so a body with no
726        // `input` at all lands here — and it must stay worth zero.
727        if let Some(instructions) = &self.instructions.as_ref().filter(|text| !text.is_empty()) {
728            total_len += role_len(Role::System) + instructions.len();
729        }
730
731        match &self.input {
732            InputParam::Text(text) if text.is_empty() => {}
733            InputParam::Text(text) => total_len += role_len(Role::User) + text.len(),
734            InputParam::Items(items) => total_len += estimate_input_items_len(items),
735        }
736
737        if let Some(tools) = &self.tools {
738            for tool in tools {
739                total_len += estimate_tool_len(tool);
740            }
741        }
742
743        let tokens = total_len / 3;
744        if tokens == 0 && total_len > 0 {
745            1
746        } else {
747            tokens as u32
748        }
749    }
750}
751
752/// Approximate character cost of a role marker, using the same constants
753/// `AnthropicCountTokensRequest::estimate_tokens` applies for the same purpose.
754fn role_len(role: Role) -> usize {
755    match role {
756        Role::User => 4,
757        Role::Assistant => 9,
758        Role::System => 6,
759        Role::Developer => 9,
760    }
761}
762
763fn input_role_len(role: InputRole) -> usize {
764    match role {
765        InputRole::User => 4,
766        InputRole::System => 6,
767        InputRole::Developer => 9,
768    }
769}
770
771/// The `tool` role marker on a tool-result message.
772///
773/// `role_len` covers only the roles upstream's `Role` enum models; chat
774/// completions' `tool` role has no variant there, so it gets its own constant
775/// on the same basis the others use — the length of the role word.
776const TOOL_ROLE_LEN: usize = 4;
777
778/// What an input item does to the converter's pending assistant message.
779enum GroupEffect {
780    /// Opens or extends the pending assistant message, emitting no message of
781    /// its own.
782    Assistant,
783    /// Flushes any pending assistant message and emits its own.
784    Flush,
785    /// Neither emits nor flushes — the converter skips it outright.
786    Skip,
787}
788
789/// Sum the input items, mirroring `convert_input_items_to_messages` — including
790/// its coalescing.
791///
792/// Assistant-side items do not each become a message. An echoed assistant
793/// message, a function call, and a reasoning summary all push into one
794/// `PendingAssistant`, which is flushed only by the next non-assistant item or
795/// by the end of the list. So the assistant role marker is charged once per
796/// flushed group, not once per item: two parallel function calls are one
797/// assistant turn and cost one marker between them.
798///
799/// A per-item sum cannot express that, which is why this walks the list rather
800/// than mapping over it.
801fn estimate_input_items_len(items: &[InputItem]) -> usize {
802    let mut total = 0;
803    let mut assistant_open = false;
804
805    for item in items {
806        let (effect, len) = measure_input_item(item);
807        total += len;
808        match effect {
809            GroupEffect::Assistant => {
810                if !assistant_open {
811                    assistant_open = true;
812                    total += role_len(Role::Assistant);
813                }
814            }
815            GroupEffect::Flush => assistant_open = false,
816            GroupEffect::Skip => {}
817        }
818    }
819
820    total
821}
822
823/// Measure one item and report what it does to the pending assistant group.
824///
825/// Assistant-side arms return content only: their role marker is the caller's
826/// to add, once per group.
827fn measure_input_item(item: &InputItem) -> (GroupEffect, usize) {
828    match item {
829        // A pointer to an item held server-side. The content it names is not in
830        // this request, so there is nothing here to measure — and the converter
831        // skips it without flushing, so it cannot split an assistant group.
832        InputItem::ItemReference(_) => (GroupEffect::Skip, 0),
833        InputItem::EasyMessage(message) => {
834            let content = estimate_easy_content_len(&message.content);
835            match message.role {
836                // A prior assistant turn echoed back; coalesces like the strict
837                // `MessageItem::Output` path.
838                Role::Assistant => (GroupEffect::Assistant, content),
839                role => (GroupEffect::Flush, role_len(role) + content),
840            }
841        }
842        InputItem::Item(item) => measure_item(item),
843    }
844}
845
846fn estimate_easy_content_len(content: &EasyInputContent) -> usize {
847    match content {
848        EasyInputContent::Text(text) => text.len(),
849        EasyInputContent::ContentList(parts) => parts.iter().map(estimate_input_content_len).sum(),
850    }
851}
852
853/// Only text parts are measured. An image or file contributes tokens as a
854/// function of its decoded form, which a character count cannot model at all —
855/// the Anthropic estimator skips non-text blocks for the same reason.
856fn estimate_input_content_len(part: &InputContent) -> usize {
857    match part {
858        InputContent::InputText(text) => text.text.len(),
859        InputContent::InputImage(_) | InputContent::InputFile(_) => 0,
860    }
861}
862
863fn measure_item(item: &Item) -> (GroupEffect, usize) {
864    match item {
865        Item::Message(MessageItem::Input(message)) => (
866            GroupEffect::Flush,
867            input_role_len(message.role)
868                + message
869                    .content
870                    .iter()
871                    .map(estimate_input_content_len)
872                    .sum::<usize>(),
873        ),
874        // Assistant-side: pushed into the pending message, so no role marker
875        // here. See `estimate_input_items_len`.
876        Item::Message(MessageItem::Output(message)) => (
877            GroupEffect::Assistant,
878            message
879                .content
880                .iter()
881                .map(|part| match part {
882                    InputOutputMessageContent::OutputText(text) => text.text.len(),
883                    InputOutputMessageContent::Refusal(refusal) => refusal.refusal.len(),
884                })
885                .sum::<usize>(),
886        ),
887        // Rendered as a tool call on the pending assistant message. `call_id`
888        // is excluded deliberately: it is correlation plumbing, not prompt
889        // text, in the templates Dynamo renders.
890        Item::FunctionCall(call) => (
891            GroupEffect::Assistant,
892            call.name.len() + call.arguments.len(),
893        ),
894        // Its own `tool`-role message, one per output, so it both flushes the
895        // assistant group and carries a role marker of its own.
896        Item::FunctionCallOutput(output) => (
897            GroupEffect::Flush,
898            TOOL_ROLE_LEN
899                + match &output.output {
900                    FunctionCallOutput::Text(text) => text.len(),
901                    FunctionCallOutput::Content(parts) => {
902                        parts.iter().map(estimate_input_content_len).sum()
903                    }
904                },
905        ),
906        // Only `summary` is measured, because only `summary` is rendered:
907        // the converter joins the summary parts and drops the rest of the
908        // item. `content` is excluded for that reason alone — if Dynamo
909        // learns to render it, it needs to start counting here too.
910        // `encrypted_content` is excluded on its own merits: it is an opaque
911        // blob the model never sees as prompt text, and it is routinely far
912        // larger than the reasoning it stands for.
913        Item::Reasoning(reasoning) => (
914            GroupEffect::Assistant,
915            reasoning
916                .summary
917                .iter()
918                .map(|part| match part {
919                    SummaryPart::SummaryText(text) => text.text.len(),
920                })
921                .sum(),
922        ),
923        // Everything below contributes nothing, because nothing below reaches
924        // the prompt: Dynamo's `convert_input_items_to_messages` flushes and
925        // skips every one of these ("we do not have a faithful Chat
926        // Completions mapping"). The arms above are exactly its handled set.
927        // Measuring the serialized form instead would bill callers for JSON
928        // scaffolding the model never sees: a bare `web_search_call` is 59
929        // characters, or 19 phantom tokens, and agentic clients echo many
930        // such items per turn.
931        //
932        // Listed out rather than wildcarded on purpose. `Item` is a shadow
933        // enum that "mirrors upstream variant-for-variant" and has to be
934        // extended by hand whenever upstream grows a variant (see CLAUDE.md,
935        // "Owned input chain"). A `_` arm would let that new variant default
936        // to zero silently; an exhaustive match turns it into a compile error
937        // that forces a render-or-not decision here, which is the same
938        // mechanism CLAUDE.md relies on to catch drift in `From` impls.
939        Item::FileSearchCall(_)
940        | Item::ComputerCall(_)
941        | Item::ComputerCallOutput(_)
942        | Item::WebSearchCall(_)
943        | Item::ToolSearchCall(_)
944        | Item::ToolSearchOutput(_)
945        | Item::Compaction(_)
946        | Item::ImageGenerationCall(_)
947        | Item::CodeInterpreterCall(_)
948        | Item::LocalShellCall(_)
949        | Item::LocalShellCallOutput(_)
950        | Item::ShellCall(_)
951        | Item::ShellCallOutput(_)
952        | Item::ApplyPatchCall(_)
953        | Item::ApplyPatchCallOutput(_)
954        | Item::McpListTools(_)
955        | Item::McpApprovalRequest(_)
956        | Item::McpApprovalResponse(_)
957        | Item::McpCall(_)
958        | Item::CustomToolCallOutput(_)
959        | Item::CustomToolCall(_) => (GroupEffect::Flush, 0),
960    }
961}
962
963/// Mirrors `convert_tools`: only function tools are forwarded to the backend,
964/// namespaced ones flattened to their bare function members. Hosted tools
965/// (web search, file search, computer use) are dropped there and so cost
966/// nothing here.
967///
968/// Wildcarded where `measure_item` is exhaustive, and deliberately so:
969/// `Tool` is upstream's type, not one of our shadows, so we carry no
970/// obligation to mirror its variants. Pinning it exhaustively would only
971/// break the build every time async-openai adds a hosted tool we would
972/// score as zero anyway.
973fn estimate_tool_len(tool: &Tool) -> usize {
974    match tool {
975        Tool::Function(function) => function_tool_len(
976            &function.name,
977            function.description.as_ref(),
978            function.parameters.as_ref(),
979        ),
980        Tool::Namespace(namespace) => namespace
981            .tools
982            .iter()
983            .map(|tool| match tool {
984                // The namespace name is an origin marker used to detect
985                // collisions, not prompt text — `push_function` forwards the
986                // bare function name.
987                NamespaceToolParamTool::Function(function) => function_tool_len(
988                    &function.name,
989                    function.description.as_ref(),
990                    function.parameters.as_ref(),
991                ),
992                NamespaceToolParamTool::Custom(_) => 0,
993            })
994            .sum(),
995        _ => 0,
996    }
997}
998
999fn function_tool_len(
1000    name: &str,
1001    description: Option<&String>,
1002    parameters: Option<&serde_json::Value>,
1003) -> usize {
1004    name.len()
1005        + description.map_or(0, |description| description.len())
1006        + parameters.map_or(0, |schema| schema.to_string().len())
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    // ---- tool_choice object form (ai-dynamo/dynamo#10963 CASE 1) ----
1014
1015    fn tool_choice_of(json: serde_json::Value) -> Option<ToolChoiceParam> {
1016        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1017            "input": "hi",
1018            "tool_choice": json,
1019        }))
1020        .expect("CreateResponse should deserialize");
1021        req.tool_choice
1022    }
1023
1024    #[test]
1025    fn tool_choice_mode_object_coerces_to_mode() {
1026        // Anthropic-style / litellm shape: a mode expressed as an object with
1027        // extra keys. Must coerce to the corresponding `Option`, ignoring extras.
1028        assert_eq!(
1029            tool_choice_of(serde_json::json!({"type": "auto", "disable_parallel_tool_use": true})),
1030            Some(ToolChoiceParam::Option(ToolChoiceOptions::Auto)),
1031        );
1032        assert_eq!(
1033            tool_choice_of(serde_json::json!({"type": "none"})),
1034            Some(ToolChoiceParam::Option(ToolChoiceOptions::None)),
1035        );
1036        assert_eq!(
1037            tool_choice_of(serde_json::json!({"type": "required"})),
1038            Some(ToolChoiceParam::Option(ToolChoiceOptions::Required)),
1039        );
1040    }
1041
1042    #[test]
1043    fn tool_choice_bare_string_still_works() {
1044        assert_eq!(
1045            tool_choice_of(serde_json::json!("auto")),
1046            Some(ToolChoiceParam::Option(ToolChoiceOptions::Auto)),
1047        );
1048    }
1049
1050    #[test]
1051    fn tool_choice_specific_function_object_still_works() {
1052        // The object form naming a specific tool must NOT be swallowed by the
1053        // mode coercion — `type: "function"` is not a mode.
1054        match tool_choice_of(serde_json::json!({"type": "function", "name": "get_weather"})) {
1055            Some(ToolChoiceParam::Function(f)) => assert_eq!(f.name, "get_weather"),
1056            other => panic!("expected Function tool choice, got {other:?}"),
1057        }
1058    }
1059
1060    #[test]
1061    fn tool_choice_absent_is_none() {
1062        let req: CreateResponse =
1063            serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
1064        assert!(req.tool_choice.is_none());
1065    }
1066
1067    // ---- reasoning item echoed back without id/summary (#10963 CASE 2) ----
1068
1069    #[test]
1070    fn reasoning_input_without_id_deserializes() {
1071        // Codex / OpenCode / agent SDKs echo a reasoning item with no `id`.
1072        let json = serde_json::json!({
1073            "type": "reasoning",
1074            "summary": [{"type": "summary_text", "text": "thinking"}],
1075        });
1076        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
1077            InputItem::Item(Item::Reasoning(r)) => {
1078                assert!(r.id.is_none());
1079                assert_eq!(r.summary.len(), 1);
1080            }
1081            other => panic!("expected Item::Reasoning, got {other:?}"),
1082        }
1083    }
1084
1085    #[test]
1086    fn reasoning_input_encrypted_without_id_or_summary_deserializes() {
1087        let json = serde_json::json!({
1088            "type": "reasoning",
1089            "encrypted_content": "AB==",
1090        });
1091        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
1092            InputItem::Item(Item::Reasoning(r)) => {
1093                assert!(r.id.is_none());
1094                assert!(r.summary.is_empty());
1095                assert_eq!(r.encrypted_content.as_deref(), Some("AB=="));
1096            }
1097            other => panic!("expected Item::Reasoning, got {other:?}"),
1098        }
1099    }
1100
1101    #[test]
1102    fn reasoning_input_with_id_still_works() {
1103        let json = serde_json::json!({
1104            "type": "reasoning",
1105            "id": "rs_1",
1106            "summary": [{"type": "summary_text", "text": "x"}],
1107            "status": "completed",
1108        });
1109        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
1110            InputItem::Item(Item::Reasoning(r)) => assert_eq!(r.id.as_deref(), Some("rs_1")),
1111            other => panic!("expected Item::Reasoning, got {other:?}"),
1112        }
1113    }
1114
1115    #[test]
1116    fn full_request_with_idless_reasoning_item_deserializes() {
1117        // The exact failure mode reported in #10963: a turn-2 `input` list
1118        // containing an echoed reasoning item that lost its `id`.
1119        let req: Result<CreateResponse, _> = serde_json::from_value(serde_json::json!({
1120            "model": "m",
1121            "input": [
1122                {"role": "user", "content": "hi"},
1123                {"type": "reasoning", "summary": [{"type": "summary_text", "text": "x"}]},
1124            ],
1125        }));
1126        assert!(
1127            req.is_ok(),
1128            "idless reasoning input should deserialize: {req:?}"
1129        );
1130    }
1131
1132    #[test]
1133    fn codex_agent_message_normalizes_to_user_message() {
1134        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1135            "input": [{
1136                "type": "agent_message",
1137                "author": "/root",
1138                "recipient": "/root/worker",
1139                "content": [
1140                    {"type": "input_text", "text": "First."},
1141                    {"type": "input_text", "text": "Second."},
1142                ],
1143            }],
1144        }))
1145        .expect("Codex agent message should deserialize");
1146
1147        let InputParam::Items(items) = req.input else {
1148            panic!("expected items");
1149        };
1150        assert!(matches!(
1151            &items[0],
1152            InputItem::EasyMessage(EasyInputMessage {
1153                role: Role::User,
1154                content: EasyInputContent::Text(text),
1155                ..
1156            }) if text == "First.\nSecond."
1157        ));
1158    }
1159
1160    #[test]
1161    fn codex_agent_message_string_content_normalizes_to_user_message() {
1162        let item: InputItem = serde_json::from_value(serde_json::json!({
1163            "type": "agent_message",
1164            "author": "/root",
1165            "recipient": "/root/worker",
1166            "content": "Return exactly OK.",
1167        }))
1168        .expect("Codex agent message with string content should deserialize");
1169
1170        assert!(matches!(
1171            item,
1172            InputItem::EasyMessage(EasyInputMessage {
1173                content: EasyInputContent::Text(text),
1174                ..
1175            }) if text == "Return exactly OK."
1176        ));
1177    }
1178
1179    #[test]
1180    fn codex_agent_message_normalizes_encrypted_content() {
1181        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1182            "input": [{
1183                "type": "agent_message",
1184                "content": [
1185                    {"type": "input_text", "text": "Payload:"},
1186                    {"type": "encrypted_content", "encrypted_content": "Return exactly OK."},
1187                ],
1188            }],
1189        }))
1190        .expect("Codex agent message with encrypted content should deserialize");
1191
1192        let InputParam::Items(items) = req.input else {
1193            panic!("expected items");
1194        };
1195        assert!(matches!(
1196            &items[0],
1197            InputItem::EasyMessage(EasyInputMessage {
1198                content: EasyInputContent::Text(text),
1199                ..
1200            }) if text == "Payload:\nReturn exactly OK."
1201        ));
1202    }
1203
1204    #[test]
1205    fn codex_agent_message_missing_content_normalizes_empty() {
1206        let item: InputItem = serde_json::from_value(serde_json::json!({
1207            "type": "agent_message",
1208            "author": "/root",
1209            "recipient": "/root/worker",
1210        }))
1211        .expect("Codex agent message without content should deserialize");
1212        assert!(matches!(
1213            item,
1214            InputItem::EasyMessage(EasyInputMessage {
1215                content: EasyInputContent::Text(text),
1216                ..
1217            }) if text.is_empty()
1218        ));
1219    }
1220
1221    #[test]
1222    fn codex_agent_message_null_content_normalizes_empty() {
1223        let item: InputItem = serde_json::from_value(serde_json::json!({
1224            "type": "agent_message",
1225            "author": "/root",
1226            "recipient": "/root/worker",
1227            "content": null,
1228        }))
1229        .expect("Codex agent message with null content should deserialize");
1230        assert!(matches!(
1231            item,
1232            InputItem::EasyMessage(EasyInputMessage {
1233                content: EasyInputContent::Text(text),
1234                ..
1235            }) if text.is_empty()
1236        ));
1237    }
1238
1239    #[test]
1240    fn relaxed_assistant_message_without_id_or_status() {
1241        let json = serde_json::json!({
1242            "type": "message",
1243            "role": "assistant",
1244            "content": [{"type": "output_text", "text": "hi"}]
1245        });
1246        let item: InputItem = serde_json::from_value(json).unwrap();
1247        match item {
1248            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1249                assert_eq!(out.role, AssistantRole::Assistant);
1250                assert!(out.id.is_none());
1251                assert!(out.status.is_none());
1252            }
1253            other => panic!("expected Item::Message(Output), got {other:?}"),
1254        }
1255    }
1256
1257    #[test]
1258    fn function_call_output_image_part_without_detail_parses() {
1259        let json = serde_json::json!({
1260            "input": [
1261                {"type": "function_call", "call_id": "c1", "name": "screenshot", "arguments": "{}"},
1262                {"type": "function_call_output", "call_id": "c1", "output": [
1263                    {"type": "input_text", "text": "captured"},
1264                    {"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="}
1265                ]}
1266            ]
1267        });
1268        let req: CreateResponse = serde_json::from_value(json).unwrap();
1269        let InputParam::Items(items) = req.input else {
1270            panic!("expected Items")
1271        };
1272        match &items[1] {
1273            InputItem::Item(Item::FunctionCallOutput(fco)) => {
1274                assert_eq!(fco.call_id, "c1");
1275                let FunctionCallOutput::Content(parts) = &fco.output else {
1276                    panic!("expected Content, got {:?}", fco.output)
1277                };
1278                assert_eq!(parts.len(), 2);
1279                match &parts[1] {
1280                    InputContent::InputImage(img) => {
1281                        assert_eq!(img.detail, ImageDetail::Auto);
1282                        assert_eq!(
1283                            img.image_url.as_deref(),
1284                            Some("data:image/png;base64,iVBORw0KGgo=")
1285                        );
1286                    }
1287                    other => panic!("expected InputImage, got {other:?}"),
1288                }
1289            }
1290            other => panic!("expected FunctionCallOutput, got {other:?}"),
1291        }
1292    }
1293
1294    #[test]
1295    fn function_call_output_image_part_with_null_detail_matches_message_content() {
1296        // `"detail": null` is accepted in message content; a tool output must not
1297        // be stricter than the message it answers.
1298        let part = serde_json::json!({
1299            "type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo=", "detail": null
1300        });
1301        let message: Item = serde_json::from_value(serde_json::json!({
1302            "type": "message", "role": "user", "content": [part]
1303        }))
1304        .unwrap();
1305        assert!(matches!(message, Item::Message(_)));
1306        let output: Item = serde_json::from_value(serde_json::json!({
1307            "type": "function_call_output", "call_id": "c1", "output": [part]
1308        }))
1309        .unwrap();
1310        let Item::FunctionCallOutput(fco) = output else {
1311            panic!("expected FunctionCallOutput, got {output:?}")
1312        };
1313        match &fco.output {
1314            FunctionCallOutput::Content(parts) => match &parts[0] {
1315                InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1316                other => panic!("expected InputImage, got {other:?}"),
1317            },
1318            other => panic!("expected Content, got {other:?}"),
1319        }
1320    }
1321
1322    #[test]
1323    fn function_call_output_from_conversions_match_upstream() {
1324        assert_eq!(
1325            FunctionCallOutput::from("ok"),
1326            FunctionCallOutput::Text("ok".to_string())
1327        );
1328        assert_eq!(
1329            FunctionCallOutput::from(String::from("ok")),
1330            FunctionCallOutput::Text("ok".to_string())
1331        );
1332        let parts = vec![InputContent::InputText(InputTextContent {
1333            text: "captured".to_string(),
1334            prompt_cache_breakpoint: None,
1335        })];
1336        let item = FunctionCallOutputItemParam {
1337            call_id: "c1".to_string(),
1338            output: parts.clone().into(),
1339            id: None,
1340            status: None,
1341        };
1342        assert_eq!(item.output, FunctionCallOutput::Content(parts));
1343    }
1344
1345    #[test]
1346    fn function_call_output_string_still_parses() {
1347        let item: Item = serde_json::from_value(serde_json::json!({
1348            "type": "function_call_output", "call_id": "c1", "output": "{\"ok\":true}"
1349        }))
1350        .unwrap();
1351        match item {
1352            Item::FunctionCallOutput(fco) => {
1353                assert!(
1354                    matches!(fco.output, FunctionCallOutput::Text(ref t) if t == "{\"ok\":true}")
1355                );
1356                assert!(fco.id.is_none() && fco.status.is_none());
1357            }
1358            other => panic!("expected FunctionCallOutput, got {other:?}"),
1359        }
1360    }
1361
1362    #[test]
1363    fn input_image_without_detail_defaults_to_auto() {
1364        let json = serde_json::json!({
1365            "type": "input_image",
1366            "image_url": "https://example.com/cat.jpg"
1367        });
1368        let content: InputContent = serde_json::from_value(json).unwrap();
1369        match content {
1370            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1371            other => panic!("expected InputImage, got {other:?}"),
1372        }
1373    }
1374
1375    #[test]
1376    fn input_image_with_explicit_null_detail_defaults_to_auto() {
1377        let json = serde_json::json!({
1378            "type": "input_image",
1379            "image_url": "https://example.com/cat.jpg",
1380            "detail": null
1381        });
1382        let content: InputContent = serde_json::from_value(json).unwrap();
1383        match content {
1384            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1385            other => panic!("expected InputImage, got {other:?}"),
1386        }
1387    }
1388
1389    #[test]
1390    fn assistant_message_without_content_field_deserializes() {
1391        // Bare assistant shell — no `content` field at all. Seen in real
1392        // Codex/Agents-SDK traffic on pure tool-call turns. `#[serde(default)]`
1393        // on `content` must accept omission and yield an empty vec.
1394        let json = serde_json::json!({
1395            "type": "message",
1396            "role": "assistant"
1397        });
1398        let item: InputItem = serde_json::from_value(json).unwrap();
1399        match item {
1400            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1401                assert_eq!(out.role, AssistantRole::Assistant);
1402                assert!(out.content.is_empty());
1403                assert!(out.id.is_none());
1404                assert!(out.status.is_none());
1405            }
1406            other => panic!("expected Item::Message(Output), got {other:?}"),
1407        }
1408    }
1409
1410    #[test]
1411    fn assistant_message_with_explicit_null_content_deserializes() {
1412        // Mirrors the `annotations: null` case: some serializers emit JSON null
1413        // for absent fields instead of omitting them. `Vec::deserialize` rejects
1414        // null, so `content` also needs `deserialize_null_as_empty_vec`.
1415        let json = serde_json::json!({
1416            "type": "message",
1417            "role": "assistant",
1418            "content": null
1419        });
1420        let item: InputItem = serde_json::from_value(json).unwrap();
1421        match item {
1422            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1423                assert!(out.content.is_empty());
1424            }
1425            other => panic!("expected Item::Message(Output), got {other:?}"),
1426        }
1427    }
1428
1429    #[test]
1430    fn mcp_call_item_deserializes() {
1431        // Guards against Item variant drift vs upstream — MCP item types were
1432        // added after the initial owned `Item` chain landed.
1433        let json = serde_json::json!({
1434            "type": "mcp_call",
1435            "id": "mcp_1",
1436            "server_label": "srv",
1437            "name": "t",
1438            "arguments": "{}"
1439        });
1440        let item: InputItem = serde_json::from_value(json).unwrap();
1441        assert!(matches!(item, InputItem::Item(Item::McpCall(_))));
1442    }
1443
1444    #[test]
1445    fn strict_assistant_message_still_deserializes() {
1446        let json = serde_json::json!({
1447            "type": "message",
1448            "role": "assistant",
1449            "id": "msg_1",
1450            "status": "completed",
1451            "content": [{"type": "output_text", "text": "hi", "annotations": []}]
1452        });
1453        let item: InputItem = serde_json::from_value(json).unwrap();
1454        match item {
1455            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1456                assert_eq!(out.id.as_deref(), Some("msg_1"));
1457                assert_eq!(out.status, Some(OutputStatus::Completed));
1458            }
1459            other => panic!("expected Item::Message(Output), got {other:?}"),
1460        }
1461    }
1462
1463    #[test]
1464    fn user_message_routes_to_input_variant() {
1465        let json = serde_json::json!({
1466            "type": "message",
1467            "role": "user",
1468            "content": [{"type": "input_text", "text": "hi"}]
1469        });
1470        let item: InputItem = serde_json::from_value(json).unwrap();
1471        assert!(matches!(
1472            item,
1473            InputItem::Item(Item::Message(MessageItem::Input(_)))
1474        ));
1475    }
1476
1477    #[test]
1478    fn function_call_item_still_deserializes() {
1479        let json = serde_json::json!({
1480            "type": "function_call",
1481            "call_id": "c",
1482            "name": "f",
1483            "arguments": "{}"
1484        });
1485        let item: InputItem = serde_json::from_value(json).unwrap();
1486        assert!(matches!(item, InputItem::Item(Item::FunctionCall(_))));
1487    }
1488
1489    #[test]
1490    fn easy_message_string_content_routes_to_easymessage() {
1491        let json = serde_json::json!({"role": "assistant", "content": "x"});
1492        let item: InputItem = serde_json::from_value(json).unwrap();
1493        assert!(matches!(item, InputItem::EasyMessage(_)));
1494    }
1495
1496    #[test]
1497    fn output_text_without_annotations_defaults_empty() {
1498        let json = serde_json::json!({"type": "output_text", "text": "hi"});
1499        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
1500        match part {
1501            InputOutputMessageContent::OutputText(t) => {
1502                assert!(t.annotations.is_empty());
1503            }
1504            _ => panic!("expected OutputText"),
1505        }
1506    }
1507
1508    #[test]
1509    fn output_text_with_explicit_null_annotations_deserializes_as_empty() {
1510        // Some clients serialize absent fields as JSON null instead of omitting
1511        // them. `Vec::deserialize` would reject null; the custom deserializer
1512        // treats explicit null identically to a missing field.
1513        let json = serde_json::json!({"type": "output_text", "text": "hi", "annotations": null});
1514        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
1515        match part {
1516            InputOutputMessageContent::OutputText(t) => {
1517                assert!(t.annotations.is_empty());
1518            }
1519            _ => panic!("expected OutputText"),
1520        }
1521    }
1522
1523    #[test]
1524    fn assistant_message_with_explicit_null_id_and_status_deserializes() {
1525        // `Option<T>` natively accepts null as `None`, so these explicit-null
1526        // fields should flow through without a custom deserializer. This test
1527        // pins that behavior against accidental regressions (e.g. if someone
1528        // switches the field type away from `Option<_>`).
1529        let json = serde_json::json!({
1530            "type": "message",
1531            "role": "assistant",
1532            "id": null,
1533            "status": null,
1534            "content": [{"type": "output_text", "text": "hi", "annotations": null}]
1535        });
1536        let item: InputItem = serde_json::from_value(json).unwrap();
1537        match item {
1538            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1539                assert!(out.id.is_none());
1540                assert!(out.status.is_none());
1541                assert_eq!(out.content.len(), 1);
1542            }
1543            other => panic!("expected Item::Message(Output), got {other:?}"),
1544        }
1545    }
1546
1547    /// The Chat Completions spelling `max_tokens` is honored as the output cap
1548    /// rather than silently dropped (#207); the wire echo is `max_output_tokens`.
1549    #[test]
1550    fn create_response_accepts_max_tokens_as_alias_for_max_output_tokens() {
1551        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1552            "model": "m", "input": "hi", "max_tokens": 16
1553        }))
1554        .unwrap();
1555        assert_eq!(req.max_output_tokens, Some(16));
1556        let back = serde_json::to_value(&req).unwrap();
1557        assert_eq!(back["max_output_tokens"], 16);
1558        assert!(back.get("max_tokens").is_none());
1559
1560        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1561            "model": "m", "input": "hi", "max_tokens": null
1562        }))
1563        .unwrap();
1564        assert_eq!(req.max_output_tokens, None);
1565
1566        // Both spellings at once is ambiguous and fails as a duplicate field.
1567        let err = serde_json::from_value::<CreateResponse>(serde_json::json!({
1568            "model": "m", "input": "hi", "max_tokens": 16, "max_output_tokens": 32
1569        }))
1570        .unwrap_err();
1571        assert!(err.to_string().contains("duplicate field"), "{err}");
1572    }
1573
1574    #[test]
1575    fn create_response_roundtrip_with_relaxed_input() {
1576        let body = serde_json::json!({
1577            "model": "m",
1578            "input": [
1579                {"type": "message", "role": "user", "content": [
1580                    {"type": "input_text", "text": "hi"}
1581                ]},
1582                {"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
1583                {"type": "message", "role": "assistant", "content": [
1584                    {"type": "output_text", "text": "\n\n"}
1585                ]},
1586                {"type": "function_call_output", "call_id": "c", "output": "x"}
1587            ]
1588        });
1589
1590        let req: CreateResponse = serde_json::from_value(body).unwrap();
1591        let items = match &req.input {
1592            InputParam::Items(items) => items,
1593            _ => panic!("expected Items"),
1594        };
1595        assert_eq!(items.len(), 4);
1596        assert!(matches!(
1597            items[2],
1598            InputItem::Item(Item::Message(MessageItem::Output(_)))
1599        ));
1600    }
1601
1602    // ---- EasyInputMessage / multimodal-without-`type` regression coverage ----
1603    // See issue #9468. Before the EasyInputMessage/EasyInputContent shadow
1604    // landed, the `InputItem::EasyMessage` fallback still routed through
1605    // upstream's strict `InputImageContent` (required `detail`), so any
1606    // multimodal message that omitted the spec-default `type: "message"` would
1607    // fail with "data did not match any variant of untagged enum InputItem".
1608
1609    #[test]
1610    fn easy_message_multimodal_without_type_routes_to_easymessage() {
1611        // AIPerf's pre-PR-931 payload shape: no top-level `type`, content is a
1612        // list containing an `input_image` part with no `detail`.
1613        let json = serde_json::json!({
1614            "role": "user",
1615            "content": [
1616                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1617            ]
1618        });
1619        let item: InputItem = serde_json::from_value(json).unwrap();
1620        match item {
1621            InputItem::EasyMessage(easy) => {
1622                assert_eq!(easy.role, Role::User);
1623                assert_eq!(easy.r#type, MessageType::Message);
1624                match easy.content {
1625                    EasyInputContent::ContentList(parts) => {
1626                        assert_eq!(parts.len(), 1);
1627                        match &parts[0] {
1628                            InputContent::InputImage(img) => {
1629                                assert_eq!(img.detail, ImageDetail::Auto);
1630                                assert_eq!(
1631                                    img.image_url.as_deref(),
1632                                    Some("data:image/png;base64,abc")
1633                                );
1634                            }
1635                            other => panic!("expected InputImage, got {other:?}"),
1636                        }
1637                    }
1638                    other => panic!("expected ContentList, got {other:?}"),
1639                }
1640            }
1641            other => panic!("expected EasyMessage, got {other:?}"),
1642        }
1643    }
1644
1645    #[test]
1646    fn easy_message_multimodal_with_explicit_null_detail() {
1647        // Same shape as above but with `detail: null` — exercises the
1648        // null-as-default path on the relaxed `InputImageContent` reached via
1649        // the EasyMessage variant.
1650        let json = serde_json::json!({
1651            "role": "user",
1652            "content": [
1653                {"type": "input_image", "image_url": "data:image/png;base64,abc", "detail": null}
1654            ]
1655        });
1656        let item: InputItem = serde_json::from_value(json).unwrap();
1657        assert!(matches!(item, InputItem::EasyMessage(_)));
1658    }
1659
1660    #[test]
1661    fn easy_message_assistant_multimodal_without_type() {
1662        // Mixed-turn shape AIPerf emits when the prior assistant turn carried
1663        // structured (non-string) content: role=assistant, content list, no
1664        // top-level `type`.
1665        let json = serde_json::json!({
1666            "role": "assistant",
1667            "content": [
1668                {"type": "input_text", "text": "ok"}
1669            ]
1670        });
1671        let item: InputItem = serde_json::from_value(json).unwrap();
1672        match item {
1673            InputItem::EasyMessage(easy) => {
1674                assert_eq!(easy.role, Role::Assistant);
1675            }
1676            other => panic!("expected EasyMessage(assistant), got {other:?}"),
1677        }
1678    }
1679
1680    #[test]
1681    fn easy_message_text_only_without_type_unchanged() {
1682        // Regression guard: the pre-existing text-only path was already
1683        // working (no multimodal content -> never hit upstream's strict
1684        // `InputImageContent`). Pin it so a future glob-shadow change can't
1685        // break it.
1686        let json = serde_json::json!({"role": "user", "content": "Hello"});
1687        let item: InputItem = serde_json::from_value(json).unwrap();
1688        match item {
1689            InputItem::EasyMessage(easy) => {
1690                assert_eq!(easy.role, Role::User);
1691                assert!(matches!(easy.content, EasyInputContent::Text(ref s) if s == "Hello"));
1692            }
1693            other => panic!("expected EasyMessage(Text), got {other:?}"),
1694        }
1695    }
1696
1697    #[test]
1698    fn easy_message_with_explicit_type_still_routes_to_item_message() {
1699        // AIPerf's post-PR-931 payload (with `type: "message"`) should still
1700        // hit the structured `Item::Message` path first — proving the existing
1701        // strict path didn't regress when EasyMessage was shadowed.
1702        let json = serde_json::json!({
1703            "type": "message",
1704            "role": "user",
1705            "content": [
1706                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1707            ]
1708        });
1709        let item: InputItem = serde_json::from_value(json).unwrap();
1710        match item {
1711            InputItem::Item(Item::Message(MessageItem::Input(msg))) => {
1712                assert_eq!(msg.role, InputRole::User);
1713                assert_eq!(msg.content.len(), 1);
1714            }
1715            other => panic!("expected Item::Message(Input), got {other:?}"),
1716        }
1717    }
1718
1719    #[test]
1720    fn create_response_roundtrip_aiperf_pre_pr931_payload() {
1721        // End-to-end shape: the exact request body AIPerf was emitting before
1722        // PR-931 for a multi-turn multimodal conversation. Mirrors what the
1723        // HTTP frontend receives. Must deserialize without error and preserve
1724        // turn ordering.
1725        let body = serde_json::json!({
1726            "model": "Qwen/Qwen2-VL-2B-Instruct",
1727            "input": [
1728                {
1729                    "role": "user",
1730                    "content": [
1731                        {"type": "input_text", "text": "Describe"},
1732                        {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1733                    ]
1734                },
1735                {
1736                    "role": "assistant",
1737                    "content": [{"type": "input_text", "text": "ok"}]
1738                },
1739                {
1740                    "role": "user",
1741                    "content": [{"type": "input_text", "text": "Now describe a different one."}]
1742                }
1743            ]
1744        });
1745        let req: CreateResponse = serde_json::from_value(body).unwrap();
1746        let items = match &req.input {
1747            InputParam::Items(items) => items,
1748            _ => panic!("expected Items"),
1749        };
1750        assert_eq!(items.len(), 3);
1751        // All three turns must land as EasyMessage (no top-level `type`).
1752        for (idx, item) in items.iter().enumerate() {
1753            assert!(
1754                matches!(item, InputItem::EasyMessage(_)),
1755                "turn {idx} did not route to EasyMessage: {item:?}",
1756            );
1757        }
1758    }
1759
1760    // ---- count input tokens (POST /v1/responses/input_tokens) ----
1761
1762    fn count(body: serde_json::Value) -> u32 {
1763        serde_json::from_value::<CountInputTokensRequest>(body)
1764            .expect("count request should deserialize")
1765            .estimate_tokens()
1766    }
1767
1768    #[test]
1769    fn count_tokens_plain_text_input() {
1770        // user role (4) + "Hello, world!" (13) == 17; 17 / 3 == 5.
1771        assert_eq!(
1772            count(serde_json::json!({"model": "m", "input": "Hello, world!"})),
1773            5
1774        );
1775    }
1776
1777    #[test]
1778    fn count_tokens_input_is_optional() {
1779        // The count endpoint accepts a body without `input`, unlike CreateResponse.
1780        assert_eq!(count(serde_json::json!({"model": "m"})), 0);
1781    }
1782
1783    #[test]
1784    fn count_tokens_empty_input_is_zero() {
1785        assert_eq!(count(serde_json::json!({"input": ""})), 0);
1786    }
1787
1788    #[test]
1789    fn count_tokens_short_input_never_rounds_to_zero() {
1790        // Every item that reaches the prompt now carries a role marker of at
1791        // least 4, so no `input` can land under the rounding threshold. Tools
1792        // are the one remaining path: they are appended to the request rather
1793        // than rendered as a message, so they carry no marker. A one-character
1794        // function name is 1, and 1 / 3 == 0 — but content was present, so
1795        // report 1.
1796        assert_eq!(
1797            count(serde_json::json!({"tools": [{"type": "function", "name": "a"}]})),
1798            1
1799        );
1800        // For comparison, the shortest possible input clears the guard on its
1801        // role marker alone: user (4) + "Hi" (2) == 6; 6 / 3 == 2.
1802        assert_eq!(count(serde_json::json!({"input": "Hi"})), 2);
1803    }
1804
1805    #[test]
1806    fn count_tokens_instructions_contribute() {
1807        // system role (6) + "You are helpful." (16)
1808        //   + user role (4) + "Hi" (2) == 28; 28 / 3 == 9.
1809        assert_eq!(
1810            count(serde_json::json!({"input": "Hi", "instructions": "You are helpful."})),
1811            9
1812        );
1813    }
1814
1815    #[test]
1816    fn count_tokens_scores_the_two_spellings_of_a_prompt_identically() {
1817        // `TryFrom<NvCreateResponse>` turns a top-level string `input` into a
1818        // user message and `instructions` into a system message, so these two
1819        // bodies build the same chat request and must score the same. Counting
1820        // the top-level forms as bare text undercounted them by the role
1821        // markers the item forms were already charged.
1822        assert_eq!(
1823            count(serde_json::json!({"input": "Hello"})),
1824            count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
1825        );
1826        assert_eq!(
1827            count(serde_json::json!({
1828                "input": "Hello",
1829                "instructions": "You are helpful."
1830            })),
1831            count(serde_json::json!({"input": [
1832                {"role": "system", "content": "You are helpful."},
1833                {"role": "user", "content": "Hello"}
1834            ]})),
1835        );
1836    }
1837
1838    #[test]
1839    fn count_tokens_easy_message_counts_role_and_content() {
1840        // user role (4) + "Hello" (5) == 9; 9 / 3 == 3.
1841        assert_eq!(
1842            count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
1843            3
1844        );
1845    }
1846
1847    #[test]
1848    fn count_tokens_structured_input_message() {
1849        // user role (4) + "Hello" (5) == 9; 9 / 3 == 3.
1850        assert_eq!(
1851            count(serde_json::json!({"input": [{
1852                "type": "message",
1853                "role": "user",
1854                "content": [{"type": "input_text", "text": "Hello"}],
1855            }]})),
1856            3
1857        );
1858    }
1859
1860    #[test]
1861    fn count_tokens_function_call_counts_name_and_arguments() {
1862        // A function call is rendered as a tool call on an assistant message:
1863        // assistant role (9) + "get_weather" (11) + r#"{"city":"SF"}"# (13)
1864        // == 33; 33 / 3 == 11.
1865        assert_eq!(
1866            count(serde_json::json!({"input": [{
1867                "type": "function_call",
1868                "call_id": "call_1",
1869                "name": "get_weather",
1870                "arguments": r#"{"city":"SF"}"#,
1871            }]})),
1872            11
1873        );
1874    }
1875
1876    #[test]
1877    fn count_tokens_charges_one_assistant_marker_per_coalesced_turn() {
1878        // `convert_input_items_to_messages` accumulates assistant-side items
1879        // into one `PendingAssistant`, so two parallel tool calls are a single
1880        // assistant message. Charging a marker per item would invent a turn
1881        // that never reaches the prompt.
1882        let one = serde_json::json!({"input": [
1883            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""}
1884        ]});
1885        let two = serde_json::json!({"input": [
1886            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1887            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1888        ]});
1889        // assistant (9) + "aa" (2) == 11 → 3; adding "bb" (2) == 13 → 4.
1890        // The marker is paid once, not twice.
1891        assert_eq!(count(one), 3);
1892        assert_eq!(count(two), 4);
1893
1894        // Assistant text, reasoning, and a tool call in one turn: still one
1895        // marker across all three.
1896        let mixed = serde_json::json!({"input": [
1897            {"role": "assistant", "content": "aa"},
1898            {"type": "reasoning", "summary": [{"type": "summary_text", "text": "bb"}]},
1899            {"type": "function_call", "call_id": "c1", "name": "cc", "arguments": ""}
1900        ]});
1901        assert_eq!(count(mixed), 5); // 9 + 2 + 2 + 2 == 15 → 5
1902    }
1903
1904    #[test]
1905    fn count_tokens_reopens_the_assistant_turn_after_a_flush() {
1906        // A tool result ends the assistant turn, so the assistant items after
1907        // it are a second turn and pay a second marker.
1908        let two_turns = serde_json::json!({"input": [
1909            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1910            {"type": "function_call_output", "call_id": "c1", "output": ""},
1911            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1912        ]});
1913        // assistant (9) + "aa" (2) + tool (4) + assistant (9) + "bb" (2)
1914        // == 26; 26 / 3 == 8.
1915        assert_eq!(count(two_turns), 8);
1916    }
1917
1918    #[test]
1919    fn count_tokens_item_reference_does_not_split_an_assistant_turn() {
1920        // The converter skips item references without flushing, so one sitting
1921        // between two tool calls must not make them look like two turns.
1922        let split = serde_json::json!({"input": [
1923            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1924            {"type": "item_reference", "id": "item_abc"},
1925            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1926        ]});
1927        let unsplit = serde_json::json!({"input": [
1928            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1929            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1930        ]});
1931        assert_eq!(count(split), count(unsplit));
1932    }
1933
1934    #[test]
1935    fn count_tokens_unsupported_item_splits_an_assistant_turn() {
1936        // The converter flushes before skipping an unsupported variant
1937        // precisely so a later function call cannot coalesce across it. The
1938        // estimate has to agree, or it undercounts the second turn's marker.
1939        let across = serde_json::json!({"input": [
1940            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1941            {"type": "web_search_call", "id": "ws_1", "status": "completed"},
1942            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1943        ]});
1944        // Two turns: 9 + 2 + 9 + 2 == 22; 22 / 3 == 7.
1945        assert_eq!(count(across), 7);
1946    }
1947
1948    #[test]
1949    fn count_tokens_function_call_output_counts_text() {
1950        // Rendered as its own tool-role message: tool role (4) + "sunny" (5)
1951        // == 9; 9 / 3 == 3.
1952        assert_eq!(
1953            count(serde_json::json!({"input": [{
1954                "type": "function_call_output",
1955                "call_id": "call_1",
1956                "output": "sunny",
1957            }]})),
1958            3
1959        );
1960    }
1961
1962    #[test]
1963    fn count_tokens_tools_contribute() {
1964        // "get_weather" (11) + "Get weather" (11) + r#"{"type":"object"}"# (17)
1965        // == 39; 39 / 3 == 13.
1966        assert_eq!(
1967            count(serde_json::json!({
1968                "input": "",
1969                "tools": [{
1970                    "type": "function",
1971                    "name": "get_weather",
1972                    "description": "Get weather",
1973                    "parameters": {"type": "object"},
1974                }],
1975            })),
1976            13
1977        );
1978    }
1979
1980    #[test]
1981    fn count_tokens_images_contribute_nothing() {
1982        // Only the text part is measured; the image part cannot be estimated
1983        // from character length.
1984        let with_image = count(serde_json::json!({"input": [{
1985            "type": "message",
1986            "role": "user",
1987            "content": [
1988                {"type": "input_text", "text": "Describe this"},
1989                {"type": "input_image", "image_url": "https://example.com/a-very-long-url.png"},
1990            ],
1991        }]}));
1992        let without_image = count(serde_json::json!({"input": [{
1993            "type": "message",
1994            "role": "user",
1995            "content": [{"type": "input_text", "text": "Describe this"}],
1996        }]}));
1997        assert_eq!(with_image, without_image);
1998    }
1999
2000    #[test]
2001    fn count_tokens_dropped_item_variants_cost_nothing() {
2002        // Dynamo's `convert_input_items_to_messages` flushes and skips these,
2003        // so they never reach the prompt. Counting their serialized form would
2004        // bill callers for JSON scaffolding the model never sees.
2005        for item in [
2006            serde_json::json!({"type": "web_search_call", "id": "ws_1", "status": "completed"}),
2007            serde_json::json!({
2008                "type": "computer_call",
2009                "call_id": "c_1",
2010                "id": "cu_1",
2011                "action": {"type": "screenshot"},
2012                "pending_safety_checks": [],
2013                "status": "completed",
2014            }),
2015        ] {
2016            assert_eq!(
2017                count(serde_json::json!({ "input": [item.clone()] })),
2018                0,
2019                "dropped item variant should not be counted: {item}"
2020            );
2021        }
2022    }
2023
2024    #[test]
2025    fn count_tokens_counts_exactly_the_variants_the_converter_renders() {
2026        // Guards the coupling documented on `measure_item`: the explicit
2027        // arms are meant to be the same set Dynamo's converter handles. Each
2028        // variant is asserted on its own — a single assertion over an array of
2029        // all four would still pass with three of the arms deleted. If dynamo
2030        // grows support for another variant, this test should gain a case.
2031        for item in [
2032            serde_json::json!({"role": "user", "content": "Hello"}),
2033            serde_json::json!({
2034                "type": "message",
2035                "role": "user",
2036                "content": [{"type": "input_text", "text": "Hello"}],
2037            }),
2038            serde_json::json!({
2039                "type": "message",
2040                "role": "assistant",
2041                "content": [{"type": "output_text", "text": "Hi", "annotations": []}],
2042            }),
2043            serde_json::json!({
2044                "type": "function_call",
2045                "call_id": "c1",
2046                "name": "get_weather",
2047                "arguments": "{}",
2048            }),
2049            serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "sunny"}),
2050            serde_json::json!({
2051                "type": "reasoning",
2052                "summary": [{"type": "summary_text", "text": "thinking"}],
2053            }),
2054        ] {
2055            assert!(
2056                count(serde_json::json!({ "input": [item.clone()] })) > 0,
2057                "rendered variant should be counted: {item}"
2058            );
2059        }
2060    }
2061
2062    #[test]
2063    fn count_tokens_reasoning_counts_summary_only() {
2064        // The converter joins `summary` and drops everything else on the item,
2065        // so `content` and `encrypted_content` must not inflate the estimate.
2066        let summary_only = serde_json::json!({"input": [{
2067            "type": "reasoning",
2068            "summary": [{"type": "summary_text", "text": "thinking"}],
2069        }]});
2070        let with_dropped_fields = serde_json::json!({"input": [{
2071            "type": "reasoning",
2072            "summary": [{"type": "summary_text", "text": "thinking"}],
2073            "content": [{"type": "reasoning_text", "text": "a much longer private chain of thought"}],
2074            "encrypted_content": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2075        }]});
2076
2077        // Reasoning rides on the pending assistant message:
2078        // assistant role (9) + "thinking" (8) == 17; 17 / 3 == 5.
2079        assert_eq!(count(summary_only.clone()), 5);
2080        assert_eq!(count(with_dropped_fields), count(summary_only));
2081    }
2082
2083    #[test]
2084    fn count_tokens_hosted_tools_cost_nothing() {
2085        // `convert_tools` forwards only function tools; hosted tools are
2086        // dropped, so they must not inflate the estimate.
2087        assert_eq!(
2088            count(serde_json::json!({
2089                "input": "",
2090                "tools": [{"type": "web_search"}],
2091            })),
2092            0
2093        );
2094    }
2095
2096    #[test]
2097    fn count_tokens_namespaced_tools_count_their_functions() {
2098        // `convert_tools` flattens namespaces to their bare function members,
2099        // so the members count and the namespace name does not.
2100        // "get_weather" (11) + "Get weather" (11) + r#"{"type":"object"}"# (17)
2101        // == 39; 39 / 3 == 13 — the same as the un-namespaced tool above.
2102        assert_eq!(
2103            count(serde_json::json!({
2104                "input": "",
2105                "tools": [{
2106                    "type": "namespace",
2107                    "name": "weather_ns",
2108                    "description": "Weather tools",
2109                    "tools": [{
2110                        "type": "function",
2111                        "name": "get_weather",
2112                        "description": "Get weather",
2113                        "parameters": {"type": "object"},
2114                    }],
2115                }],
2116            })),
2117            13
2118        );
2119    }
2120
2121    #[test]
2122    fn count_tokens_item_reference_contributes_nothing() {
2123        // The referenced content is held server-side and is not in this request.
2124        assert_eq!(
2125            count(serde_json::json!({"input": [{"type": "item_reference", "id": "msg_1"}]})),
2126            0
2127        );
2128    }
2129
2130    #[test]
2131    fn count_tokens_ignores_unsupported_stateful_fields() {
2132        // `previous_response_id` / `conversation` are accepted and disregarded
2133        // rather than rejected — this endpoint only estimates.
2134        assert_eq!(
2135            count(serde_json::json!({
2136                "model": "m",
2137                "input": "Hello, world!",
2138                "previous_response_id": "resp_abc123",
2139                "conversation": {"id": "conv_1"},
2140            })),
2141            5
2142        );
2143    }
2144
2145    #[test]
2146    fn count_tokens_deserializes_the_litellm_request_shape() {
2147        // The exact body LiteLLM's CountTokens handler sends:
2148        // {model, input, instructions?, tools?} with chat tools already
2149        // flattened into the Responses shape.
2150        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2151            "model": "dynamo/deepseek-ai/deepseek-v4-pro-sglang",
2152            "input": [{"role": "user", "content": "Hello"}],
2153            "instructions": "You are helpful.",
2154            "tools": [{
2155                "type": "function",
2156                "name": "get_weather",
2157                "description": "Get weather",
2158                "parameters": {"type": "object"},
2159            }],
2160        }))
2161        .expect("LiteLLM request shape should deserialize");
2162
2163        assert_eq!(
2164            request.model.as_deref(),
2165            Some("dynamo/deepseek-ai/deepseek-v4-pro-sglang")
2166        );
2167        assert!(matches!(request.input, InputParam::Items(ref items) if items.len() == 1));
2168        assert!(request.estimate_tokens() > 0);
2169    }
2170
2171    #[test]
2172    fn count_tokens_accepts_explicit_null_input() {
2173        // `#[serde(default)]` covers an absent `input`; this covers the null
2174        // an emitter produces when it always writes the key.
2175        assert_eq!(count(serde_json::json!({"model": "m", "input": null})), 0);
2176        assert_eq!(
2177            count(serde_json::json!({
2178                "model": "m",
2179                "input": null,
2180                "instructions": "You are helpful."
2181            })),
2182            7
2183        );
2184    }
2185
2186    #[test]
2187    fn count_tokens_drops_unparseable_tools_instead_of_failing() {
2188        // A Chat-Completions-shaped `custom` tool: `Tool` models the Responses
2189        // shape (`{"type": "custom", "name": ...}`), not the nested chat one.
2190        // It must not take the whole request down with it.
2191        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2192            "model": "m",
2193            "input": "Hello, world!",
2194            "tools": [{"type": "custom", "custom": {"name": "x"}}],
2195        }))
2196        .expect("an unparseable tool should be dropped, not rejected");
2197        assert_eq!(request.tools.as_deref(), Some(&[][..]));
2198        // Same count as the identical body with no `tools` key at all: the
2199        // dropped tool was worth 0 either way.
2200        assert_eq!(request.estimate_tokens(), 5);
2201    }
2202
2203    #[test]
2204    fn count_tokens_keeps_parseable_tools_alongside_dropped_ones() {
2205        // Dropping is per-entry, not all-or-nothing: a good function tool in
2206        // the same array still gets counted.
2207        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2208            "model": "m",
2209            "input": "Hello, world!",
2210            "tools": [
2211                {"type": "custom", "custom": {"name": "x"}},
2212                {"type": "function", "name": "get_weather", "description": "Get weather"},
2213            ],
2214        }))
2215        .expect("a mixed tool array should deserialize");
2216        assert_eq!(request.tools.as_ref().map(Vec::len), Some(1));
2217        assert!(
2218            request.estimate_tokens()
2219                > count(serde_json::json!({"model": "m", "input": "Hello, world!"}))
2220        );
2221    }
2222
2223    #[test]
2224    fn count_tokens_distinguishes_absent_tools_from_empty_tools() {
2225        // `None` and `Some([])` both score 0, but the field must round-trip
2226        // its presence: `skip_serializing_if` relies on the distinction.
2227        let absent: CountInputTokensRequest =
2228            serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
2229        assert_eq!(absent.tools, None);
2230        let empty: CountInputTokensRequest =
2231            serde_json::from_value(serde_json::json!({"input": "hi", "tools": []})).unwrap();
2232        assert_eq!(empty.tools.as_deref(), Some(&[][..]));
2233        let null: CountInputTokensRequest =
2234            serde_json::from_value(serde_json::json!({"input": "hi", "tools": null})).unwrap();
2235        assert_eq!(null.tools, None);
2236    }
2237
2238    #[test]
2239    fn count_tokens_response_serializes_to_the_openai_shape() {
2240        assert_eq!(
2241            serde_json::to_value(CountInputTokensResponse::new(42)).unwrap(),
2242            serde_json::json!({"object": "response.input_tokens", "input_tokens": 42})
2243        );
2244    }
2245}
2246
2247#[cfg(test)]
2248mod reasoning_effort_tests {
2249    use super::CreateResponse;
2250
2251    #[test]
2252    fn every_reasoning_effort_deserializes() {
2253        for effort in ["none", "minimal", "low", "medium", "high", "xhigh", "max"] {
2254            let body = serde_json::json!({
2255                "model": "m", "input": "hi", "reasoning": {"effort": effort}
2256            });
2257            serde_json::from_value::<CreateResponse>(body)
2258                .unwrap_or_else(|e| panic!("effort {effort} must deserialize: {e}"));
2259        }
2260    }
2261}