Skip to content

Preserve provider error bodies on non-200 streaming responses - #3

Open
tarzan wants to merge 5 commits into
masterfrom
fix/stream-error-body
Open

Preserve provider error bodies on non-200 streaming responses#3
tarzan wants to merge 5 commits into
masterfrom
fix/stream-error-body

Conversation

@tarzan

@tarzan tarzan commented Sep 4, 2026

Copy link
Copy Markdown
Member

Problem

DefactoAI.StreamRunner.run/2 and DefactoAI.Client.LangChain.stream_chat/2 post streaming chat completions with Req using into: collector() and decode_body: false. The collectors parse every body chunk as SSE and keep only data: lines.

When the gateway answers a non-200 status, the body is a plain JSON error document with no data: lines. The collector consumed it anyway, so the error surfaced as {:api_error, 422, ""} and the provider's message was lost.

Concrete case: Heroku Inference rejects an unreachable image URL with

422 {"error":{"code":422,"message":"failed to fetch image; check the url provided is valid","type":"unprocessable_entity"}}

Consumers classify these errors by matching on the message text (e.g. distinguishing a bad image URL from a genuine provider fault), which is impossible when the body arrives empty.

Fix

In both collectors, when resp.status != 200, append the raw chunk bytes to a private :error_body buffer instead of SSE-parsing them. The non-200 branch reports that buffered text, falling back to the existing body_to_text(body) when nothing was buffered. The 200 path is unchanged. The raw JSON text is returned as-is (callers match on substrings).

Tests

  • New test/defacto_ai/stream_runner_test.exs (Req plug: stubs): streamed-content happy path, a 422 JSON error body is preserved verbatim, and an empty 503 body still yields "".
  • New stream_chat/2 case asserting the streamed {:error, {:api_error, 422, body}} element carries the JSON body.

Both new error-body tests fail against the previous collectors (left: "") and pass with this change. Full suite: 97 tests, 0 failures.

🤖 Generated with Claude Code

tarzan and others added 5 commits September 4, 2026 16:40
Both streaming Req collectors (DefactoAI.StreamRunner.run/2 and
DefactoAI.Client.LangChain.run_stream_request/6) parsed every body chunk
as SSE, keeping only `data:` lines. When the gateway answers a non-200
status the body is a plain JSON error document with no `data:` lines, so
the collector silently consumed it and callers received
`{:api_error, 422, ""}` with the provider's message lost.

When resp.status != 200, buffer the raw chunk bytes on a private
`:error_body` key instead of SSE-parsing them, and report that text on
the non-200 branch (falling back to the existing body_to_text/1 when
nothing was buffered). The 200 path is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After a validation failure the repair loop re-runs the chain with the
model's tool-call assistant turn in the history. That turn has blank
content (nil, "" or []) because its payload lives in tool_calls, and
ChatOpenAI.for_api/3 serialises it verbatim. OpenAI accepts null/"" next
to tool_calls, but gateways fronting Anthropic (e.g. Heroku Inference)
require non-empty text on every message and reject the retry with
HTTP 400 `messages[2]: content is required` — which the earlier
nil -> "" coercion did not avoid.

fill_blank_content/1 now substitutes a short placeholder,
"(structured response provided as a tool call)", for assistant
messages with blank content that carry tool calls, and leaves every
other message untouched.

Also make validation failures visible in production: the retry is now
logged at :info (was :debug) with a bounded (<= 500 chars) reason —
strategy, schema, the changeset errors via describe_error/1 and the
payload's top-level keys, never the full payload — and the same reason
is attached to the [:defacto_ai, :repair_loop, :retry] telemetry
metadata as :reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…se on retry

Production runs against Heroku Inference (claude-4-5-sonnet, streaming,
ToolCall strategy) surfaced two follow-on failures.

1. `validation failed (markdown: is required) payload_keys=[]`: the
   streamed tool call reached decode_payload with empty arguments.
   DefactoAI.StreamRunner keyed every chunk on `index`, so a gateway that
   opens the call with index/id/function.name and then streams argument
   deltas with a missing or different index left the real call empty and
   the arguments in a nameless slot. Resolve the target slot per chunk:
   an explicit index at an opened call wins; a delta without a usable
   index (nil, or a slot never opened) continues the most recently opened
   call unless it clearly names another one; a complete (non-delta)
   `choices[0].message.tool_calls` is accepted as finished calls; and the
   legacy `delta.function_call` shape is treated as index 0. When a call
   still ends with empty arguments and no content, log at :info a bounded
   sample (first 5 chunks, 300 chars each) of the raw tool-call chunks so
   the real shape shows up in production logs. Raw chunks live in the
   collector state only until build_message/1.

2. The retry then failed with Anthropic's `messages.2: tool_use ids were
   found without tool_result blocks immediately after`. OpenAI has the
   same rule. DefactoAI.RepairLoop now answers a rejected tool-call turn
   with one is_error tool result per tool call (ToolResult with the
   call's tool_call_id and name, content = the validation error) before
   the existing corrective user message; ChatOpenAI.for_api/3 serialises
   each as a `role: tool` message with that tool_call_id. Without tool
   calls the single user message is kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ttrs

A live probe of Heroku Inference (stream: true, forced tool) shows the
real chunk shape: frames are `event: message` + `data: {...}`; the
opening chunk carries index/id/function.name; every argument delta
repeats `"name": ""`; and `tool_calls[].index` is Anthropic's
content-block index, so a text block before the tool_use puts the
argument deltas on a different index than the opening chunk. That is how
the streamed `respond` call reached decode_payload with no arguments.

DefactoAI.StreamRunner now normalises blank ids/names to "not given",
so `""` can neither open a call nor overwrite a real name; a delta
without id and without a non-empty name merges into the most recently
started call regardless of its index (an explicit index is only honoured
when it points at a started call, which keeps OpenAI's parallel calls
apart); argument deltas that arrive before their call opens are folded
into it once it does; and orphan slots without id or name are dropped
when a started call exists.

DefactoAI.Strategy.ToolCall.decode_payload/1 prefers the `respond` call
that actually carries arguments when several tool calls are present,
then any `respond` call, then the first one.

New per-call option `chat_model: keyword | map` on complete_structured/3,
complete_chat/2 and stream_chat/2: ChatOpenAI attributes merged into the
request (e.g. `max_tokens: 8_000` — Heroku's default is too small for a
multi-page markdown answer, and a truncated tool-call arguments JSON
fails to parse and cascades through every strategy). Strategy-controlled
attributes (tool_choice, json_response, stream) win over the caller's.
stream_chat builds its body by hand and honours :max_tokens and
:temperature. Documented in the README and the Client opts typedoc.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Production (Heroku Inference, images + long output) shows the opening
tool-call chunk arriving and then, after ~58 s of generation, no argument
deltas at all: `streamed tool call finished with empty arguments; 1 raw
tool-call chunk(s) seen`. The repair loop then spent both retries on the
same empty payload and the import failed with validation_failed.

Diagnostics: the StreamRunner empty-arguments log now also reports the
assembled content length and a 300-char sample, how many `data:` frames
were parsed, how many of those were not recognised as delta/message
chunks, and one unrecognised frame — so the next occurrence shows where
the bytes went.

Recovery: Strategy.ToolCall.decode_payload/1 hands unparsed argument text
to the JSON pipeline; when the chosen `respond` call has empty arguments
but the message has text content, returns that content so
JSON.decode_and_cast can extract the JSON; and when both are empty
returns {:error, :empty_tool_call}, which the client already maps to
:decode_failed and falls back to JsonMode/TextRepair instead of retrying.
RepairLoop treats an empty decoded map the same way
({:decode_failed, :empty_payload}).

Tests that exercised repair-loop retries via an empty tool call now use a
wrong-key payload, since an empty one is a fallback by design.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant