fix(extract): enforce structured output on the chat transports - #479
Merged
Conversation
The model was free to return a tool call that violated the caller's schema,
and did. On one account, 71 of 84 extract failures were a tool call carrying
only the injected `basis` object; lifting the basis out left `{}`, which then
failed validation with every required property missing, after the scrape and
the LLM call had both been paid for.
- force `tool_choice` on the OpenAI and Anthropic transports (the Responses
transport already did), gated on a caller schema being present: a
prompt-only extraction has no validation behind it, so forcing there would
trade a loud failure for an invented object
- drop the force and retry once on a 4xx, so a gateway that does not accept
the object form cannot turn a working self-host setup into a hard failure
- retry once on the two failure classes the model controls, feeding the
concrete error back: a schema violation and an unparseable tool call.
Provider HTTP errors, bad config and unsupported providers surface
immediately rather than paying for a second call
- treat the call timeout as the budget for the whole operation, so a retry
cannot widen the request envelope
- report the successful attempt's tokens only, since the billing surface
prices off them and the retry exists because our own call was unreliable;
`calls` keeps the retry visible, and crw_structured_retries_total is added
because a retried attempt never reaches usage telemetry
- set `parallel_tool_calls: false`; only the first tool call is consumed, so
dropping calls 2..n would look exactly like the partial extraction being
fixed here
- instruct the model to use null rather than guess: block detection only
catches walls, not thin pages, and a forced call on a sparse page would
otherwise fabricate values that validate cleanly
Measured against the live provider with the account's own schema and urls:
89 of 89 jobs complete, against 7 of 34 for the request shape shipped today.
Both retry classes fired during those runs and both recovered.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The chat-completions transports sent the caller's JSON schema as a suggestion: no
tool_choice, no enforcement. The model was therefore free to return a tool call that violated the schema, and did.On one account, 71 of 84
/v1/extractfailures were a tool call carrying only the injectedbasisobject. Lifting the basis out left{}, which failed validation with every required property missing:That error is our validator's output, produced after the scrape and the LLM call were both paid for. There was no retry and no partial result. Credits were refunded, so the customer was not overcharged, but they got 84 failed calls and we ate the provider spend.
A contributing trigger:
serde_jsonmaps areBTreeMap, so every schema serialises alphabetically and the injectedbasisproperty lands FIRST. The model follows schema order, writes the evidence, and stops.Changes
tool_choiceon the OpenAI and Anthropic transports.responses.rsalready did this; the chat path never got it. Gated on a caller schema being present: a prompt-only extraction has no validation behind it, so forcing there would trade a loud failure for an invented object.base_urlis supported config, and the "model answered in prose" fallback is only reachable on a 200. A gateway that does not accept the object form must not turn a working self-host setup into a hard failure.callskeeps the retry visible, andcrw_structured_retries_totalis added because a retried attempt never reaches usage telemetry.parallel_tool_calls: false— only the first tool call is consumed, so dropping calls 2..n would look exactly like the partial extraction being fixed.classify_blockreturnsNoneunconditionally once the markdown clears ~100 bytes), and a forced call on a sparse page would otherwise fabricate values that validate cleanly.judge.rsneeded no change of its own: it imports the same three transports.strict: truewas evaluated and rejected. The Azure deployment we run does not enforce it; on real OpenAI it would 400 on schemas usingformat/maxLength(this account's schema uses"format": "uri"); and the injected basis leaf is"value": {}, polymorphic by design and structurally incompatible with strict mode. Making it work would mean rewriting the caller's schema, which silently turns every optional field mandatory.Verification
Release-built and run against the live provider with the account's own schema and
basis: true, across 10 urls, each job polled to a terminal state:Baseline for the same schema and urls: 7/34 (21%). The account's own 30-day history: 35/119 (29%). Engine-side extraction-failure count across all 89 jobs: zero.
Both retry classes fired during those runs and both recovered — without them those two jobs are 502s.
Also captured the real wire request behind a recording proxy:
tool_choicenames the tool that was offered,parallel_tool_calls: false, grounding clause present, nostrictkey.Completion tokens across the forced runs peaked at 1,355 against the 4,096 cap, so there is no truncation cliff for this schema shape.
Compatibility
tool_choiceis request-side to the provider./firecrawl/v2extract goes through the same job path and behaves identically.tokensUsedreflects the successful attempt;creditsUsedderives from page count and is unchanged.Not verified
tool_choiceshape, response envelope and text fallback all differ. Unit tests prove the field is on the wire; nothing proves the provider accepts it in practice.tool_choiceis rejected by Anthropic when manual extended thinking is enabled. Nothing wiresthinkingintoLlmConfigtoday, so the forcing is unconditional; if that changes, it has to become conditional with it. Noted at the field.Tests
11 new tests: forced
tool_choicepresent on both chat transports;tool_choicenames the tool that was offered (a literal there would 400 every judge call); 4xx falls back to unforced and succeeds; a schema violation retries once with the errors fed back; a second violation is returned rather than looped; an unparseable tool call retries; a provider 5xx does not; prompt-only never forces and never retries; the grounding clause is present. The billing decision is asserted in the same test as the retry (one leg's tokens,calls == 2), so a future change back to summing both attempts fails a test instead of a customer's bill.