Skip to content

fix(extract): enforce structured output on the chat transports - #479

Merged
us merged 1 commit into
mainfrom
fix/extract-structured-output-enforcement
Aug 27, 2026
Merged

fix(extract): enforce structured output on the chat transports#479
us merged 1 commit into
mainfrom
fix/extract-structured-output-enforcement

Conversation

@us

@us us commented Aug 27, 2026

Copy link
Copy Markdown
Owner

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/extract failures were a tool call carrying only the injected basis object. Lifting the basis out left {}, which failed validation with every required property missing:

Extraction error: LLM output failed schema validation:
"company_name" is a required property
... (all 10 top-level properties)

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_json maps are BTreeMap, so every schema serialises alphabetically and the injected basis property lands FIRST. The model follows schema order, writes the evidence, and stops.

Changes

  • Force tool_choice on the OpenAI and Anthropic transports. responses.rs already 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.
  • Drop the force and retry once on a 4xx. An arbitrary base_url is 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.
  • One bounded retry on the two failure classes the model controls: a schema violation and an unparseable tool call, with the concrete error fed back. Provider HTTP errors, bad config and unsupported providers surface immediately rather than paying for a second call.
  • The call timeout becomes the budget for the whole operation (measure elapsed, give the retry what is left, skip it when too little remains), so a retry cannot widen the request envelope.
  • Report the successful attempt's tokens only. The billing surface prices off these counts; the retry exists because our own call was unreliable, so the customer does not pay for it. calls keeps the retry visible, and crw_structured_retries_total is 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.
  • Grounding clause in the instruction. Block detection catches walls, not thin pages (classify_block returns None unconditionally once the markdown clears ~100 bytes), and a forced call on a sparse page would otherwise fabricate values that validate cleanly.

judge.rs needed no change of its own: it imports the same three transports.

strict: true was evaluated and rejected. The Azure deployment we run does not enforce it; on real OpenAI it would 400 on schemas using format/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:

build jobs complete retries fired
forced tool_choice + schema retry 49 49 (100%) 1, recovered
+ unparseable-tool-call retry 40 40 (100%) 1, recovered

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_choice names the tool that was offered, parallel_tool_calls: false, grounding clause present, no strict key.

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

  • No response shape, error code or OpenAPI change. tool_choice is request-side to the provider.
  • /firecrawl/v2 extract goes through the same job path and behaves identically.
  • tokensUsed reflects the successful attempt; creditsUsed derives from page count and is unchanged.

Not verified

  • Anthropic is changed at n=0. Every measured run went through the OpenAI-compatible transport against one gateway. The Anthropic tool_choice shape, response envelope and text fallback all differ. Unit tests prove the field is on the wire; nothing proves the provider accepts it in practice.
  • Real OpenAI and DeepSeek native are unmeasured (identical wire shape to what was measured).
  • Forced tool_choice is rejected by Anthropic when manual extended thinking is enabled. Nothing wires thinking into LlmConfig today, 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_choice present on both chat transports; tool_choice names 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.

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.
@us
us merged commit aeb55ca into main Aug 27, 2026
11 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 27, 2026
@us
us deleted the fix/extract-structured-output-enforcement branch August 27, 2026 19:33
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant