Skip to main content

dynamo_protocols/types/responses/
mod.rs

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