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    #[serde(skip_serializing_if = "Option::is_none")]
550    pub max_output_tokens: Option<u32>,
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub max_tool_calls: Option<u32>,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub metadata: Option<HashMap<String, String>>,
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub model: Option<String>,
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub parallel_tool_calls: Option<bool>,
559    #[serde(skip_serializing_if = "Option::is_none")]
560    pub previous_response_id: Option<String>,
561    #[serde(skip_serializing_if = "Option::is_none")]
562    pub prompt: Option<Prompt>,
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub prompt_cache_key: Option<String>,
565    #[serde(skip_serializing_if = "Option::is_none")]
566    pub prompt_cache_retention: Option<PromptCacheRetention>,
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub reasoning: Option<Reasoning>,
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub safety_identifier: Option<String>,
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub service_tier: Option<ServiceTier>,
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub store: Option<bool>,
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub stream: Option<bool>,
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub stream_options: Option<ResponseStreamOptions>,
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub temperature: Option<f32>,
581    #[serde(skip_serializing_if = "Option::is_none")]
582    pub text: Option<ResponseTextParam>,
583    #[serde(
584        default,
585        deserialize_with = "deserialize_tool_choice",
586        skip_serializing_if = "Option::is_none"
587    )]
588    pub tool_choice: Option<ToolChoiceParam>,
589    #[serde(skip_serializing_if = "Option::is_none")]
590    pub tools: Option<Vec<Tool>>,
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub top_logprobs: Option<u8>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub top_p: Option<f32>,
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub truncation: Option<Truncation>,
597}
598
599// ---------------------------------------------------------------------------
600// CountInputTokens (`POST /v1/responses/input_tokens`)
601// ---------------------------------------------------------------------------
602
603/// The `object` discriminator on a [`CountInputTokensResponse`].
604pub const RESPONSE_INPUT_TOKENS_OBJECT: &str = "response.input_tokens";
605
606/// Request body for `POST /v1/responses/input_tokens`.
607///
608/// A subset of [`CreateResponse`] — only the fields that reach the rendered
609/// prompt. This mirrors `AnthropicCountTokensRequest`, which is the same
610/// subset-of-the-create-request shape for `POST /v1/messages/count_tokens`.
611///
612/// Two deliberate differences from `CreateResponse`: `input` defaults (the
613/// count endpoint accepts a body without one, whereas creating a response
614/// requires it), and unknown fields are ignored, so stateful parameters
615/// Dynamo does not serve (`conversation`, `previous_response_id`) are accepted
616/// and disregarded rather than rejected. This endpoint reports a pre-flight
617/// estimate; it never generates, so there is nothing for them to affect.
618///
619/// Deserialization is forgiving in two further places — an explicit
620/// `"input": null` and unrecognized tool shapes — for the same reason
621/// `AnthropicTool` keeps every field but `name` optional: a pre-flight
622/// estimate that rejects a body it could have scored is strictly worse than
623/// one that scores it approximately. See [`deserialize_lenient_tools`].
624#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
625pub struct CountInputTokensRequest {
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub model: Option<String>,
628    /// `#[serde(default)]` alone covers an absent `input`, but not an explicit
629    /// `"input": null` — serde still hands that null to `InputParam`, whose
630    /// deserializer rejects it. Here both mean "nothing to count".
631    #[serde(default, deserialize_with = "deserialize_null_default_input")]
632    pub input: InputParam,
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub instructions: Option<String>,
635    #[serde(
636        default,
637        skip_serializing_if = "Option::is_none",
638        deserialize_with = "deserialize_lenient_tools"
639    )]
640    pub tools: Option<Vec<Tool>>,
641}
642
643fn deserialize_null_default_input<'de, D>(deserializer: D) -> Result<InputParam, D::Error>
644where
645    D: serde::Deserializer<'de>,
646{
647    Ok(Option::<InputParam>::deserialize(deserializer)?.unwrap_or_default())
648}
649
650/// Drop tool entries that do not deserialize into a known [`Tool`], rather than
651/// failing the whole request.
652///
653/// `Tool` is upstream's `#[serde(tag = "type")]` enum, so it models only the
654/// tool types the pinned `async-openai` knows. A caller that forwards a tool in
655/// a shape upstream does not model — a Chat-Completions-style `{"type":
656/// "custom", "custom": {...}}`, or a tool type newer than the pin — would
657/// otherwise get a 400 for a field that contributes almost nothing to the
658/// estimate.
659///
660/// Dropping costs nothing: [`estimate_tool_len`] already scores every
661/// non-function tool as 0, because `convert_tools` forwards only function tools
662/// to the backend. An unparseable tool was going to be worth 0 either way; this
663/// only decides whether the rest of the body still gets counted. That is the
664/// same trade `estimate_tool_len` documents when it wildcards where
665/// `measure_item` is exhaustive — `Tool` is upstream's type, and we carry
666/// no obligation to mirror its variants.
667fn deserialize_lenient_tools<'de, D>(deserializer: D) -> Result<Option<Vec<Tool>>, D::Error>
668where
669    D: serde::Deserializer<'de>,
670{
671    let Some(raw) = Option::<Vec<serde_json::Value>>::deserialize(deserializer)? else {
672        return Ok(None);
673    };
674    Ok(Some(
675        raw.into_iter()
676            .filter_map(|tool| serde_json::from_value::<Tool>(tool).ok())
677            .collect(),
678    ))
679}
680
681/// Response body for `POST /v1/responses/input_tokens`.
682///
683/// `Deserialize` is derived where the Anthropic count response is
684/// serialize-only: this body is round-tripped by the frontend's integration
685/// tests, which assert on the parsed shape rather than on raw JSON.
686#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
687pub struct CountInputTokensResponse {
688    /// Always [`RESPONSE_INPUT_TOKENS_OBJECT`]. Required by the OpenAI spec.
689    pub object: String,
690    pub input_tokens: u32,
691}
692
693impl CountInputTokensResponse {
694    pub fn new(input_tokens: u32) -> Self {
695        Self {
696            object: RESPONSE_INPUT_TOKENS_OBJECT.to_string(),
697            input_tokens,
698        }
699    }
700}
701
702impl CountInputTokensRequest {
703    /// Estimate input token count using a `len/3` heuristic.
704    ///
705    /// Same contract as `AnthropicCountTokensRequest::estimate_tokens`: sum the
706    /// character lengths of everything that reaches the prompt, divide by three,
707    /// and never report zero for input that carried content.
708    ///
709    /// This is an estimate, not a tokenization. A frontend serving a
710    /// backend that tokenizes for itself has no tokenizer loaded, so this
711    /// endpoint has to be able to answer without one.
712    pub fn estimate_tokens(&self) -> u32 {
713        let mut total_len: usize = 0;
714
715        // `instructions` and a top-level string `input` are not free-floating
716        // text: the converter turns them into a system and a user chat message
717        // respectively, exactly like the item messages below. Charge them the
718        // same role markers, or the identical prompt scores differently
719        // depending on which shape the caller used to express it.
720        //
721        // Both are skipped when empty, because an absent field is not a
722        // message. `InputParam::default()` is `Text("")`, so a body with no
723        // `input` at all lands here — and it must stay worth zero.
724        if let Some(instructions) = &self.instructions.as_ref().filter(|text| !text.is_empty()) {
725            total_len += role_len(Role::System) + instructions.len();
726        }
727
728        match &self.input {
729            InputParam::Text(text) if text.is_empty() => {}
730            InputParam::Text(text) => total_len += role_len(Role::User) + text.len(),
731            InputParam::Items(items) => total_len += estimate_input_items_len(items),
732        }
733
734        if let Some(tools) = &self.tools {
735            for tool in tools {
736                total_len += estimate_tool_len(tool);
737            }
738        }
739
740        let tokens = total_len / 3;
741        if tokens == 0 && total_len > 0 {
742            1
743        } else {
744            tokens as u32
745        }
746    }
747}
748
749/// Approximate character cost of a role marker, using the same constants
750/// `AnthropicCountTokensRequest::estimate_tokens` applies for the same purpose.
751fn role_len(role: Role) -> usize {
752    match role {
753        Role::User => 4,
754        Role::Assistant => 9,
755        Role::System => 6,
756        Role::Developer => 9,
757    }
758}
759
760fn input_role_len(role: InputRole) -> usize {
761    match role {
762        InputRole::User => 4,
763        InputRole::System => 6,
764        InputRole::Developer => 9,
765    }
766}
767
768/// The `tool` role marker on a tool-result message.
769///
770/// `role_len` covers only the roles upstream's `Role` enum models; chat
771/// completions' `tool` role has no variant there, so it gets its own constant
772/// on the same basis the others use — the length of the role word.
773const TOOL_ROLE_LEN: usize = 4;
774
775/// What an input item does to the converter's pending assistant message.
776enum GroupEffect {
777    /// Opens or extends the pending assistant message, emitting no message of
778    /// its own.
779    Assistant,
780    /// Flushes any pending assistant message and emits its own.
781    Flush,
782    /// Neither emits nor flushes — the converter skips it outright.
783    Skip,
784}
785
786/// Sum the input items, mirroring `convert_input_items_to_messages` — including
787/// its coalescing.
788///
789/// Assistant-side items do not each become a message. An echoed assistant
790/// message, a function call, and a reasoning summary all push into one
791/// `PendingAssistant`, which is flushed only by the next non-assistant item or
792/// by the end of the list. So the assistant role marker is charged once per
793/// flushed group, not once per item: two parallel function calls are one
794/// assistant turn and cost one marker between them.
795///
796/// A per-item sum cannot express that, which is why this walks the list rather
797/// than mapping over it.
798fn estimate_input_items_len(items: &[InputItem]) -> usize {
799    let mut total = 0;
800    let mut assistant_open = false;
801
802    for item in items {
803        let (effect, len) = measure_input_item(item);
804        total += len;
805        match effect {
806            GroupEffect::Assistant => {
807                if !assistant_open {
808                    assistant_open = true;
809                    total += role_len(Role::Assistant);
810                }
811            }
812            GroupEffect::Flush => assistant_open = false,
813            GroupEffect::Skip => {}
814        }
815    }
816
817    total
818}
819
820/// Measure one item and report what it does to the pending assistant group.
821///
822/// Assistant-side arms return content only: their role marker is the caller's
823/// to add, once per group.
824fn measure_input_item(item: &InputItem) -> (GroupEffect, usize) {
825    match item {
826        // A pointer to an item held server-side. The content it names is not in
827        // this request, so there is nothing here to measure — and the converter
828        // skips it without flushing, so it cannot split an assistant group.
829        InputItem::ItemReference(_) => (GroupEffect::Skip, 0),
830        InputItem::EasyMessage(message) => {
831            let content = estimate_easy_content_len(&message.content);
832            match message.role {
833                // A prior assistant turn echoed back; coalesces like the strict
834                // `MessageItem::Output` path.
835                Role::Assistant => (GroupEffect::Assistant, content),
836                role => (GroupEffect::Flush, role_len(role) + content),
837            }
838        }
839        InputItem::Item(item) => measure_item(item),
840    }
841}
842
843fn estimate_easy_content_len(content: &EasyInputContent) -> usize {
844    match content {
845        EasyInputContent::Text(text) => text.len(),
846        EasyInputContent::ContentList(parts) => parts.iter().map(estimate_input_content_len).sum(),
847    }
848}
849
850/// Only text parts are measured. An image or file contributes tokens as a
851/// function of its decoded form, which a character count cannot model at all —
852/// the Anthropic estimator skips non-text blocks for the same reason.
853fn estimate_input_content_len(part: &InputContent) -> usize {
854    match part {
855        InputContent::InputText(text) => text.text.len(),
856        InputContent::InputImage(_) | InputContent::InputFile(_) => 0,
857    }
858}
859
860fn measure_item(item: &Item) -> (GroupEffect, usize) {
861    match item {
862        Item::Message(MessageItem::Input(message)) => (
863            GroupEffect::Flush,
864            input_role_len(message.role)
865                + message
866                    .content
867                    .iter()
868                    .map(estimate_input_content_len)
869                    .sum::<usize>(),
870        ),
871        // Assistant-side: pushed into the pending message, so no role marker
872        // here. See `estimate_input_items_len`.
873        Item::Message(MessageItem::Output(message)) => (
874            GroupEffect::Assistant,
875            message
876                .content
877                .iter()
878                .map(|part| match part {
879                    InputOutputMessageContent::OutputText(text) => text.text.len(),
880                    InputOutputMessageContent::Refusal(refusal) => refusal.refusal.len(),
881                })
882                .sum::<usize>(),
883        ),
884        // Rendered as a tool call on the pending assistant message. `call_id`
885        // is excluded deliberately: it is correlation plumbing, not prompt
886        // text, in the templates Dynamo renders.
887        Item::FunctionCall(call) => (
888            GroupEffect::Assistant,
889            call.name.len() + call.arguments.len(),
890        ),
891        // Its own `tool`-role message, one per output, so it both flushes the
892        // assistant group and carries a role marker of its own.
893        Item::FunctionCallOutput(output) => (
894            GroupEffect::Flush,
895            TOOL_ROLE_LEN
896                + match &output.output {
897                    FunctionCallOutput::Text(text) => text.len(),
898                    FunctionCallOutput::Content(parts) => {
899                        parts.iter().map(estimate_input_content_len).sum()
900                    }
901                },
902        ),
903        // Only `summary` is measured, because only `summary` is rendered:
904        // the converter joins the summary parts and drops the rest of the
905        // item. `content` is excluded for that reason alone — if Dynamo
906        // learns to render it, it needs to start counting here too.
907        // `encrypted_content` is excluded on its own merits: it is an opaque
908        // blob the model never sees as prompt text, and it is routinely far
909        // larger than the reasoning it stands for.
910        Item::Reasoning(reasoning) => (
911            GroupEffect::Assistant,
912            reasoning
913                .summary
914                .iter()
915                .map(|part| match part {
916                    SummaryPart::SummaryText(text) => text.text.len(),
917                })
918                .sum(),
919        ),
920        // Everything below contributes nothing, because nothing below reaches
921        // the prompt: Dynamo's `convert_input_items_to_messages` flushes and
922        // skips every one of these ("we do not have a faithful Chat
923        // Completions mapping"). The arms above are exactly its handled set.
924        // Measuring the serialized form instead would bill callers for JSON
925        // scaffolding the model never sees: a bare `web_search_call` is 59
926        // characters, or 19 phantom tokens, and agentic clients echo many
927        // such items per turn.
928        //
929        // Listed out rather than wildcarded on purpose. `Item` is a shadow
930        // enum that "mirrors upstream variant-for-variant" and has to be
931        // extended by hand whenever upstream grows a variant (see CLAUDE.md,
932        // "Owned input chain"). A `_` arm would let that new variant default
933        // to zero silently; an exhaustive match turns it into a compile error
934        // that forces a render-or-not decision here, which is the same
935        // mechanism CLAUDE.md relies on to catch drift in `From` impls.
936        Item::FileSearchCall(_)
937        | Item::ComputerCall(_)
938        | Item::ComputerCallOutput(_)
939        | Item::WebSearchCall(_)
940        | Item::ToolSearchCall(_)
941        | Item::ToolSearchOutput(_)
942        | Item::Compaction(_)
943        | Item::ImageGenerationCall(_)
944        | Item::CodeInterpreterCall(_)
945        | Item::LocalShellCall(_)
946        | Item::LocalShellCallOutput(_)
947        | Item::ShellCall(_)
948        | Item::ShellCallOutput(_)
949        | Item::ApplyPatchCall(_)
950        | Item::ApplyPatchCallOutput(_)
951        | Item::McpListTools(_)
952        | Item::McpApprovalRequest(_)
953        | Item::McpApprovalResponse(_)
954        | Item::McpCall(_)
955        | Item::CustomToolCallOutput(_)
956        | Item::CustomToolCall(_) => (GroupEffect::Flush, 0),
957    }
958}
959
960/// Mirrors `convert_tools`: only function tools are forwarded to the backend,
961/// namespaced ones flattened to their bare function members. Hosted tools
962/// (web search, file search, computer use) are dropped there and so cost
963/// nothing here.
964///
965/// Wildcarded where `measure_item` is exhaustive, and deliberately so:
966/// `Tool` is upstream's type, not one of our shadows, so we carry no
967/// obligation to mirror its variants. Pinning it exhaustively would only
968/// break the build every time async-openai adds a hosted tool we would
969/// score as zero anyway.
970fn estimate_tool_len(tool: &Tool) -> usize {
971    match tool {
972        Tool::Function(function) => function_tool_len(
973            &function.name,
974            function.description.as_ref(),
975            function.parameters.as_ref(),
976        ),
977        Tool::Namespace(namespace) => namespace
978            .tools
979            .iter()
980            .map(|tool| match tool {
981                // The namespace name is an origin marker used to detect
982                // collisions, not prompt text — `push_function` forwards the
983                // bare function name.
984                NamespaceToolParamTool::Function(function) => function_tool_len(
985                    &function.name,
986                    function.description.as_ref(),
987                    function.parameters.as_ref(),
988                ),
989                NamespaceToolParamTool::Custom(_) => 0,
990            })
991            .sum(),
992        _ => 0,
993    }
994}
995
996fn function_tool_len(
997    name: &str,
998    description: Option<&String>,
999    parameters: Option<&serde_json::Value>,
1000) -> usize {
1001    name.len()
1002        + description.map_or(0, |description| description.len())
1003        + parameters.map_or(0, |schema| schema.to_string().len())
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009
1010    // ---- tool_choice object form (ai-dynamo/dynamo#10963 CASE 1) ----
1011
1012    fn tool_choice_of(json: serde_json::Value) -> Option<ToolChoiceParam> {
1013        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1014            "input": "hi",
1015            "tool_choice": json,
1016        }))
1017        .expect("CreateResponse should deserialize");
1018        req.tool_choice
1019    }
1020
1021    #[test]
1022    fn tool_choice_mode_object_coerces_to_mode() {
1023        // Anthropic-style / litellm shape: a mode expressed as an object with
1024        // extra keys. Must coerce to the corresponding `Mode`, ignoring extras.
1025        assert_eq!(
1026            tool_choice_of(serde_json::json!({"type": "auto", "disable_parallel_tool_use": true})),
1027            Some(ToolChoiceParam::Mode(ToolChoiceOptions::Auto)),
1028        );
1029        assert_eq!(
1030            tool_choice_of(serde_json::json!({"type": "none"})),
1031            Some(ToolChoiceParam::Mode(ToolChoiceOptions::None)),
1032        );
1033        assert_eq!(
1034            tool_choice_of(serde_json::json!({"type": "required"})),
1035            Some(ToolChoiceParam::Mode(ToolChoiceOptions::Required)),
1036        );
1037    }
1038
1039    #[test]
1040    fn tool_choice_bare_string_still_works() {
1041        assert_eq!(
1042            tool_choice_of(serde_json::json!("auto")),
1043            Some(ToolChoiceParam::Mode(ToolChoiceOptions::Auto)),
1044        );
1045    }
1046
1047    #[test]
1048    fn tool_choice_specific_function_object_still_works() {
1049        // The object form naming a specific tool must NOT be swallowed by the
1050        // mode coercion — `type: "function"` is not a mode.
1051        match tool_choice_of(serde_json::json!({"type": "function", "name": "get_weather"})) {
1052            Some(ToolChoiceParam::Function(f)) => assert_eq!(f.name, "get_weather"),
1053            other => panic!("expected Function tool choice, got {other:?}"),
1054        }
1055    }
1056
1057    #[test]
1058    fn tool_choice_absent_is_none() {
1059        let req: CreateResponse =
1060            serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
1061        assert!(req.tool_choice.is_none());
1062    }
1063
1064    // ---- reasoning item echoed back without id/summary (#10963 CASE 2) ----
1065
1066    #[test]
1067    fn reasoning_input_without_id_deserializes() {
1068        // Codex / OpenCode / agent SDKs echo a reasoning item with no `id`.
1069        let json = serde_json::json!({
1070            "type": "reasoning",
1071            "summary": [{"type": "summary_text", "text": "thinking"}],
1072        });
1073        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
1074            InputItem::Item(Item::Reasoning(r)) => {
1075                assert!(r.id.is_none());
1076                assert_eq!(r.summary.len(), 1);
1077            }
1078            other => panic!("expected Item::Reasoning, got {other:?}"),
1079        }
1080    }
1081
1082    #[test]
1083    fn reasoning_input_encrypted_without_id_or_summary_deserializes() {
1084        let json = serde_json::json!({
1085            "type": "reasoning",
1086            "encrypted_content": "AB==",
1087        });
1088        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
1089            InputItem::Item(Item::Reasoning(r)) => {
1090                assert!(r.id.is_none());
1091                assert!(r.summary.is_empty());
1092                assert_eq!(r.encrypted_content.as_deref(), Some("AB=="));
1093            }
1094            other => panic!("expected Item::Reasoning, got {other:?}"),
1095        }
1096    }
1097
1098    #[test]
1099    fn reasoning_input_with_id_still_works() {
1100        let json = serde_json::json!({
1101            "type": "reasoning",
1102            "id": "rs_1",
1103            "summary": [{"type": "summary_text", "text": "x"}],
1104            "status": "completed",
1105        });
1106        match serde_json::from_value::<InputItem>(json).expect("should deserialize") {
1107            InputItem::Item(Item::Reasoning(r)) => assert_eq!(r.id.as_deref(), Some("rs_1")),
1108            other => panic!("expected Item::Reasoning, got {other:?}"),
1109        }
1110    }
1111
1112    #[test]
1113    fn full_request_with_idless_reasoning_item_deserializes() {
1114        // The exact failure mode reported in #10963: a turn-2 `input` list
1115        // containing an echoed reasoning item that lost its `id`.
1116        let req: Result<CreateResponse, _> = serde_json::from_value(serde_json::json!({
1117            "model": "m",
1118            "input": [
1119                {"role": "user", "content": "hi"},
1120                {"type": "reasoning", "summary": [{"type": "summary_text", "text": "x"}]},
1121            ],
1122        }));
1123        assert!(
1124            req.is_ok(),
1125            "idless reasoning input should deserialize: {req:?}"
1126        );
1127    }
1128
1129    #[test]
1130    fn codex_agent_message_normalizes_to_user_message() {
1131        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1132            "input": [{
1133                "type": "agent_message",
1134                "author": "/root",
1135                "recipient": "/root/worker",
1136                "content": [
1137                    {"type": "input_text", "text": "First."},
1138                    {"type": "input_text", "text": "Second."},
1139                ],
1140            }],
1141        }))
1142        .expect("Codex agent message should deserialize");
1143
1144        let InputParam::Items(items) = req.input else {
1145            panic!("expected items");
1146        };
1147        assert!(matches!(
1148            &items[0],
1149            InputItem::EasyMessage(EasyInputMessage {
1150                role: Role::User,
1151                content: EasyInputContent::Text(text),
1152                ..
1153            }) if text == "First.\nSecond."
1154        ));
1155    }
1156
1157    #[test]
1158    fn codex_agent_message_string_content_normalizes_to_user_message() {
1159        let item: InputItem = serde_json::from_value(serde_json::json!({
1160            "type": "agent_message",
1161            "author": "/root",
1162            "recipient": "/root/worker",
1163            "content": "Return exactly OK.",
1164        }))
1165        .expect("Codex agent message with string content should deserialize");
1166
1167        assert!(matches!(
1168            item,
1169            InputItem::EasyMessage(EasyInputMessage {
1170                content: EasyInputContent::Text(text),
1171                ..
1172            }) if text == "Return exactly OK."
1173        ));
1174    }
1175
1176    #[test]
1177    fn codex_agent_message_normalizes_encrypted_content() {
1178        let req: CreateResponse = serde_json::from_value(serde_json::json!({
1179            "input": [{
1180                "type": "agent_message",
1181                "content": [
1182                    {"type": "input_text", "text": "Payload:"},
1183                    {"type": "encrypted_content", "encrypted_content": "Return exactly OK."},
1184                ],
1185            }],
1186        }))
1187        .expect("Codex agent message with encrypted content should deserialize");
1188
1189        let InputParam::Items(items) = req.input else {
1190            panic!("expected items");
1191        };
1192        assert!(matches!(
1193            &items[0],
1194            InputItem::EasyMessage(EasyInputMessage {
1195                content: EasyInputContent::Text(text),
1196                ..
1197            }) if text == "Payload:\nReturn exactly OK."
1198        ));
1199    }
1200
1201    #[test]
1202    fn codex_agent_message_missing_content_normalizes_empty() {
1203        let item: InputItem = serde_json::from_value(serde_json::json!({
1204            "type": "agent_message",
1205            "author": "/root",
1206            "recipient": "/root/worker",
1207        }))
1208        .expect("Codex agent message without content should deserialize");
1209        assert!(matches!(
1210            item,
1211            InputItem::EasyMessage(EasyInputMessage {
1212                content: EasyInputContent::Text(text),
1213                ..
1214            }) if text.is_empty()
1215        ));
1216    }
1217
1218    #[test]
1219    fn codex_agent_message_null_content_normalizes_empty() {
1220        let item: InputItem = serde_json::from_value(serde_json::json!({
1221            "type": "agent_message",
1222            "author": "/root",
1223            "recipient": "/root/worker",
1224            "content": null,
1225        }))
1226        .expect("Codex agent message with null content should deserialize");
1227        assert!(matches!(
1228            item,
1229            InputItem::EasyMessage(EasyInputMessage {
1230                content: EasyInputContent::Text(text),
1231                ..
1232            }) if text.is_empty()
1233        ));
1234    }
1235
1236    #[test]
1237    fn relaxed_assistant_message_without_id_or_status() {
1238        let json = serde_json::json!({
1239            "type": "message",
1240            "role": "assistant",
1241            "content": [{"type": "output_text", "text": "hi"}]
1242        });
1243        let item: InputItem = serde_json::from_value(json).unwrap();
1244        match item {
1245            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1246                assert_eq!(out.role, AssistantRole::Assistant);
1247                assert!(out.id.is_none());
1248                assert!(out.status.is_none());
1249            }
1250            other => panic!("expected Item::Message(Output), got {other:?}"),
1251        }
1252    }
1253
1254    #[test]
1255    fn function_call_output_image_part_without_detail_parses() {
1256        let json = serde_json::json!({
1257            "input": [
1258                {"type": "function_call", "call_id": "c1", "name": "screenshot", "arguments": "{}"},
1259                {"type": "function_call_output", "call_id": "c1", "output": [
1260                    {"type": "input_text", "text": "captured"},
1261                    {"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="}
1262                ]}
1263            ]
1264        });
1265        let req: CreateResponse = serde_json::from_value(json).unwrap();
1266        let InputParam::Items(items) = req.input else {
1267            panic!("expected Items")
1268        };
1269        match &items[1] {
1270            InputItem::Item(Item::FunctionCallOutput(fco)) => {
1271                assert_eq!(fco.call_id, "c1");
1272                let FunctionCallOutput::Content(parts) = &fco.output else {
1273                    panic!("expected Content, got {:?}", fco.output)
1274                };
1275                assert_eq!(parts.len(), 2);
1276                match &parts[1] {
1277                    InputContent::InputImage(img) => {
1278                        assert_eq!(img.detail, ImageDetail::Auto);
1279                        assert_eq!(
1280                            img.image_url.as_deref(),
1281                            Some("data:image/png;base64,iVBORw0KGgo=")
1282                        );
1283                    }
1284                    other => panic!("expected InputImage, got {other:?}"),
1285                }
1286            }
1287            other => panic!("expected FunctionCallOutput, got {other:?}"),
1288        }
1289    }
1290
1291    #[test]
1292    fn function_call_output_image_part_with_null_detail_matches_message_content() {
1293        // `"detail": null` is accepted in message content; a tool output must not
1294        // be stricter than the message it answers.
1295        let part = serde_json::json!({
1296            "type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo=", "detail": null
1297        });
1298        let message: Item = serde_json::from_value(serde_json::json!({
1299            "type": "message", "role": "user", "content": [part]
1300        }))
1301        .unwrap();
1302        assert!(matches!(message, Item::Message(_)));
1303        let output: Item = serde_json::from_value(serde_json::json!({
1304            "type": "function_call_output", "call_id": "c1", "output": [part]
1305        }))
1306        .unwrap();
1307        let Item::FunctionCallOutput(fco) = output else {
1308            panic!("expected FunctionCallOutput, got {output:?}")
1309        };
1310        match &fco.output {
1311            FunctionCallOutput::Content(parts) => match &parts[0] {
1312                InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1313                other => panic!("expected InputImage, got {other:?}"),
1314            },
1315            other => panic!("expected Content, got {other:?}"),
1316        }
1317    }
1318
1319    #[test]
1320    fn function_call_output_from_conversions_match_upstream() {
1321        assert_eq!(
1322            FunctionCallOutput::from("ok"),
1323            FunctionCallOutput::Text("ok".to_string())
1324        );
1325        assert_eq!(
1326            FunctionCallOutput::from(String::from("ok")),
1327            FunctionCallOutput::Text("ok".to_string())
1328        );
1329        let parts = vec![InputContent::InputText(InputTextContent {
1330            text: "captured".to_string(),
1331        })];
1332        let item = FunctionCallOutputItemParam {
1333            call_id: "c1".to_string(),
1334            output: parts.clone().into(),
1335            id: None,
1336            status: None,
1337        };
1338        assert_eq!(item.output, FunctionCallOutput::Content(parts));
1339    }
1340
1341    #[test]
1342    fn function_call_output_string_still_parses() {
1343        let item: Item = serde_json::from_value(serde_json::json!({
1344            "type": "function_call_output", "call_id": "c1", "output": "{\"ok\":true}"
1345        }))
1346        .unwrap();
1347        match item {
1348            Item::FunctionCallOutput(fco) => {
1349                assert!(
1350                    matches!(fco.output, FunctionCallOutput::Text(ref t) if t == "{\"ok\":true}")
1351                );
1352                assert!(fco.id.is_none() && fco.status.is_none());
1353            }
1354            other => panic!("expected FunctionCallOutput, got {other:?}"),
1355        }
1356    }
1357
1358    #[test]
1359    fn input_image_without_detail_defaults_to_auto() {
1360        let json = serde_json::json!({
1361            "type": "input_image",
1362            "image_url": "https://example.com/cat.jpg"
1363        });
1364        let content: InputContent = serde_json::from_value(json).unwrap();
1365        match content {
1366            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1367            other => panic!("expected InputImage, got {other:?}"),
1368        }
1369    }
1370
1371    #[test]
1372    fn input_image_with_explicit_null_detail_defaults_to_auto() {
1373        let json = serde_json::json!({
1374            "type": "input_image",
1375            "image_url": "https://example.com/cat.jpg",
1376            "detail": null
1377        });
1378        let content: InputContent = serde_json::from_value(json).unwrap();
1379        match content {
1380            InputContent::InputImage(img) => assert_eq!(img.detail, ImageDetail::Auto),
1381            other => panic!("expected InputImage, got {other:?}"),
1382        }
1383    }
1384
1385    #[test]
1386    fn assistant_message_without_content_field_deserializes() {
1387        // Bare assistant shell — no `content` field at all. Seen in real
1388        // Codex/Agents-SDK traffic on pure tool-call turns. `#[serde(default)]`
1389        // on `content` must accept omission and yield an empty vec.
1390        let json = serde_json::json!({
1391            "type": "message",
1392            "role": "assistant"
1393        });
1394        let item: InputItem = serde_json::from_value(json).unwrap();
1395        match item {
1396            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1397                assert_eq!(out.role, AssistantRole::Assistant);
1398                assert!(out.content.is_empty());
1399                assert!(out.id.is_none());
1400                assert!(out.status.is_none());
1401            }
1402            other => panic!("expected Item::Message(Output), got {other:?}"),
1403        }
1404    }
1405
1406    #[test]
1407    fn assistant_message_with_explicit_null_content_deserializes() {
1408        // Mirrors the `annotations: null` case: some serializers emit JSON null
1409        // for absent fields instead of omitting them. `Vec::deserialize` rejects
1410        // null, so `content` also needs `deserialize_null_as_empty_vec`.
1411        let json = serde_json::json!({
1412            "type": "message",
1413            "role": "assistant",
1414            "content": null
1415        });
1416        let item: InputItem = serde_json::from_value(json).unwrap();
1417        match item {
1418            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1419                assert!(out.content.is_empty());
1420            }
1421            other => panic!("expected Item::Message(Output), got {other:?}"),
1422        }
1423    }
1424
1425    #[test]
1426    fn mcp_call_item_deserializes() {
1427        // Guards against Item variant drift vs upstream — MCP item types were
1428        // added after the initial owned `Item` chain landed.
1429        let json = serde_json::json!({
1430            "type": "mcp_call",
1431            "id": "mcp_1",
1432            "server_label": "srv",
1433            "name": "t",
1434            "arguments": "{}"
1435        });
1436        let item: InputItem = serde_json::from_value(json).unwrap();
1437        assert!(matches!(item, InputItem::Item(Item::McpCall(_))));
1438    }
1439
1440    #[test]
1441    fn strict_assistant_message_still_deserializes() {
1442        let json = serde_json::json!({
1443            "type": "message",
1444            "role": "assistant",
1445            "id": "msg_1",
1446            "status": "completed",
1447            "content": [{"type": "output_text", "text": "hi", "annotations": []}]
1448        });
1449        let item: InputItem = serde_json::from_value(json).unwrap();
1450        match item {
1451            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1452                assert_eq!(out.id.as_deref(), Some("msg_1"));
1453                assert_eq!(out.status, Some(OutputStatus::Completed));
1454            }
1455            other => panic!("expected Item::Message(Output), got {other:?}"),
1456        }
1457    }
1458
1459    #[test]
1460    fn user_message_routes_to_input_variant() {
1461        let json = serde_json::json!({
1462            "type": "message",
1463            "role": "user",
1464            "content": [{"type": "input_text", "text": "hi"}]
1465        });
1466        let item: InputItem = serde_json::from_value(json).unwrap();
1467        assert!(matches!(
1468            item,
1469            InputItem::Item(Item::Message(MessageItem::Input(_)))
1470        ));
1471    }
1472
1473    #[test]
1474    fn function_call_item_still_deserializes() {
1475        let json = serde_json::json!({
1476            "type": "function_call",
1477            "call_id": "c",
1478            "name": "f",
1479            "arguments": "{}"
1480        });
1481        let item: InputItem = serde_json::from_value(json).unwrap();
1482        assert!(matches!(item, InputItem::Item(Item::FunctionCall(_))));
1483    }
1484
1485    #[test]
1486    fn easy_message_string_content_routes_to_easymessage() {
1487        let json = serde_json::json!({"role": "assistant", "content": "x"});
1488        let item: InputItem = serde_json::from_value(json).unwrap();
1489        assert!(matches!(item, InputItem::EasyMessage(_)));
1490    }
1491
1492    #[test]
1493    fn output_text_without_annotations_defaults_empty() {
1494        let json = serde_json::json!({"type": "output_text", "text": "hi"});
1495        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
1496        match part {
1497            InputOutputMessageContent::OutputText(t) => {
1498                assert!(t.annotations.is_empty());
1499            }
1500            _ => panic!("expected OutputText"),
1501        }
1502    }
1503
1504    #[test]
1505    fn output_text_with_explicit_null_annotations_deserializes_as_empty() {
1506        // Some clients serialize absent fields as JSON null instead of omitting
1507        // them. `Vec::deserialize` would reject null; the custom deserializer
1508        // treats explicit null identically to a missing field.
1509        let json = serde_json::json!({"type": "output_text", "text": "hi", "annotations": null});
1510        let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
1511        match part {
1512            InputOutputMessageContent::OutputText(t) => {
1513                assert!(t.annotations.is_empty());
1514            }
1515            _ => panic!("expected OutputText"),
1516        }
1517    }
1518
1519    #[test]
1520    fn assistant_message_with_explicit_null_id_and_status_deserializes() {
1521        // `Option<T>` natively accepts null as `None`, so these explicit-null
1522        // fields should flow through without a custom deserializer. This test
1523        // pins that behavior against accidental regressions (e.g. if someone
1524        // switches the field type away from `Option<_>`).
1525        let json = serde_json::json!({
1526            "type": "message",
1527            "role": "assistant",
1528            "id": null,
1529            "status": null,
1530            "content": [{"type": "output_text", "text": "hi", "annotations": null}]
1531        });
1532        let item: InputItem = serde_json::from_value(json).unwrap();
1533        match item {
1534            InputItem::Item(Item::Message(MessageItem::Output(out))) => {
1535                assert!(out.id.is_none());
1536                assert!(out.status.is_none());
1537                assert_eq!(out.content.len(), 1);
1538            }
1539            other => panic!("expected Item::Message(Output), got {other:?}"),
1540        }
1541    }
1542
1543    #[test]
1544    fn create_response_roundtrip_with_relaxed_input() {
1545        let body = serde_json::json!({
1546            "model": "m",
1547            "input": [
1548                {"type": "message", "role": "user", "content": [
1549                    {"type": "input_text", "text": "hi"}
1550                ]},
1551                {"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
1552                {"type": "message", "role": "assistant", "content": [
1553                    {"type": "output_text", "text": "\n\n"}
1554                ]},
1555                {"type": "function_call_output", "call_id": "c", "output": "x"}
1556            ]
1557        });
1558
1559        let req: CreateResponse = serde_json::from_value(body).unwrap();
1560        let items = match &req.input {
1561            InputParam::Items(items) => items,
1562            _ => panic!("expected Items"),
1563        };
1564        assert_eq!(items.len(), 4);
1565        assert!(matches!(
1566            items[2],
1567            InputItem::Item(Item::Message(MessageItem::Output(_)))
1568        ));
1569    }
1570
1571    // ---- EasyInputMessage / multimodal-without-`type` regression coverage ----
1572    // See issue #9468. Before the EasyInputMessage/EasyInputContent shadow
1573    // landed, the `InputItem::EasyMessage` fallback still routed through
1574    // upstream's strict `InputImageContent` (required `detail`), so any
1575    // multimodal message that omitted the spec-default `type: "message"` would
1576    // fail with "data did not match any variant of untagged enum InputItem".
1577
1578    #[test]
1579    fn easy_message_multimodal_without_type_routes_to_easymessage() {
1580        // AIPerf's pre-PR-931 payload shape: no top-level `type`, content is a
1581        // list containing an `input_image` part with no `detail`.
1582        let json = serde_json::json!({
1583            "role": "user",
1584            "content": [
1585                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1586            ]
1587        });
1588        let item: InputItem = serde_json::from_value(json).unwrap();
1589        match item {
1590            InputItem::EasyMessage(easy) => {
1591                assert_eq!(easy.role, Role::User);
1592                assert_eq!(easy.r#type, MessageType::Message);
1593                match easy.content {
1594                    EasyInputContent::ContentList(parts) => {
1595                        assert_eq!(parts.len(), 1);
1596                        match &parts[0] {
1597                            InputContent::InputImage(img) => {
1598                                assert_eq!(img.detail, ImageDetail::Auto);
1599                                assert_eq!(
1600                                    img.image_url.as_deref(),
1601                                    Some("data:image/png;base64,abc")
1602                                );
1603                            }
1604                            other => panic!("expected InputImage, got {other:?}"),
1605                        }
1606                    }
1607                    other => panic!("expected ContentList, got {other:?}"),
1608                }
1609            }
1610            other => panic!("expected EasyMessage, got {other:?}"),
1611        }
1612    }
1613
1614    #[test]
1615    fn easy_message_multimodal_with_explicit_null_detail() {
1616        // Same shape as above but with `detail: null` — exercises the
1617        // null-as-default path on the relaxed `InputImageContent` reached via
1618        // the EasyMessage variant.
1619        let json = serde_json::json!({
1620            "role": "user",
1621            "content": [
1622                {"type": "input_image", "image_url": "data:image/png;base64,abc", "detail": null}
1623            ]
1624        });
1625        let item: InputItem = serde_json::from_value(json).unwrap();
1626        assert!(matches!(item, InputItem::EasyMessage(_)));
1627    }
1628
1629    #[test]
1630    fn easy_message_assistant_multimodal_without_type() {
1631        // Mixed-turn shape AIPerf emits when the prior assistant turn carried
1632        // structured (non-string) content: role=assistant, content list, no
1633        // top-level `type`.
1634        let json = serde_json::json!({
1635            "role": "assistant",
1636            "content": [
1637                {"type": "input_text", "text": "ok"}
1638            ]
1639        });
1640        let item: InputItem = serde_json::from_value(json).unwrap();
1641        match item {
1642            InputItem::EasyMessage(easy) => {
1643                assert_eq!(easy.role, Role::Assistant);
1644            }
1645            other => panic!("expected EasyMessage(assistant), got {other:?}"),
1646        }
1647    }
1648
1649    #[test]
1650    fn easy_message_text_only_without_type_unchanged() {
1651        // Regression guard: the pre-existing text-only path was already
1652        // working (no multimodal content -> never hit upstream's strict
1653        // `InputImageContent`). Pin it so a future glob-shadow change can't
1654        // break it.
1655        let json = serde_json::json!({"role": "user", "content": "Hello"});
1656        let item: InputItem = serde_json::from_value(json).unwrap();
1657        match item {
1658            InputItem::EasyMessage(easy) => {
1659                assert_eq!(easy.role, Role::User);
1660                assert!(matches!(easy.content, EasyInputContent::Text(ref s) if s == "Hello"));
1661            }
1662            other => panic!("expected EasyMessage(Text), got {other:?}"),
1663        }
1664    }
1665
1666    #[test]
1667    fn easy_message_with_explicit_type_still_routes_to_item_message() {
1668        // AIPerf's post-PR-931 payload (with `type: "message"`) should still
1669        // hit the structured `Item::Message` path first — proving the existing
1670        // strict path didn't regress when EasyMessage was shadowed.
1671        let json = serde_json::json!({
1672            "type": "message",
1673            "role": "user",
1674            "content": [
1675                {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1676            ]
1677        });
1678        let item: InputItem = serde_json::from_value(json).unwrap();
1679        match item {
1680            InputItem::Item(Item::Message(MessageItem::Input(msg))) => {
1681                assert_eq!(msg.role, InputRole::User);
1682                assert_eq!(msg.content.len(), 1);
1683            }
1684            other => panic!("expected Item::Message(Input), got {other:?}"),
1685        }
1686    }
1687
1688    #[test]
1689    fn create_response_roundtrip_aiperf_pre_pr931_payload() {
1690        // End-to-end shape: the exact request body AIPerf was emitting before
1691        // PR-931 for a multi-turn multimodal conversation. Mirrors what the
1692        // HTTP frontend receives. Must deserialize without error and preserve
1693        // turn ordering.
1694        let body = serde_json::json!({
1695            "model": "Qwen/Qwen2-VL-2B-Instruct",
1696            "input": [
1697                {
1698                    "role": "user",
1699                    "content": [
1700                        {"type": "input_text", "text": "Describe"},
1701                        {"type": "input_image", "image_url": "data:image/png;base64,abc"}
1702                    ]
1703                },
1704                {
1705                    "role": "assistant",
1706                    "content": [{"type": "input_text", "text": "ok"}]
1707                },
1708                {
1709                    "role": "user",
1710                    "content": [{"type": "input_text", "text": "Now describe a different one."}]
1711                }
1712            ]
1713        });
1714        let req: CreateResponse = serde_json::from_value(body).unwrap();
1715        let items = match &req.input {
1716            InputParam::Items(items) => items,
1717            _ => panic!("expected Items"),
1718        };
1719        assert_eq!(items.len(), 3);
1720        // All three turns must land as EasyMessage (no top-level `type`).
1721        for (idx, item) in items.iter().enumerate() {
1722            assert!(
1723                matches!(item, InputItem::EasyMessage(_)),
1724                "turn {idx} did not route to EasyMessage: {item:?}",
1725            );
1726        }
1727    }
1728
1729    // ---- count input tokens (POST /v1/responses/input_tokens) ----
1730
1731    fn count(body: serde_json::Value) -> u32 {
1732        serde_json::from_value::<CountInputTokensRequest>(body)
1733            .expect("count request should deserialize")
1734            .estimate_tokens()
1735    }
1736
1737    #[test]
1738    fn count_tokens_plain_text_input() {
1739        // user role (4) + "Hello, world!" (13) == 17; 17 / 3 == 5.
1740        assert_eq!(
1741            count(serde_json::json!({"model": "m", "input": "Hello, world!"})),
1742            5
1743        );
1744    }
1745
1746    #[test]
1747    fn count_tokens_input_is_optional() {
1748        // The count endpoint accepts a body without `input`, unlike CreateResponse.
1749        assert_eq!(count(serde_json::json!({"model": "m"})), 0);
1750    }
1751
1752    #[test]
1753    fn count_tokens_empty_input_is_zero() {
1754        assert_eq!(count(serde_json::json!({"input": ""})), 0);
1755    }
1756
1757    #[test]
1758    fn count_tokens_short_input_never_rounds_to_zero() {
1759        // Every item that reaches the prompt now carries a role marker of at
1760        // least 4, so no `input` can land under the rounding threshold. Tools
1761        // are the one remaining path: they are appended to the request rather
1762        // than rendered as a message, so they carry no marker. A one-character
1763        // function name is 1, and 1 / 3 == 0 — but content was present, so
1764        // report 1.
1765        assert_eq!(
1766            count(serde_json::json!({"tools": [{"type": "function", "name": "a"}]})),
1767            1
1768        );
1769        // For comparison, the shortest possible input clears the guard on its
1770        // role marker alone: user (4) + "Hi" (2) == 6; 6 / 3 == 2.
1771        assert_eq!(count(serde_json::json!({"input": "Hi"})), 2);
1772    }
1773
1774    #[test]
1775    fn count_tokens_instructions_contribute() {
1776        // system role (6) + "You are helpful." (16)
1777        //   + user role (4) + "Hi" (2) == 28; 28 / 3 == 9.
1778        assert_eq!(
1779            count(serde_json::json!({"input": "Hi", "instructions": "You are helpful."})),
1780            9
1781        );
1782    }
1783
1784    #[test]
1785    fn count_tokens_scores_the_two_spellings_of_a_prompt_identically() {
1786        // `TryFrom<NvCreateResponse>` turns a top-level string `input` into a
1787        // user message and `instructions` into a system message, so these two
1788        // bodies build the same chat request and must score the same. Counting
1789        // the top-level forms as bare text undercounted them by the role
1790        // markers the item forms were already charged.
1791        assert_eq!(
1792            count(serde_json::json!({"input": "Hello"})),
1793            count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
1794        );
1795        assert_eq!(
1796            count(serde_json::json!({
1797                "input": "Hello",
1798                "instructions": "You are helpful."
1799            })),
1800            count(serde_json::json!({"input": [
1801                {"role": "system", "content": "You are helpful."},
1802                {"role": "user", "content": "Hello"}
1803            ]})),
1804        );
1805    }
1806
1807    #[test]
1808    fn count_tokens_easy_message_counts_role_and_content() {
1809        // user role (4) + "Hello" (5) == 9; 9 / 3 == 3.
1810        assert_eq!(
1811            count(serde_json::json!({"input": [{"role": "user", "content": "Hello"}]})),
1812            3
1813        );
1814    }
1815
1816    #[test]
1817    fn count_tokens_structured_input_message() {
1818        // user role (4) + "Hello" (5) == 9; 9 / 3 == 3.
1819        assert_eq!(
1820            count(serde_json::json!({"input": [{
1821                "type": "message",
1822                "role": "user",
1823                "content": [{"type": "input_text", "text": "Hello"}],
1824            }]})),
1825            3
1826        );
1827    }
1828
1829    #[test]
1830    fn count_tokens_function_call_counts_name_and_arguments() {
1831        // A function call is rendered as a tool call on an assistant message:
1832        // assistant role (9) + "get_weather" (11) + r#"{"city":"SF"}"# (13)
1833        // == 33; 33 / 3 == 11.
1834        assert_eq!(
1835            count(serde_json::json!({"input": [{
1836                "type": "function_call",
1837                "call_id": "call_1",
1838                "name": "get_weather",
1839                "arguments": r#"{"city":"SF"}"#,
1840            }]})),
1841            11
1842        );
1843    }
1844
1845    #[test]
1846    fn count_tokens_charges_one_assistant_marker_per_coalesced_turn() {
1847        // `convert_input_items_to_messages` accumulates assistant-side items
1848        // into one `PendingAssistant`, so two parallel tool calls are a single
1849        // assistant message. Charging a marker per item would invent a turn
1850        // that never reaches the prompt.
1851        let one = serde_json::json!({"input": [
1852            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""}
1853        ]});
1854        let two = serde_json::json!({"input": [
1855            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1856            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1857        ]});
1858        // assistant (9) + "aa" (2) == 11 → 3; adding "bb" (2) == 13 → 4.
1859        // The marker is paid once, not twice.
1860        assert_eq!(count(one), 3);
1861        assert_eq!(count(two), 4);
1862
1863        // Assistant text, reasoning, and a tool call in one turn: still one
1864        // marker across all three.
1865        let mixed = serde_json::json!({"input": [
1866            {"role": "assistant", "content": "aa"},
1867            {"type": "reasoning", "summary": [{"type": "summary_text", "text": "bb"}]},
1868            {"type": "function_call", "call_id": "c1", "name": "cc", "arguments": ""}
1869        ]});
1870        assert_eq!(count(mixed), 5); // 9 + 2 + 2 + 2 == 15 → 5
1871    }
1872
1873    #[test]
1874    fn count_tokens_reopens_the_assistant_turn_after_a_flush() {
1875        // A tool result ends the assistant turn, so the assistant items after
1876        // it are a second turn and pay a second marker.
1877        let two_turns = serde_json::json!({"input": [
1878            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1879            {"type": "function_call_output", "call_id": "c1", "output": ""},
1880            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1881        ]});
1882        // assistant (9) + "aa" (2) + tool (4) + assistant (9) + "bb" (2)
1883        // == 26; 26 / 3 == 8.
1884        assert_eq!(count(two_turns), 8);
1885    }
1886
1887    #[test]
1888    fn count_tokens_item_reference_does_not_split_an_assistant_turn() {
1889        // The converter skips item references without flushing, so one sitting
1890        // between two tool calls must not make them look like two turns.
1891        let split = serde_json::json!({"input": [
1892            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1893            {"type": "item_reference", "id": "item_abc"},
1894            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1895        ]});
1896        let unsplit = serde_json::json!({"input": [
1897            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1898            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1899        ]});
1900        assert_eq!(count(split), count(unsplit));
1901    }
1902
1903    #[test]
1904    fn count_tokens_unsupported_item_splits_an_assistant_turn() {
1905        // The converter flushes before skipping an unsupported variant
1906        // precisely so a later function call cannot coalesce across it. The
1907        // estimate has to agree, or it undercounts the second turn's marker.
1908        let across = serde_json::json!({"input": [
1909            {"type": "function_call", "call_id": "c1", "name": "aa", "arguments": ""},
1910            {"type": "web_search_call", "id": "ws_1", "status": "completed"},
1911            {"type": "function_call", "call_id": "c2", "name": "bb", "arguments": ""}
1912        ]});
1913        // Two turns: 9 + 2 + 9 + 2 == 22; 22 / 3 == 7.
1914        assert_eq!(count(across), 7);
1915    }
1916
1917    #[test]
1918    fn count_tokens_function_call_output_counts_text() {
1919        // Rendered as its own tool-role message: tool role (4) + "sunny" (5)
1920        // == 9; 9 / 3 == 3.
1921        assert_eq!(
1922            count(serde_json::json!({"input": [{
1923                "type": "function_call_output",
1924                "call_id": "call_1",
1925                "output": "sunny",
1926            }]})),
1927            3
1928        );
1929    }
1930
1931    #[test]
1932    fn count_tokens_tools_contribute() {
1933        // "get_weather" (11) + "Get weather" (11) + r#"{"type":"object"}"# (17)
1934        // == 39; 39 / 3 == 13.
1935        assert_eq!(
1936            count(serde_json::json!({
1937                "input": "",
1938                "tools": [{
1939                    "type": "function",
1940                    "name": "get_weather",
1941                    "description": "Get weather",
1942                    "parameters": {"type": "object"},
1943                }],
1944            })),
1945            13
1946        );
1947    }
1948
1949    #[test]
1950    fn count_tokens_images_contribute_nothing() {
1951        // Only the text part is measured; the image part cannot be estimated
1952        // from character length.
1953        let with_image = count(serde_json::json!({"input": [{
1954            "type": "message",
1955            "role": "user",
1956            "content": [
1957                {"type": "input_text", "text": "Describe this"},
1958                {"type": "input_image", "image_url": "https://example.com/a-very-long-url.png"},
1959            ],
1960        }]}));
1961        let without_image = count(serde_json::json!({"input": [{
1962            "type": "message",
1963            "role": "user",
1964            "content": [{"type": "input_text", "text": "Describe this"}],
1965        }]}));
1966        assert_eq!(with_image, without_image);
1967    }
1968
1969    #[test]
1970    fn count_tokens_dropped_item_variants_cost_nothing() {
1971        // Dynamo's `convert_input_items_to_messages` flushes and skips these,
1972        // so they never reach the prompt. Counting their serialized form would
1973        // bill callers for JSON scaffolding the model never sees.
1974        for item in [
1975            serde_json::json!({"type": "web_search_call", "id": "ws_1", "status": "completed"}),
1976            serde_json::json!({
1977                "type": "computer_call",
1978                "call_id": "c_1",
1979                "id": "cu_1",
1980                "action": {"type": "screenshot"},
1981                "pending_safety_checks": [],
1982                "status": "completed",
1983            }),
1984        ] {
1985            assert_eq!(
1986                count(serde_json::json!({ "input": [item.clone()] })),
1987                0,
1988                "dropped item variant should not be counted: {item}"
1989            );
1990        }
1991    }
1992
1993    #[test]
1994    fn count_tokens_counts_exactly_the_variants_the_converter_renders() {
1995        // Guards the coupling documented on `measure_item`: the explicit
1996        // arms are meant to be the same set Dynamo's converter handles. Each
1997        // variant is asserted on its own — a single assertion over an array of
1998        // all four would still pass with three of the arms deleted. If dynamo
1999        // grows support for another variant, this test should gain a case.
2000        for item in [
2001            serde_json::json!({"role": "user", "content": "Hello"}),
2002            serde_json::json!({
2003                "type": "message",
2004                "role": "user",
2005                "content": [{"type": "input_text", "text": "Hello"}],
2006            }),
2007            serde_json::json!({
2008                "type": "message",
2009                "role": "assistant",
2010                "content": [{"type": "output_text", "text": "Hi", "annotations": []}],
2011            }),
2012            serde_json::json!({
2013                "type": "function_call",
2014                "call_id": "c1",
2015                "name": "get_weather",
2016                "arguments": "{}",
2017            }),
2018            serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "sunny"}),
2019            serde_json::json!({
2020                "type": "reasoning",
2021                "summary": [{"type": "summary_text", "text": "thinking"}],
2022            }),
2023        ] {
2024            assert!(
2025                count(serde_json::json!({ "input": [item.clone()] })) > 0,
2026                "rendered variant should be counted: {item}"
2027            );
2028        }
2029    }
2030
2031    #[test]
2032    fn count_tokens_reasoning_counts_summary_only() {
2033        // The converter joins `summary` and drops everything else on the item,
2034        // so `content` and `encrypted_content` must not inflate the estimate.
2035        let summary_only = serde_json::json!({"input": [{
2036            "type": "reasoning",
2037            "summary": [{"type": "summary_text", "text": "thinking"}],
2038        }]});
2039        let with_dropped_fields = serde_json::json!({"input": [{
2040            "type": "reasoning",
2041            "summary": [{"type": "summary_text", "text": "thinking"}],
2042            "content": [{"type": "reasoning_text", "text": "a much longer private chain of thought"}],
2043            "encrypted_content": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2044        }]});
2045
2046        // Reasoning rides on the pending assistant message:
2047        // assistant role (9) + "thinking" (8) == 17; 17 / 3 == 5.
2048        assert_eq!(count(summary_only.clone()), 5);
2049        assert_eq!(count(with_dropped_fields), count(summary_only));
2050    }
2051
2052    #[test]
2053    fn count_tokens_hosted_tools_cost_nothing() {
2054        // `convert_tools` forwards only function tools; hosted tools are
2055        // dropped, so they must not inflate the estimate.
2056        assert_eq!(
2057            count(serde_json::json!({
2058                "input": "",
2059                "tools": [{"type": "web_search"}],
2060            })),
2061            0
2062        );
2063    }
2064
2065    #[test]
2066    fn count_tokens_namespaced_tools_count_their_functions() {
2067        // `convert_tools` flattens namespaces to their bare function members,
2068        // so the members count and the namespace name does not.
2069        // "get_weather" (11) + "Get weather" (11) + r#"{"type":"object"}"# (17)
2070        // == 39; 39 / 3 == 13 — the same as the un-namespaced tool above.
2071        assert_eq!(
2072            count(serde_json::json!({
2073                "input": "",
2074                "tools": [{
2075                    "type": "namespace",
2076                    "name": "weather_ns",
2077                    "description": "Weather tools",
2078                    "tools": [{
2079                        "type": "function",
2080                        "name": "get_weather",
2081                        "description": "Get weather",
2082                        "parameters": {"type": "object"},
2083                    }],
2084                }],
2085            })),
2086            13
2087        );
2088    }
2089
2090    #[test]
2091    fn count_tokens_item_reference_contributes_nothing() {
2092        // The referenced content is held server-side and is not in this request.
2093        assert_eq!(
2094            count(serde_json::json!({"input": [{"type": "item_reference", "id": "msg_1"}]})),
2095            0
2096        );
2097    }
2098
2099    #[test]
2100    fn count_tokens_ignores_unsupported_stateful_fields() {
2101        // `previous_response_id` / `conversation` are accepted and disregarded
2102        // rather than rejected — this endpoint only estimates.
2103        assert_eq!(
2104            count(serde_json::json!({
2105                "model": "m",
2106                "input": "Hello, world!",
2107                "previous_response_id": "resp_abc123",
2108                "conversation": {"id": "conv_1"},
2109            })),
2110            5
2111        );
2112    }
2113
2114    #[test]
2115    fn count_tokens_deserializes_the_litellm_request_shape() {
2116        // The exact body LiteLLM's CountTokens handler sends:
2117        // {model, input, instructions?, tools?} with chat tools already
2118        // flattened into the Responses shape.
2119        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2120            "model": "dynamo/deepseek-ai/deepseek-v4-pro-sglang",
2121            "input": [{"role": "user", "content": "Hello"}],
2122            "instructions": "You are helpful.",
2123            "tools": [{
2124                "type": "function",
2125                "name": "get_weather",
2126                "description": "Get weather",
2127                "parameters": {"type": "object"},
2128            }],
2129        }))
2130        .expect("LiteLLM request shape should deserialize");
2131
2132        assert_eq!(
2133            request.model.as_deref(),
2134            Some("dynamo/deepseek-ai/deepseek-v4-pro-sglang")
2135        );
2136        assert!(matches!(request.input, InputParam::Items(ref items) if items.len() == 1));
2137        assert!(request.estimate_tokens() > 0);
2138    }
2139
2140    #[test]
2141    fn count_tokens_accepts_explicit_null_input() {
2142        // `#[serde(default)]` covers an absent `input`; this covers the null
2143        // an emitter produces when it always writes the key.
2144        assert_eq!(count(serde_json::json!({"model": "m", "input": null})), 0);
2145        assert_eq!(
2146            count(serde_json::json!({
2147                "model": "m",
2148                "input": null,
2149                "instructions": "You are helpful."
2150            })),
2151            7
2152        );
2153    }
2154
2155    #[test]
2156    fn count_tokens_drops_unparseable_tools_instead_of_failing() {
2157        // A Chat-Completions-shaped `custom` tool: `Tool` models the Responses
2158        // shape (`{"type": "custom", "name": ...}`), not the nested chat one.
2159        // It must not take the whole request down with it.
2160        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2161            "model": "m",
2162            "input": "Hello, world!",
2163            "tools": [{"type": "custom", "custom": {"name": "x"}}],
2164        }))
2165        .expect("an unparseable tool should be dropped, not rejected");
2166        assert_eq!(request.tools.as_deref(), Some(&[][..]));
2167        // Same count as the identical body with no `tools` key at all: the
2168        // dropped tool was worth 0 either way.
2169        assert_eq!(request.estimate_tokens(), 5);
2170    }
2171
2172    #[test]
2173    fn count_tokens_keeps_parseable_tools_alongside_dropped_ones() {
2174        // Dropping is per-entry, not all-or-nothing: a good function tool in
2175        // the same array still gets counted.
2176        let request: CountInputTokensRequest = serde_json::from_value(serde_json::json!({
2177            "model": "m",
2178            "input": "Hello, world!",
2179            "tools": [
2180                {"type": "custom", "custom": {"name": "x"}},
2181                {"type": "function", "name": "get_weather", "description": "Get weather"},
2182            ],
2183        }))
2184        .expect("a mixed tool array should deserialize");
2185        assert_eq!(request.tools.as_ref().map(Vec::len), Some(1));
2186        assert!(
2187            request.estimate_tokens()
2188                > count(serde_json::json!({"model": "m", "input": "Hello, world!"}))
2189        );
2190    }
2191
2192    #[test]
2193    fn count_tokens_distinguishes_absent_tools_from_empty_tools() {
2194        // `None` and `Some([])` both score 0, but the field must round-trip
2195        // its presence: `skip_serializing_if` relies on the distinction.
2196        let absent: CountInputTokensRequest =
2197            serde_json::from_value(serde_json::json!({"input": "hi"})).unwrap();
2198        assert_eq!(absent.tools, None);
2199        let empty: CountInputTokensRequest =
2200            serde_json::from_value(serde_json::json!({"input": "hi", "tools": []})).unwrap();
2201        assert_eq!(empty.tools.as_deref(), Some(&[][..]));
2202        let null: CountInputTokensRequest =
2203            serde_json::from_value(serde_json::json!({"input": "hi", "tools": null})).unwrap();
2204        assert_eq!(null.tools, None);
2205    }
2206
2207    #[test]
2208    fn count_tokens_response_serializes_to_the_openai_shape() {
2209        assert_eq!(
2210            serde_json::to_value(CountInputTokensResponse::new(42)).unwrap(),
2211            serde_json::json!({"object": "response.input_tokens", "input_tokens": 42})
2212        );
2213    }
2214}