Skip to content

fix: backfill missing strict responses fields#175

Open
pjb157 wants to merge 1 commit intomainfrom
imparando/01c42fed-88f0-45df-8af1-16917bfa479f
Open

fix: backfill missing strict responses fields#175
pjb157 wants to merge 1 commit intomainfrom
imparando/01c42fed-88f0-45df-8af1-16917bfa479f

Conversation

@pjb157
Copy link
Copy Markdown
Contributor

@pjb157 pjb157 commented Apr 13, 2026

Problem

In strict mode, success responses are sanitized by deserializing provider payloads into typed OpenAI schemas and then re-serializing them.

Some downstream providers return valid generations but omit schema-required, non-critical bookkeeping fields. In those cases, strict mode rejects the payload during sanitization even though the
generation itself succeeded and already incurred downstream cost. That turns a usable provider response into a user-visible error.

This failure mode is not limited to /v1/responses; the same pattern can affect strict chat completions and legacy completions as well.

Solution

Add sanitizer-layer normalization before typed deserialization for strict success responses.

This backfills OpenAI-compatible placeholder defaults for omitted non-critical fields so successful generations can still round-trip through the strict schema. The actual generated output is
preserved, and usage remains null when the provider did not send it.

This now applies to:

  • non-streaming /v1/chat/completions
  • streaming /v1/chat/completions
  • non-streaming /v1/completions
  • streaming /v1/completions
  • non-streaming /v1/responses
  • streaming /v1/responses response snapshots

Scope and Safety

The normalization is intentionally scoped to the sanitizer layer rather than implemented as serde defaults on the schema structs.

Those structs are used as strict schemas in multiple deserialize paths. Struct-level defaults would silently relax all of them, including internal loads and other strict-schema callsites. We only
want this leniency when shaping third-party provider payloads into the public strict-mode response.

Normalization is also gated so it only applies when the payload already resembles the expected endpoint response:

  • chat/completions payloads must already contain choices
  • completions payloads must already contain choices
  • non-streaming responses payloads must already contain output

SSE error objects such as {"error": ...} are still handled through the existing streaming error path and are not coerced into successful responses.

Why not serde defaults?

This is intentionally not implemented with struct-level serde defaults.

ResponsesResponse, ChatCompletionResponse, ChatCompletionChunk, CompletionResponse, and CompletionChunk are used as strict schema types. Adding serde defaults there would silently relax
every deserialize path for those types, not just provider response sanitization.

Streaming also needs event-aware defaults. In particular, some /v1/responses snapshot fields, especially status, depend on the SSE event type such as response.created vs response.completed,
which is not a good fit for plain struct-level defaults.

@imparandodev imparandodev bot force-pushed the imparando/01c42fed-88f0-45df-8af1-16917bfa479f branch from 70bb699 to 87e76fb Compare April 13, 2026 08:04
@pjb157 pjb157 requested a review from Copilot April 13, 2026 10:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves strict-mode handling of Open Responses provider payloads by normalizing/backfilling omitted (schema-required) fields so that otherwise-successful Responses API outputs can round-trip through the strict schema (including in SSE streaming), rather than failing sanitization.

Changes:

  • Added JSON Value normalizers to backfill missing fields for ResponsesResponse and for Responses SSE streaming events.
  • Updated strict sanitizers to normalize raw JSON before coercing into typed strict structs (both non-streaming and streaming paths).
  • Added tests covering backfill behavior for non-streaming and streaming Responses flows.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/strict/schemas/responses.rs Adds normalization helpers to backfill missing strict-schema fields in response payloads and streaming event snapshots.
src/strict/handlers.rs Uses the normalizers in strict response sanitization (JSON->normalize->typed coercion) and adds regression tests for backfilling.
Comments suppressed due to low confidence (1)

src/strict/handlers.rs:1822

  • In streaming sanitization, provider SSE error objects like {"error": ...} are valid JSON, so serde_json::from_str::<Value> succeeds, but serde_json::from_value::<ResponsesStreamingEvent> then fails and the stream is terminated. This regresses the previous behavior that detected these error objects and forwarded them via try_format_sse_error(...). Consider reintroducing the try_format_sse_error fallback when the typed ResponsesStreamingEvent coercion fails (or check for error before attempting to coerce).
                        normalize_responses_streaming_event_value(&mut raw_event);

                        match serde_json::from_value::<ResponsesStreamingEvent>(raw_event) {
                            Ok(mut event) => {
                                // Rewrite model on response-level events
                                if let Some(ref mut response) = event.response {
                                    response.model = original_model.clone();
                                }

                                // Re-serialize
                                match serde_json::to_string(&event) {
                                    Ok(sanitized_json) => {
                                        sanitized_lines.push(format!("data: {}", sanitized_json));
                                    }
                                    Err(e) => {
                                        error!(error = %e, "Failed to serialize responses chunk, terminating stream");
                                        return Err(std::io::Error::other(
                                            "Failed to serialize chunk",
                                        ));
                                    }
                                }
                            }
                            Err(e) => {
                                error!(
                                    error = %e,
                                    data_sample = ?data_part.chars().take(200).collect::<String>(),
                                    "Failed to parse responses SSE data line from provider, terminating stream"
                                );
                                return Err(std::io::Error::other(
                                    "Malformed SSE data from provider",
                                ));
                            }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +39 to +51
ensure_field(object, "id", || {
Value::String("resp_placeholder".to_string())
});
ensure_field(object, "object", || Value::String("response".to_string()));
ensure_field(object, "created_at", || Value::from(0));
ensure_field(object, "completed_at", || Value::Null);
ensure_field(object, "status", || Value::String("completed".to_string()));
ensure_field(object, "incomplete_details", || Value::Null);
ensure_field(object, "model", || Value::String(String::new()));
ensure_field(object, "previous_response_id", || Value::Null);
ensure_field(object, "instructions", || Value::Null);
ensure_field(object, "output", || Value::Array(Vec::new()));
ensure_field(object, "error", || Value::Null);
Comment on lines +121 to +128
if missing_completed_at
&& matches!(
event_type.as_str(),
"response.created" | "response.in_progress" | "response.failed"
)
{
response_object.insert("completed_at".to_string(), Value::Null);
}
@imparandodev imparandodev bot force-pushed the imparando/01c42fed-88f0-45df-8af1-16917bfa479f branch from 87e76fb to bddafa2 Compare April 13, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants