Unverified Commit 4ef28940 authored by ishandhanani's avatar ishandhanani Committed by GitHub
Browse files

fix(responses): own input chain in dynamo-protocols to accept Codex/Agents-SDK shapes (#8275)

parent c388483a
CLAUDE.md
\ No newline at end of file
# lib/protocols
OpenAI-compatible request/response types for Dynamo's HTTP surface. Built on top of the `async-openai` crate, with selective Dynamo-owned overrides where we need behaviors upstream won't accept or hasn't merged yet.
If you're extending or debugging types here, read this whole file before editing. The central question every change hinges on is: **do we re-export this upstream, or do we own it?** This document exists so the answer is consistent.
## The core tension
`async-openai` is well-maintained but slow on input-laxity PRs. The maintainer generally wants changes to match the OpenAPI spec exactly, even when OpenAI's *hosted* API accepts more permissive shapes on input than the spec requires. See `64bit/async-openai#535` (optional `ReasoningItem.id`) and prior work on optional `OutputMessage.id`/`status` — both driven by real Agents-SDK / Codex traffic that the spec technically rejects.
We can't block Dynamo on upstream merges. We also can't fork the whole crate — it's enormous and updates often. The rule we settled on is: **re-export upstream by default; own the narrowest type subtree that lets us fix the behavior we need.**
## The ownership rubric
Default to upstream. Own a type only when at least one of these is true:
1. **Upstream rejects a shape real clients send.** The driving case. Example: `OutputMessage.id`/`status`/`annotations` marked required upstream but routinely omitted by Codex / Agents SDK on input.
2. **We need to extend the schema with a Dynamo-specific field** that doesn't belong upstream. Example: `CreateChatCompletionRequest.mm_processor_kwargs` (vLLM multimodal), `ChatCompletionRequestAssistantMessage.reasoning_content` (R1 / QwQ), `ChatCompletionStreamOptions.continuous_usage_stats`.
3. **Upstream's type forces a shape that breaks downstream backends.** Example: `FunctionCall.arguments` is `String` upstream; LangChain and similar send it as an object. We own `FunctionCall` to accept both via a custom deserializer and normalize to `String`.
4. **Upstream has a known bug.** Example: `ChatCompletionMessageToolCall.type` wasn't always serialized; we own it with `#[serde(default = "default_function_type")]` to preserve wire compat.
**Do not own a type just because an adjacent type is owned.** Keep the blast radius small. If owning `OutputMessage` would cascade into owning `Response`, `OutputItem`, streaming events, and half the crate — stop and find a narrower fix (see "Naming: avoiding dual-side collisions" below).
## Layout
- `src/types/chat.rs` — Chat Completions (request, response, stream, messages). Extensively owned: multimodal content, reasoning, continuous usage stats, flexible `arguments`.
- `src/types/responses/mod.rs` — Responses API (Codex, Agents SDK). Input chain owned; output chain fully upstream.
- `src/types/completion.rs` — Legacy completions. Mostly upstream.
- `src/types/anthropic.rs` — Anthropic Messages API. Fully owned (no upstream equivalent in `async-openai`).
- `src/types/embeddings`, `src/types/images` — full upstream re-export (no Dynamo extensions).
## Re-export conventions
Use **explicit re-exports** (`pub use foo::{A, B, C}`), not globs, when you need to selectively shadow. Globs (`pub use foo::*`) are allowed at the top of a module — Rust lets a local `pub struct Foo` shadow a glob-imported `Foo` (the glob just emits `unused_imports` warnings). But explicit lists make the ownership split obvious to readers and catch mistakes at compile time when upstream renames or removes a type.
`src/types/responses/mod.rs` uses glob re-export because the surface is huge (200+ types). `src/types/chat.rs` uses explicit lists because the surface is manageable and Dynamo owns more of it. Either pattern is acceptable; pick based on how many types you'd have to enumerate to exclude the ones you own.
## Naming: avoiding dual-side collisions
**The trap.** Upstream sometimes reuses the same type on both request-input and response-output sides. `OutputMessage` is the canonical example: it appears inside `MessageItem::Output(...)` (input side — a prior assistant turn echoed back) AND inside `OutputItem::Message(...)` (output side — the assistant message we just produced).
If we relax `OutputMessage` (make `id`/`status` optional) and shadow upstream's name, every place that constructs an `OutputItem::Message(OutputMessage { ... })` on the output side breaks: `OutputItem::Message` variant holds upstream's type, not ours, and our relaxed struct doesn't match.
The naive fix is to also own `OutputItem`. But that cascades into owning `Response`, streaming events, and a long tail of their sub-types. The right fix is smaller:
**Rule.** If a type is reused by upstream on both input and output sides, give the Dynamo-owned input-side variant a *different name*. The output side keeps using upstream's name via the glob / explicit re-export.
Current naming in `responses/mod.rs`:
- `InputOutputMessage` — Dynamo-owned, relaxed; used in `MessageItem::Output(...)` on the input side.
- `OutputMessage` — upstream, unchanged; used in `OutputItem::Message(...)` on the output side.
- Same pattern for `InputOutputMessageContent` (input) vs upstream `OutputMessageContent` (output), and `InputOutputTextContent` (input) vs upstream `OutputTextContent` (output).
Input-only types can shadow upstream with the same name — no conflict. Current shadows: `MessageItem`, `Item`, `InputItem`, `InputParam`, `CreateResponse`.
## The Responses input chain, specifically
As of this writing, the owned input chain is:
```
CreateResponse
└── input: InputParam (shadow)
└── InputItem (shadow)
├── ItemReference (upstream)
├── EasyInputMessage (upstream)
└── Item (shadow, mirrors upstream variant-for-variant)
├── Message(MessageItem) (shadow)
│ ├── Input(InputMessage) (upstream)
│ └── Output(InputOutputMessage) (NEW NAME — relaxed)
│ └── content: Vec<InputOutputMessageContent> (NEW NAME)
│ └── OutputText(InputOutputTextContent) (NEW NAME — relaxed)
└── ... 19 other upstream variants (FunctionCall, Reasoning, etc.)
```
`Item` mirrors upstream variant-for-variant because it's a `#[serde(tag = "type")]` enum — we can't inherit variants. If upstream adds a new variant to their `Item`, we must add it here too, or payloads carrying that type will fail to deserialize. This is the one place where upstream drift bites us; accept it as the cost of owning the chain.
The output chain (`Response`, `OutputItem`, `OutputMessage`, streaming events, etc.) is fully upstream. We mint valid id/status on output, so there's no lenience needed and no reason to own it.
## When upstream finally merges a relaxation
If an upstream PR lands that makes a field optional (matching what we relaxed), the checklist is:
1. Bump `async-openai` in `Cargo.toml`.
2. Delete the owned override if it's now identical to upstream, or narrow it if upstream only partially relaxed.
3. Update consumer sites (convert `Option<T>` to `T` if upstream still has the field but non-optional, etc.).
4. Run the full test suite; the serialization-shape tests should catch any regressions.
Don't leave redundant Dynamo-owned types in place "just in case." Dead ownership is tech debt.
## When upstream renames or restructures a type we re-export
Glob re-exports will silently pick up the rename. Explicit re-exports will fail to compile — which is the point. Update the explicit list and any consumer code, confirm no semantic drift, run tests.
## Testing patterns
- Serialization-shape tests (`test_response_wire_format_shape` in `lib/llm`) validate that our serialized JSON matches the API spec. Lean on these when you change owned types.
- Deserialization tests for owned types should cover both the relaxed shape (the reason we own it) and the strict shape (to prove we didn't break spec-conformant clients).
- When you add a new Dynamo field to an owned type, add a test that omits it and asserts the default behavior.
## Things that are explicitly *not* this crate's job
- HTTP transport (request execution, retries, streaming frame parsing) — that's `lib/llm/src/http/`.
- Semantic conversion between API types (Responses → Chat, Anthropic → Chat, etc.) — that lives in `lib/llm/src/protocols/` and uses the types defined here.
- Model-specific tokenization or prompt templating.
Keep this crate declarative: types, serde derives, builders, conversions-by-`From`. Business logic belongs downstream.
## Common mistakes
- Owning a type because it's *nearby* a bug, not because of the bug itself. Narrow the fix.
- Shadowing a dual-side type without checking output-side construction sites. `grep` the workspace for constructor calls before renaming.
- Adding fields to upstream-re-exported types via `#[serde(default)]` on a local wrapper struct. Doesn't work — serde can't inject defaults into a foreign type unless you use `#[serde(remote)]`, which requires field-for-field mirroring and doesn't help with optional-vs-required mismatches.
- Forgetting to update `From` impls when adding variants. The compiler catches exhaustive matches but not variant count on `From<Ours> for Upstream` when the enum is non-exhaustive.
// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Re-exports upstream async-openai responses types.
// Upstream provides sdk convenience methods (output_text, etc.) directly.
// Dynamo owns the Responses-API input-side type chain. Upstream async-openai
// is the source for everything else (output-side types, streaming events,
// individual tool-call payloads, etc.).
//
// The input chain is owned because upstream marks fields as required that
// real-world clients (OpenAI Agents SDK, Codex, etc.) routinely omit when
// round-tripping a prior assistant turn as input:
// - `OutputMessage.id` / `.status` — omitted when echoing a previous output
// - `OutputTextContent.annotations` — omitted when the part carried none
// Upstream is slow to relax these (see e.g. 64bit/async-openai#535 for the
// sibling `ReasoningItem.id` fix, still open at time of writing); OpenAI's own
// hosted API accepts the relaxed shapes on input regardless.
//
// This mirrors the pattern in `crate::types::chat` where Dynamo owns the
// request types it needs to extend or relax while re-exporting the rest of
// upstream's type library verbatim.
//
// Naming: the relaxed assistant-input message is `InputOutputMessage` (and
// `InputOutputMessageContent` / `InputOutputTextContent` for its content
// parts) to avoid colliding with upstream's `OutputMessage`, which remains the
// canonical type for *output-side* response construction (`OutputItem`,
// `Response.output`). `MessageItem`, `Item`, `InputItem`, `InputParam`, and
// `CreateResponse` are input-only and shadow upstream's same-named types
// without conflict.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
// Re-export all upstream response types (includes shared types like
// ComparisonFilter, ResponseUsage, InputTokenDetails, etc.)
// Re-export all upstream response types (shared structures like ResponseUsage,
// tool-call item types, streaming events, etc.). The types we own below
// shadow their upstream counterparts where no dual-side conflict exists.
pub use async_openai::types::responses::*;
// Re-export from parent module for backward compat
// Re-export from parent module for backward compat.
pub use crate::types::ImageDetail;
pub use crate::types::ReasoningEffort;
pub use crate::types::ResponseFormatJsonSchema;
/// Stream of response events
pub type ResponseStream = std::pin::Pin<
Box<dyn futures::Stream<Item = Result<ResponseStreamEvent, crate::error::OpenAIError>> + Send>,
>;
// Backward-compatible type aliases for Dynamo consumer code migration.
pub type Input = InputParam;
pub type PromptConfig = Prompt;
pub type TextConfig = ResponseTextParam;
pub type TextResponseFormat = TextResponseFormatConfiguration;
/// Stream of response events.
pub type ResponseStream = std::pin::Pin<
Box<dyn futures::Stream<Item = Result<ResponseStreamEvent, crate::error::OpenAIError>> + Send>,
>;
// ---------------------------------------------------------------------------
// Input-side assistant message (relaxed vs upstream OutputMessage)
// ---------------------------------------------------------------------------
/// Deserialize `null` or a missing field as the default empty `Vec`. Plain
/// `#[serde(default)]` only fires when the field is absent; explicit `null`
/// would otherwise fail `Vec::deserialize`. Clients (notably some Agents SDK
/// variants) have been observed to send `"annotations": null`, so treat
/// omission and explicit null the same.
fn deserialize_null_as_empty_vec<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
}
/// Relaxed counterpart to upstream `OutputTextContent` for input-side content.
/// `annotations` tolerates both missing and explicit `null`; upstream requires
/// it to be a present non-null array.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct InputOutputTextContent {
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
pub annotations: Vec<Annotation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Vec<LogProb>>,
pub text: String,
}
/// Content parts of a prior assistant message presented as input.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputOutputMessageContent {
OutputText(InputOutputTextContent),
Refusal(RefusalContent),
}
/// An assistant message echoed back as input for a subsequent turn. Relaxed
/// compared to upstream `OutputMessage`: `id`, `status`, and `content` are all
/// optional. Some clients send a bare assistant shell (`{"type":"message",
/// "role":"assistant"}`) with no `content` at all, usually on pure tool-call
/// turns; treat absent `content` as an empty vec, same way we treat a missing
/// `id`/`status`.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct InputOutputMessage {
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
pub content: Vec<InputOutputMessageContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub role: AssistantRole,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<MessagePhase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<OutputStatus>,
}
// ---------------------------------------------------------------------------
// Input-side Item / Message / InputItem / InputParam (shadow upstream)
// ---------------------------------------------------------------------------
/// Message item within `Item`. Untagged; disambiguated by the `role` field:
/// the `Output` variant requires `role: "assistant"` (via `AssistantRole`,
/// which is a single-variant enum) and `Input` requires `role` in
/// `"user" | "system" | "developer"` (via `InputRole`). A payload with an
/// unknown role (e.g. `"tool"`) or a missing `role` produces the generic
/// untagged-enum error — callers are expected to send a valid role. If you
/// see the "data did not match any variant of untagged enum" failure on this
/// type, it is almost always a role mismatch.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum MessageItem {
/// Prior assistant output echoed back (role: assistant). Tried first — its
/// `role` constraint excludes user/system/developer inputs.
Output(InputOutputMessage),
/// User / system / developer input message.
Input(InputMessage),
}
/// Structured input/output item, discriminated by `type`. Mirrors upstream
/// `Item` variant-for-variant; only `Message` uses a Dynamo-owned type.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Item {
Message(MessageItem),
FileSearchCall(FileSearchToolCall),
ComputerCall(ComputerToolCall),
ComputerCallOutput(ComputerCallOutputItemParam),
WebSearchCall(WebSearchToolCall),
FunctionCall(FunctionToolCall),
FunctionCallOutput(FunctionCallOutputItemParam),
ToolSearchCall(ToolSearchCallItemParam),
ToolSearchOutput(ToolSearchOutputItemParam),
Reasoning(ReasoningItem),
Compaction(CompactionSummaryItemParam),
ImageGenerationCall(ImageGenToolCall),
CodeInterpreterCall(CodeInterpreterToolCall),
LocalShellCall(LocalShellToolCall),
LocalShellCallOutput(LocalShellToolCallOutput),
ShellCall(FunctionShellCallItemParam),
ShellCallOutput(FunctionShellCallOutputItemParam),
ApplyPatchCall(ApplyPatchToolCallItemParam),
ApplyPatchCallOutput(ApplyPatchToolCallOutputItemParam),
McpListTools(MCPListTools),
McpApprovalRequest(MCPApprovalRequest),
McpApprovalResponse(MCPApprovalResponse),
McpCall(MCPToolCall),
CustomToolCallOutput(CustomToolCallOutput),
CustomToolCall(CustomToolCall),
}
/// Single input item. Untagged; order matters (most specific first).
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum InputItem {
ItemReference(ItemReference),
Item(Item),
EasyMessage(EasyInputMessage),
}
/// Input to a `POST /v1/responses` request.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum InputParam {
Text(String),
Items(Vec<InputItem>),
}
impl Default for InputParam {
fn default() -> Self {
Self::Text(String::new())
}
}
// ---------------------------------------------------------------------------
// CreateResponse (owned, uses Dynamo-owned InputParam)
// ---------------------------------------------------------------------------
/// Request body for `POST /v1/responses`. Mirrors upstream `CreateResponse`
/// field-for-field but uses Dynamo-owned `InputParam`, which transitively
/// accepts the relaxed input shapes described in this module's header. All
/// other fields reference upstream types verbatim.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
pub struct CreateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation: Option<ConversationParam>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<IncludeEnum>>,
pub input: InputParam,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tool_calls: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<Prompt>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_retention: Option<PromptCacheRetention>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Reasoning>,
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<ResponseStreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<ResponseTextParam>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoiceParam>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation: Option<Truncation>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relaxed_assistant_message_without_id_or_status() {
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi"}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert_eq!(out.role, AssistantRole::Assistant);
assert!(out.id.is_none());
assert!(out.status.is_none());
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn assistant_message_without_content_field_deserializes() {
// Bare assistant shell — no `content` field at all. Seen in real
// Codex/Agents-SDK traffic on pure tool-call turns. `#[serde(default)]`
// on `content` must accept omission and yield an empty vec.
let json = serde_json::json!({
"type": "message",
"role": "assistant"
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert_eq!(out.role, AssistantRole::Assistant);
assert!(out.content.is_empty());
assert!(out.id.is_none());
assert!(out.status.is_none());
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn assistant_message_with_explicit_null_content_deserializes() {
// Mirrors the `annotations: null` case: some serializers emit JSON null
// for absent fields instead of omitting them. `Vec::deserialize` rejects
// null, so `content` also needs `deserialize_null_as_empty_vec`.
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"content": null
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert!(out.content.is_empty());
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn mcp_call_item_deserializes() {
// Guards against Item variant drift vs upstream — MCP item types were
// added after the initial owned `Item` chain landed.
let json = serde_json::json!({
"type": "mcp_call",
"id": "mcp_1",
"server_label": "srv",
"name": "t",
"arguments": "{}"
});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(item, InputItem::Item(Item::McpCall(_))));
}
#[test]
fn strict_assistant_message_still_deserializes() {
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"id": "msg_1",
"status": "completed",
"content": [{"type": "output_text", "text": "hi", "annotations": []}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert_eq!(out.id.as_deref(), Some("msg_1"));
assert_eq!(out.status, Some(OutputStatus::Completed));
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn user_message_routes_to_input_variant() {
let json = serde_json::json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hi"}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(
item,
InputItem::Item(Item::Message(MessageItem::Input(_)))
));
}
#[test]
fn function_call_item_still_deserializes() {
let json = serde_json::json!({
"type": "function_call",
"call_id": "c",
"name": "f",
"arguments": "{}"
});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(item, InputItem::Item(Item::FunctionCall(_))));
}
#[test]
fn easy_message_string_content_routes_to_easymessage() {
let json = serde_json::json!({"role": "assistant", "content": "x"});
let item: InputItem = serde_json::from_value(json).unwrap();
assert!(matches!(item, InputItem::EasyMessage(_)));
}
#[test]
fn output_text_without_annotations_defaults_empty() {
let json = serde_json::json!({"type": "output_text", "text": "hi"});
let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
match part {
InputOutputMessageContent::OutputText(t) => {
assert!(t.annotations.is_empty());
}
_ => panic!("expected OutputText"),
}
}
#[test]
fn output_text_with_explicit_null_annotations_deserializes_as_empty() {
// Some clients serialize absent fields as JSON null instead of omitting
// them. `Vec::deserialize` would reject null; the custom deserializer
// treats explicit null identically to a missing field.
let json = serde_json::json!({"type": "output_text", "text": "hi", "annotations": null});
let part: InputOutputMessageContent = serde_json::from_value(json).unwrap();
match part {
InputOutputMessageContent::OutputText(t) => {
assert!(t.annotations.is_empty());
}
_ => panic!("expected OutputText"),
}
}
#[test]
fn assistant_message_with_explicit_null_id_and_status_deserializes() {
// `Option<T>` natively accepts null as `None`, so these explicit-null
// fields should flow through without a custom deserializer. This test
// pins that behavior against accidental regressions (e.g. if someone
// switches the field type away from `Option<_>`).
let json = serde_json::json!({
"type": "message",
"role": "assistant",
"id": null,
"status": null,
"content": [{"type": "output_text", "text": "hi", "annotations": null}]
});
let item: InputItem = serde_json::from_value(json).unwrap();
match item {
InputItem::Item(Item::Message(MessageItem::Output(out))) => {
assert!(out.id.is_none());
assert!(out.status.is_none());
assert_eq!(out.content.len(), 1);
}
other => panic!("expected Item::Message(Output), got {other:?}"),
}
}
#[test]
fn create_response_roundtrip_with_relaxed_input() {
let body = serde_json::json!({
"model": "m",
"input": [
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "hi"}
]},
{"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"},
{"type": "message", "role": "assistant", "content": [
{"type": "output_text", "text": "\n\n"}
]},
{"type": "function_call_output", "call_id": "c", "output": "x"}
]
});
let req: CreateResponse = serde_json::from_value(body).unwrap();
let items = match &req.input {
InputParam::Items(items) => items,
_ => panic!("expected Items"),
};
assert_eq!(items.len(), 4);
assert!(matches!(
items[2],
InputItem::Item(Item::Message(MessageItem::Output(_)))
));
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment