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 `Mode` 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::Mode(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<ServiceTier>,
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 `Mode`, ignoring extras.
1028        assert_eq!(
1029            tool_choice_of(serde_json::json!({"type": "auto", "disable_parallel_tool_use": true})),
1030            Some(ToolChoiceParam::Mode(ToolChoiceOptions::Auto)),
1031        );
1032        assert_eq!(
1033            tool_choice_of(serde_json::json!({"type": "none"})),
1034            Some(ToolChoiceParam::Mode(ToolChoiceOptions::None)),
1035        );
1036        assert_eq!(
1037            tool_choice_of(serde_json::json!({"type": "required"})),
1038            Some(ToolChoiceParam::Mode(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::Mode(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        })];
1335        let item = FunctionCallOutputItemParam {
1336            call_id: "c1".to_string(),
1337            output: parts.clone().into(),
1338            id: None,
1339            status: None,
1340        };
1341        assert_eq!(item.output, FunctionCallOutput::Content(parts));
1342    }
1343
1344    #[test]
1345    fn function_call_output_string_still_parses() {
1346        let item: Item = serde_json::from_value(serde_json::json!({
1347            "type": "function_call_output", "call_id": "c1", "output": "{\"ok\":true}"
1348        }))
1349        .unwrap();
1350        match item {
1351            Item::FunctionCallOutput(fco) => {
1352                assert!(
1353                    matches!(fco.output, FunctionCallOutput::Text(ref t) if t == "{\"ok\":true}")
1354                );
1355                assert!(fco.id.is_none() && fco.status.is_none());
1356            }
1357            other => panic!("expected FunctionCallOutput, got {other:?}"),
1358        }
1359    }
1360
1361    #[test]
1362    fn input_image_without_detail_defaults_to_auto() {
1363        let json = serde_json::json!({
1364            "type": "input_image",
1365            "image_url": "https://example.com/cat.jpg"
1366        });
1367        let content: InputContent = serde_json::from_value(json).unwrap();
1368        match content {
1369            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1370            other => panic!("expected InputImage, got {other:?}"),
1371        }
1372    }
1373
1374    #[test]
1375    fn input_image_with_explicit_null_detail_defaults_to_auto() {
1376        let json = serde_json::json!({
1377            "type": "input_image",
1378            "image_url": "https://example.com/cat.jpg",
1379            "detail": null
1380        });
1381        let content: InputContent = serde_json::from_value(json).unwrap();
1382        match content {
1383            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1384            other => panic!("expected InputImage, got {other:?}"),
1385        }
1386    }
1387
1388    #[test]
1389    fn assistant_message_without_content_field_deserializes() {
1390        // Bare assistant shell — no `content` field at all. Seen in real
1391        // Codex/Agents-SDK traffic on pure tool-call turns. `#[serde(default)]`
1392        // on `content` must accept omission and yield an empty vec.
1393        let json = serde_json::json!({
1394            "type": "message",
1395            "role": "assistant"
1396        });
1397        let item: InputItem = serde_json::from_value(json).unwrap();
1398        match item {
1399            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1400                assert_eq!(out.role, AssistantRole::Assistant);
1401                assert!(out.content.is_empty());
1402                assert!(out.id.is_none());
1403                assert!(out.status.is_none());
1404            }
1405            other => panic!("expected Item::Message(Output), got {other:?}"),
1406        }
1407    }
1408
1409    #[test]
1410    fn assistant_message_with_explicit_null_content_deserializes() {
1411        // Mirrors the `annotations: null` case: some serializers emit JSON null
1412        // for absent fields instead of omitting them. `Vec::deserialize` rejects
1413        // null, so `content` also needs `deserialize_null_as_empty_vec`.
1414        let json = serde_json::json!({
1415            "type": "message",
1416            "role": "assistant",
1417            "content": null
1418        });
1419        let item: InputItem = serde_json::from_value(json).unwrap();
1420        match item {
1421            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1422                assert!(out.content.is_empty());
1423            }
1424            other => panic!("expected Item::Message(Output), got {other:?}"),
1425        }
1426    }
1427
1428    #[test]
1429    fn mcp_call_item_deserializes() {
1430        // Guards against Item variant drift vs upstream — MCP item types were
1431        // added after the initial owned `Item` chain landed.
1432        let json = serde_json::json!({
1433            "type": "mcp_call",
1434            "id": "mcp_1",
1435            "server_label": "srv",
1436            "name": "t",
1437            "arguments": "{}"
1438        });
1439        let item: InputItem = serde_json::from_value(json).unwrap();
1440        assert!(matches!(item, InputItem::Item(Item::McpCall(_))));
1441    }
1442
1443    #[test]
1444    fn strict_assistant_message_still_deserializes() {
1445        let json = serde_json::json!({
1446            "type": "message",
1447            "role": "assistant",
1448            "id": "msg_1",
1449            "status": "completed",
1450            "content": [{"type": "output_text", "text": "hi", "annotations": []}]
1451        });
1452        let item: InputItem = serde_json::from_value(json).unwrap();
1453        match item {
1454            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1455                assert_eq!(out.id.as_deref(), Some("msg_1"));
1456                assert_eq!(out.status, Some(OutputStatus::Completed));
1457            }
1458            other => panic!("expected Item::Message(Output), got {other:?}"),
1459        }
1460    }
1461
1462    #[test]
1463    fn user_message_routes_to_input_variant() {
1464        let json = serde_json::json!({
1465            "type": "message",
1466            "role": "user",
1467            "content": [{"type": "input_text", "text": "hi"}]
1468        });
1469        let item: InputItem = serde_json::from_value(json).unwrap();
1470        assert!(matches!(
1471            item,
1472            InputItem::Item(Item::Message(MessageItem::Input(_)))
1473        ));
1474    }
1475
1476    #[test]
1477    fn function_call_item_still_deserializes() {
1478        let json = serde_json::json!({
1479            "type": "function_call",
1480            "call_id": "c",
1481            "name": "f",
1482            "arguments": "{}"
1483        });
1484        let item: InputItem = serde_json::from_value(json).unwrap();
1485        assert!(matches!(item, InputItem::Item(Item::FunctionCall(_))));
1486    }
1487
1488    #[test]
1489    fn easy_message_string_content_routes_to_easymessage() {
1490        let json = serde_json::json!({"role": "assistant", "content": "x"});
1491        let item: InputItem = serde_json::from_value(json).unwrap();
1492        assert!(matches!(item, InputItem::EasyMessage(_)));
1493    }
1494
1495    #[test]
1496    fn output_text_without_annotations_defaults_empty() {
1497        let json = serde_json::json!({"type": "output_text", "text": "hi"});
1498        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
1499        match part {
1500            InputOutputMessageContent::OutputText(t) => {
1501                assert!(t.annotations.is_empty());
1502            }
1503            _ => panic!("expected OutputText"),
1504        }
1505    }
1506
1507    #[test]
1508    fn output_text_with_explicit_null_annotations_deserializes_as_empty() {
1509        // Some clients serialize absent fields as JSON null instead of omitting
1510        // them. `Vec::deserialize` would reject null; the custom deserializer
1511        // treats explicit null identically to a missing field.
1512        let json = serde_json::json!({"type": "output_text", "text": "hi", "annotations": null});
1513        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
1514        match part {
1515            InputOutputMessageContent::OutputText(t) => {
1516                assert!(t.annotations.is_empty());
1517            }
1518            _ => panic!("expected OutputText"),
1519        }
1520    }
1521
1522    #[test]
1523    fn assistant_message_with_explicit_null_id_and_status_deserializes() {
1524        // `Option<T>` natively accepts null as `None`, so these explicit-null
1525        // fields should flow through without a custom deserializer. This test
1526        // pins that behavior against accidental regressions (e.g. if someone
1527        // switches the field type away from `Option<_>`).
1528        let json = serde_json::json!({
1529            "type": "message",
1530            "role": "assistant",
1531            "id": null,
1532            "status": null,
1533            "content": [{"type": "output_text", "text": "hi", "annotations": null}]
1534        });
1535        let item: InputItem = serde_json::from_value(json).unwrap();
1536        match item {
1537            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1538                assert!(out.id.is_none());
1539                assert!(out.status.is_none());
1540                assert_eq!(out.content.len(), 1);
1541            }
1542            other => panic!("expected Item::Message(Output), got {other:?}"),
1543        }
1544    }
1545
1546    /// The Chat Completions spelling `max_tokens` is honored as the output cap
1547    /// rather than silently dropped (#207); the wire echo is `max_output_tokens`.
1548    #[test]
1549    fn create_response_accepts_max_tokens_as_alias_for_max_output_tokens() {
1550        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1551            "model": "m", "input": "hi", "max_tokens": 16
1552        }))
1553        .unwrap();
1554        assert_eq!(req.max_output_tokens, Some(16));
1555        let back = serde_json::to_value(&req).unwrap();
1556        assert_eq!(back["max_output_tokens"], 16);
1557        assert!(back.get("max_tokens").is_none());
1558
1559        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1560            "model": "m", "input": "hi", "max_tokens": null
1561        }))
1562        .unwrap();
1563        assert_eq!(req.max_output_tokens, None);
1564
1565        // Both spellings at once is ambiguous and fails as a duplicate field.
1566        let err = serde_json::from_value::<CreateResponse>(serde_json::json!({
1567            "model": "m", "input": "hi", "max_tokens": 16, "max_output_tokens": 32
1568        }))
1569        .unwrap_err();
1570        assert!(err.to_string().contains("duplicate field"), "{err}");
1571    }
1572
1573    #[test]
1574    fn create_response_roundtrip_with_relaxed_input() {
1575        let body = serde_json::json!({
1576            "model": "m",
1577            "input": [
1578                {"type": "message", "role": "user", "content": [
1579                    {"type": "input_text", "text": "hi"}
1580                ]},
1581                {"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
1582                {"type": "message", "role": "assistant", "content": [
1583                    {"type": "output_text", "text": "\n\n"}
1584                ]},
1585                {"type": "function_call_output", "call_id": "c", "output": "x"}
1586            ]
1587        });
1588
1589        let req: CreateResponse = serde_json::from_value(body).unwrap();
1590        let items = match &req.input {
1591            InputParam::Items(items) => items,
1592            _ => panic!("expected Items"),
1593        };
1594        assert_eq!(items.len(), 4);
1595        assert!(matches!(
1596            items[2],
1597            InputItem::Item(Item::Message(MessageItem::Output(_)))
1598        ));
1599    }
1600
1601    // ---- EasyInputMessage / multimodal-without-`type` regression coverage ----
1602    // See issue #9468. Before the EasyInputMessage/EasyInputContent shadow
1603    // landed, the `InputItem::EasyMessage` fallback still routed through
1604    // upstream's strict `InputImageContent` (required `detail`), so any
1605    // multimodal message that omitted the spec-default `type: "message"` would
1606    // fail with "data did not match any variant of untagged enum InputItem".
1607
1608    #[test]
1609    fn easy_message_multimodal_without_type_routes_to_easymessage() {
1610        // AIPerf's pre-PR-931 payload shape: no top-level `type`, content is a
1611        // list containing an `input_image` part with no `detail`.
1612        let json = serde_json::json!({
1613            "role": "user",
1614            "content": [
1615                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1616            ]
1617        });
1618        let item: InputItem = serde_json::from_value(json).unwrap();
1619        match item {
1620            InputItem::EasyMessage(easy) => {
1621                assert_eq!(easy.role, Role::User);
1622                assert_eq!(easy.r#type, MessageType::Message);
1623                match easy.content {
1624                    EasyInputContent::ContentList(parts) => {
1625                        assert_eq!(parts.len(), 1);
1626                        match &parts[0] {
1627                            InputContent::InputImage(img) => {
1628                                assert_eq!(img.detail, ImageDetail::Auto);
1629                                assert_eq!(
1630                                    img.image_url.as_deref(),
1631                                    Some("data:image/png;base64,abc")
1632                                );
1633                            }
1634                            other => panic!("expected InputImage, got {other:?}"),
1635                        }
1636                    }
1637                    other => panic!("expected ContentList, got {other:?}"),
1638                }
1639            }
1640            other => panic!("expected EasyMessage, got {other:?}"),
1641        }
1642    }
1643
1644    #[test]
1645    fn easy_message_multimodal_with_explicit_null_detail() {
1646        // Same shape as above but with `detail: null` — exercises the
1647        // null-as-default path on the relaxed `InputImageContent` reached via
1648        // the EasyMessage variant.
1649        let json = serde_json::json!({
1650            "role": "user",
1651            "content": [
1652                {"type": "input_image", "image_url": "data:image/png;base64,abc", "detail": null}
1653            ]
1654        });
1655        let item: InputItem = serde_json::from_value(json).unwrap();
1656        assert!(matches!(item, InputItem::EasyMessage(_)));
1657    }
1658
1659    #[test]
1660    fn easy_message_assistant_multimodal_without_type() {
1661        // Mixed-turn shape AIPerf emits when the prior assistant turn carried
1662        // structured (non-string) content: role=assistant, content list, no
1663        // top-level `type`.
1664        let json = serde_json::json!({
1665            "role": "assistant",
1666            "content": [
1667                {"type": "input_text", "text": "ok"}
1668            ]
1669        });
1670        let item: InputItem = serde_json::from_value(json).unwrap();
1671        match item {
1672            InputItem::EasyMessage(easy) => {
1673                assert_eq!(easy.role, Role::Assistant);
1674            }
1675            other => panic!("expected EasyMessage(assistant), got {other:?}"),
1676        }
1677    }
1678
1679    #[test]
1680    fn easy_message_text_only_without_type_unchanged() {
1681        // Regression guard: the pre-existing text-only path was already
1682        // working (no multimodal content -> never hit upstream's strict
1683        // `InputImageContent`). Pin it so a future glob-shadow change can't
1684        // break it.
1685        let json = serde_json::json!({"role": "user", "content": "Hello"});
1686        let item: InputItem = serde_json::from_value(json).unwrap();
1687        match item {
1688            InputItem::EasyMessage(easy) => {
1689                assert_eq!(easy.role, Role::User);
1690                assert!(matches!(easy.content, EasyInputContent::Text(ref s) if s == "Hello"));
1691            }
1692            other => panic!("expected EasyMessage(Text), got {other:?}"),
1693        }
1694    }
1695
1696    #[test]
1697    fn easy_message_with_explicit_type_still_routes_to_item_message() {
1698        // AIPerf's post-PR-931 payload (with `type: "message"`) should still
1699        // hit the structured `Item::Message` path first — proving the existing
1700        // strict path didn't regress when EasyMessage was shadowed.
1701        let json = serde_json::json!({
1702            "type": "message",
1703            "role": "user",
1704            "content": [
1705                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1706            ]
1707        });
1708        let item: InputItem = serde_json::from_value(json).unwrap();
1709        match item {
1710            InputItem::Item(Item::Message(MessageItem::Input(msg))) => {
1711                assert_eq!(msg.role, InputRole::User);
1712                assert_eq!(msg.content.len(), 1);
1713            }
1714            other => panic!("expected Item::Message(Input), got {other:?}"),
1715        }
1716    }
1717
1718    #[test]
1719    fn create_response_roundtrip_aiperf_pre_pr931_payload() {
1720        // End-to-end shape: the exact request body AIPerf was emitting before
1721        // PR-931 for a multi-turn multimodal conversation. Mirrors what the
1722        // HTTP frontend receives. Must deserialize without error and preserve
1723        // turn ordering.
1724        let body = serde_json::json!({
1725            "model": "Qwen/Qwen2-VL-2B-Instruct",
1726            "input": [
1727                {
1728                    "role": "user",
1729                    "content": [
1730                        {"type": "input_text", "text": "Describe"},
1731                        {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1732                    ]
1733                },
1734                {
1735                    "role": "assistant",
1736                    "content": [{"type": "input_text", "text": "ok"}]
1737                },
1738                {
1739                    "role": "user",
1740                    "content": [{"type": "input_text", "text": "Now describe a different one."}]
1741                }
1742            ]
1743        });
1744        let req: CreateResponse = serde_json::from_value(body).unwrap();
1745        let items = match &req.input {
1746            InputParam::Items(items) => items,
1747            _ => panic!("expected Items"),
1748        };
1749        assert_eq!(items.len(), 3);
1750        // All three turns must land as EasyMessage (no top-level `type`).
1751        for (idx, item) in items.iter().enumerate() {
1752            assert!(
1753                matches!(item, InputItem::EasyMessage(_)),
1754                "turn {idx} did not route to EasyMessage: {item:?}",
1755            );
1756        }
1757    }
1758
1759    // ---- count input tokens (POST /v1/responses/input_tokens) ----
1760
1761    fn count(body: serde_json::Value) -> u32 {
1762        serde_json::from_value::<CountInputTokensRequest>(body)
1763            .expect("count request should deserialize")
1764            .estimate_tokens()
1765    }
1766
1767    #[test]
1768    fn count_tokens_plain_text_input() {
1769        // user role (4) + "Hello, world!" (13) == 17; 17 / 3 == 5.
1770        assert_eq!(
1771            count(serde_json::json!({"model": "m", "input": "Hello, world!"})),
1772            5
1773        );
1774    }
1775
1776    #[test]
1777    fn count_tokens_input_is_optional() {
1778        // The count endpoint accepts a body without `input`, unlike CreateResponse.
1779        assert_eq!(count(serde_json::json!({"model": "m"})), 0);
1780    }
1781
1782    #[test]
1783    fn count_tokens_empty_input_is_zero() {
1784        assert_eq!(count(serde_json::json!({"input": ""})), 0);
1785    }
1786
1787    #[test]
1788    fn count_tokens_short_input_never_rounds_to_zero() {
1789        // Every item that reaches the prompt now carries a role marker of at
1790        // least 4, so no `input` can land under the rounding threshold. Tools
1791        // are the one remaining path: they are appended to the request rather
1792        // than rendered as a message, so they carry no marker. A one-character
1793        // function name is 1, and 1 / 3 == 0 — but content was present, so
1794        // report 1.
1795        assert_eq!(
1796            count(serde_json::json!({"tools": [{"type": "function", "name": "a"}]})),
1797            1
1798        );
1799        // For comparison, the shortest possible input clears the guard on its
1800        // role marker alone: user (4) + "Hi" (2) == 6; 6 / 3 == 2.
1801        assert_eq!(count(serde_json::json!({"input": "Hi"})), 2);
1802    }
1803
1804    #[test]
1805    fn count_tokens_instructions_contribute() {
1806        // system role (6) + "You are helpful." (16)
1807        //   + user role (4) + "Hi" (2) == 28; 28 / 3 == 9.
1808        assert_eq!(
1809            count(serde_json::json!({"input": "Hi", "instructions": "You are helpful."})),
1810            9
1811        );
1812    }
1813
1814    #[test]
1815    fn count_tokens_scores_the_two_spellings_of_a_prompt_identically() {
1816        // `TryFrom<NvCreateResponse>` turns a top-level string `input` into a
1817        // user message and `instructions` into a system message, so these two
1818        // bodies build the same chat request and must score the same. Counting
1819        // the top-level forms as bare text undercounted them by the role
1820        // markers the item forms were already charged.
1821        assert_eq!(
1822            count(serde_json::json!({"input": "Hello"})),
1823            count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
1824        );
1825        assert_eq!(
1826            count(serde_json::json!({
1827                "input": "Hello",
1828                "instructions": "You are helpful."
1829            })),
1830            count(serde_json::json!({"input": [
1831                {"role": "system", "content": "You are helpful."},
1832                {"role": "user", "content": "Hello"}
1833            ]})),
1834        );
1835    }
1836
1837    #[test]
1838    fn count_tokens_easy_message_counts_role_and_content() {
1839        // user role (4) + "Hello" (5) == 9; 9 / 3 == 3.
1840        assert_eq!(
1841            count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
1842            3
1843        );
1844    }
1845
1846    #[test]
1847    fn count_tokens_structured_input_message() {
1848        // user role (4) + "Hello" (5) == 9; 9 / 3 == 3.
1849        assert_eq!(
1850            count(serde_json::json!({"input": [{
1851                "type": "message",
1852                "role": "user",
1853                "content": [{"type": "input_text", "text": "Hello"}],
1854            }]})),
1855            3
1856        );
1857    }
1858
1859    #[test]
1860    fn count_tokens_function_call_counts_name_and_arguments() {
1861        // A function call is rendered as a tool call on an assistant message:
1862        // assistant role (9) + "get_weather" (11) + r#"{"city":"SF"}"# (13)
1863        // == 33; 33 / 3 == 11.
1864        assert_eq!(
1865            count(serde_json::json!({"input": [{
1866                "type": "function_call",
1867                "call_id": "call_1",
1868                "name": "get_weather",
1869                "arguments": r#"{"city":"SF"}"#,
1870            }]})),
1871            11
1872        );
1873    }
1874
1875    #[test]
1876    fn count_tokens_charges_one_assistant_marker_per_coalesced_turn() {
1877        // `convert_input_items_to_messages` accumulates assistant-side items
1878        // into one `PendingAssistant`, so two parallel tool calls are a single
1879        // assistant message. Charging a marker per item would invent a turn
1880        // that never reaches the prompt.
1881        let one = serde_json::json!({"input": [
1882            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""}
1883        ]});
1884        let two = serde_json::json!({"input": [
1885            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1886            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1887        ]});
1888        // assistant (9) + "aa" (2) == 11 → 3; adding "bb" (2) == 13 → 4.
1889        // The marker is paid once, not twice.
1890        assert_eq!(count(one), 3);
1891        assert_eq!(count(two), 4);
1892
1893        // Assistant text, reasoning, and a tool call in one turn: still one
1894        // marker across all three.
1895        let mixed = serde_json::json!({"input": [
1896            {"role": "assistant", "content": "aa"},
1897            {"type": "reasoning", "summary": [{"type": "summary_text", "text": "bb"}]},
1898            {"type": "function_call", "call_id": "c1", "name": "cc", "arguments": ""}
1899        ]});
1900        assert_eq!(count(mixed), 5); // 9 + 2 + 2 + 2 == 15 → 5
1901    }
1902
1903    #[test]
1904    fn count_tokens_reopens_the_assistant_turn_after_a_flush() {
1905        // A tool result ends the assistant turn, so the assistant items after
1906        // it are a second turn and pay a second marker.
1907        let two_turns = serde_json::json!({"input": [
1908            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1909            {"type": "function_call_output", "call_id": "c1", "output": ""},
1910            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1911        ]});
1912        // assistant (9) + "aa" (2) + tool (4) + assistant (9) + "bb" (2)
1913        // == 26; 26 / 3 == 8.
1914        assert_eq!(count(two_turns), 8);
1915    }
1916
1917    #[test]
1918    fn count_tokens_item_reference_does_not_split_an_assistant_turn() {
1919        // The converter skips item references without flushing, so one sitting
1920        // between two tool calls must not make them look like two turns.
1921        let split = serde_json::json!({"input": [
1922            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1923            {"type": "item_reference", "id": "item_abc"},
1924            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1925        ]});
1926        let unsplit = serde_json::json!({"input": [
1927            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1928            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1929        ]});
1930        assert_eq!(count(split), count(unsplit));
1931    }
1932
1933    #[test]
1934    fn count_tokens_unsupported_item_splits_an_assistant_turn() {
1935        // The converter flushes before skipping an unsupported variant
1936        // precisely so a later function call cannot coalesce across it. The
1937        // estimate has to agree, or it undercounts the second turn's marker.
1938        let across = serde_json::json!({"input": [
1939            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1940            {"type": "web_search_call", "id": "ws_1", "status": "completed"},
1941            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1942        ]});
1943        // Two turns: 9 + 2 + 9 + 2 == 22; 22 / 3 == 7.
1944        assert_eq!(count(across), 7);
1945    }
1946
1947    #[test]
1948    fn count_tokens_function_call_output_counts_text() {
1949        // Rendered as its own tool-role message: tool role (4) + "sunny" (5)
1950        // == 9; 9 / 3 == 3.
1951        assert_eq!(
1952            count(serde_json::json!({"input": [{
1953                "type": "function_call_output",
1954                "call_id": "call_1",
1955                "output": "sunny",
1956            }]})),
1957            3
1958        );
1959    }
1960
1961    #[test]
1962    fn count_tokens_tools_contribute() {
1963        // "get_weather" (11) + "Get weather" (11) + r#"{"type":"object"}"# (17)
1964        // == 39; 39 / 3 == 13.
1965        assert_eq!(
1966            count(serde_json::json!({
1967                "input": "",
1968                "tools": [{
1969                    "type": "function",
1970                    "name": "get_weather",
1971                    "description": "Get weather",
1972                    "parameters": {"type": "object"},
1973                }],
1974            })),
1975            13
1976        );
1977    }
1978
1979    #[test]
1980    fn count_tokens_images_contribute_nothing() {
1981        // Only the text part is measured; the image part cannot be estimated
1982        // from character length.
1983        let with_image = count(serde_json::json!({"input": [{
1984            "type": "message",
1985            "role": "user",
1986            "content": [
1987                {"type": "input_text", "text": "Describe this"},
1988                {"type": "input_image", "image_url": "https://example.com/a-very-long-url.png"},
1989            ],
1990        }]}));
1991        let without_image = count(serde_json::json!({"input": [{
1992            "type": "message",
1993            "role": "user",
1994            "content": [{"type": "input_text", "text": "Describe this"}],
1995        }]}));
1996        assert_eq!(with_image, without_image);
1997    }
1998
1999    #[test]
2000    fn count_tokens_dropped_item_variants_cost_nothing() {
2001        // Dynamo's `convert_input_items_to_messages` flushes and skips these,
2002        // so they never reach the prompt. Counting their serialized form would
2003        // bill callers for JSON scaffolding the model never sees.
2004        for item in [
2005            serde_json::json!({"type": "web_search_call", "id": "ws_1", "status": "completed"}),
2006            serde_json::json!({
2007                "type": "computer_call",
2008                "call_id": "c_1",
2009                "id": "cu_1",
2010                "action": {"type": "screenshot"},
2011                "pending_safety_checks": [],
2012                "status": "completed",
2013            }),
2014        ] {
2015            assert_eq!(
2016                count(serde_json::json!({ "input": [item.clone()] })),
2017                0,
2018                "dropped item variant should not be counted: {item}"
2019            );
2020        }
2021    }
2022
2023    #[test]
2024    fn count_tokens_counts_exactly_the_variants_the_converter_renders() {
2025        // Guards the coupling documented on `measure_item`: the explicit
2026        // arms are meant to be the same set Dynamo's converter handles. Each
2027        // variant is asserted on its own — a single assertion over an array of
2028        // all four would still pass with three of the arms deleted. If dynamo
2029        // grows support for another variant, this test should gain a case.
2030        for item in [
2031            serde_json::json!({"role": "user", "content": "Hello"}),
2032            serde_json::json!({
2033                "type": "message",
2034                "role": "user",
2035                "content": [{"type": "input_text", "text": "Hello"}],
2036            }),
2037            serde_json::json!({
2038                "type": "message",
2039                "role": "assistant",
2040                "content": [{"type": "output_text", "text": "Hi", "annotations": []}],
2041            }),
2042            serde_json::json!({
2043                "type": "function_call",
2044                "call_id": "c1",
2045                "name": "get_weather",
2046                "arguments": "{}",
2047            }),
2048            serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "sunny"}),
2049            serde_json::json!({
2050                "type": "reasoning",
2051                "summary": [{"type": "summary_text", "text": "thinking"}],
2052            }),
2053        ] {
2054            assert!(
2055                count(serde_json::json!({ "input": [item.clone()] })) > 0,
2056                "rendered variant should be counted: {item}"
2057            );
2058        }
2059    }
2060
2061    #[test]
2062    fn count_tokens_reasoning_counts_summary_only() {
2063        // The converter joins `summary` and drops everything else on the item,
2064        // so `content` and `encrypted_content` must not inflate the estimate.
2065        let summary_only = serde_json::json!({"input": [{
2066            "type": "reasoning",
2067            "summary": [{"type": "summary_text", "text": "thinking"}],
2068        }]});
2069        let with_dropped_fields = serde_json::json!({"input": [{
2070            "type": "reasoning",
2071            "summary": [{"type": "summary_text", "text": "thinking"}],
2072            "content": [{"type": "reasoning_text", "text": "a much longer private chain of thought"}],
2073            "encrypted_content": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2074        }]});
2075
2076        // Reasoning rides on the pending assistant message:
2077        // assistant role (9) + "thinking" (8) == 17; 17 / 3 == 5.
2078        assert_eq!(count(summary_only.clone()), 5);
2079        assert_eq!(count(with_dropped_fields), count(summary_only));
2080    }
2081
2082    #[test]
2083    fn count_tokens_hosted_tools_cost_nothing() {
2084        // `convert_tools` forwards only function tools; hosted tools are
2085        // dropped, so they must not inflate the estimate.
2086        assert_eq!(
2087            count(serde_json::json!({
2088                "input": "",
2089                "tools": [{"type": "web_search"}],
2090            })),
2091            0
2092        );
2093    }
2094
2095    #[test]
2096    fn count_tokens_namespaced_tools_count_their_functions() {
2097        // `convert_tools` flattens namespaces to their bare function members,
2098        // so the members count and the namespace name does not.
2099        // "get_weather" (11) + "Get weather" (11) + r#"{"type":"object"}"# (17)
2100        // == 39; 39 / 3 == 13 — the same as the un-namespaced tool above.
2101        assert_eq!(
2102            count(serde_json::json!({
2103                "input": "",
2104                "tools": [{
2105                    "type": "namespace",
2106                    "name": "weather_ns",
2107                    "description": "Weather tools",
2108                    "tools": [{
2109                        "type": "function",
2110                        "name": "get_weather",
2111                        "description": "Get weather",
2112                        "parameters": {"type": "object"},
2113                    }],
2114                }],
2115            })),
2116            13
2117        );
2118    }
2119
2120    #[test]
2121    fn count_tokens_item_reference_contributes_nothing() {
2122        // The referenced content is held server-side and is not in this request.
2123        assert_eq!(
2124            count(serde_json::json!({"input": [{"type": "item_reference", "id": "msg_1"}]})),
2125            0
2126        );
2127    }
2128
2129    #[test]
2130    fn count_tokens_ignores_unsupported_stateful_fields() {
2131        // `previous_response_id` / `conversation` are accepted and disregarded
2132        // rather than rejected — this endpoint only estimates.
2133        assert_eq!(
2134            count(serde_json::json!({
2135                "model": "m",
2136                "input": "Hello, world!",
2137                "previous_response_id": "resp_abc123",
2138                "conversation": {"id": "conv_1"},
2139            })),
2140            5
2141        );
2142    }
2143
2144    #[test]
2145    fn count_tokens_deserializes_the_litellm_request_shape() {
2146        // The exact body LiteLLM's CountTokens handler sends:
2147        // {model, input, instructions?, tools?} with chat tools already
2148        // flattened into the Responses shape.
2149        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2150            "model": "dynamo/deepseek-ai/deepseek-v4-pro-sglang",
2151            "input": [{"role": "user", "content": "Hello"}],
2152            "instructions": "You are helpful.",
2153            "tools": [{
2154                "type": "function",
2155                "name": "get_weather",
2156                "description": "Get weather",
2157                "parameters": {"type": "object"},
2158            }],
2159        }))
2160        .expect("LiteLLM request shape should deserialize");
2161
2162        assert_eq!(
2163            request.model.as_deref(),
2164            Some("dynamo/deepseek-ai/deepseek-v4-pro-sglang")
2165        );
2166        assert!(matches!(request.input, InputParam::Items(ref items) if items.len() == 1));
2167        assert!(request.estimate_tokens() > 0);
2168    }
2169
2170    #[test]
2171    fn count_tokens_accepts_explicit_null_input() {
2172        // `#[serde(default)]` covers an absent `input`; this covers the null
2173        // an emitter produces when it always writes the key.
2174        assert_eq!(count(serde_json::json!({"model": "m", "input": null})), 0);
2175        assert_eq!(
2176            count(serde_json::json!({
2177                "model": "m",
2178                "input": null,
2179                "instructions": "You are helpful."
2180            })),
2181            7
2182        );
2183    }
2184
2185    #[test]
2186    fn count_tokens_drops_unparseable_tools_instead_of_failing() {
2187        // A Chat-Completions-shaped `custom` tool: `Tool` models the Responses
2188        // shape (`{"type": "custom", "name": ...}`), not the nested chat one.
2189        // It must not take the whole request down with it.
2190        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2191            "model": "m",
2192            "input": "Hello, world!",
2193            "tools": [{"type": "custom", "custom": {"name": "x"}}],
2194        }))
2195        .expect("an unparseable tool should be dropped, not rejected");
2196        assert_eq!(request.tools.as_deref(), Some(&[][..]));
2197        // Same count as the identical body with no `tools` key at all: the
2198        // dropped tool was worth 0 either way.
2199        assert_eq!(request.estimate_tokens(), 5);
2200    }
2201
2202    #[test]
2203    fn count_tokens_keeps_parseable_tools_alongside_dropped_ones() {
2204        // Dropping is per-entry, not all-or-nothing: a good function tool in
2205        // the same array still gets counted.
2206        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2207            "model": "m",
2208            "input": "Hello, world!",
2209            "tools": [
2210                {"type": "custom", "custom": {"name": "x"}},
2211                {"type": "function", "name": "get_weather", "description": "Get weather"},
2212            ],
2213        }))
2214        .expect("a mixed tool array should deserialize");
2215        assert_eq!(request.tools.as_ref().map(Vec::len), Some(1));
2216        assert!(
2217            request.estimate_tokens()
2218                > count(serde_json::json!({"model": "m", "input": "Hello, world!"}))
2219        );
2220    }
2221
2222    #[test]
2223    fn count_tokens_distinguishes_absent_tools_from_empty_tools() {
2224        // `None` and `Some([])` both score 0, but the field must round-trip
2225        // its presence: `skip_serializing_if` relies on the distinction.
2226        let absent: CountInputTokensRequest =
2227            serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
2228        assert_eq!(absent.tools, None);
2229        let empty: CountInputTokensRequest =
2230            serde_json::from_value(serde_json::json!({"input": "hi", "tools": []})).unwrap();
2231        assert_eq!(empty.tools.as_deref(), Some(&[][..]));
2232        let null: CountInputTokensRequest =
2233            serde_json::from_value(serde_json::json!({"input": "hi", "tools": null})).unwrap();
2234        assert_eq!(null.tools, None);
2235    }
2236
2237    #[test]
2238    fn count_tokens_response_serializes_to_the_openai_shape() {
2239        assert_eq!(
2240            serde_json::to_value(CountInputTokensResponse::new(42)).unwrap(),
2241            serde_json::json!({"object": "response.input_tokens", "input_tokens": 42})
2242        );
2243    }
2244}