fix: make native structured output work with strict providers (#118, #119, #120) - #121
Merged
jsntsay merged 5 commits intoAug 13, 2026
Conversation
added 5 commits
August 12, 2026 11:28
Three independent defects made ALTK's native ``response_format`` path unusable against strict providers (AWS Bedrock, Azure OpenAI), including for its own SPARC metric schemas. Fixes AgentToolkit#118 — numeric bounds on the wire schema. ``_CONSTRAINT_ARGS`` mapped ``minimum``/``maximum`` onto the generated Pydantic ``Field``, so every SPARC metric emitted ``{"type": "integer", "minimum": 1, "maximum": 5}`` and Bedrock rejected it ("For 'integer' type, properties maximum, minimum are not supported"). That model is *only* ever the wire schema — ``_validate`` runs ``jsonschema`` against the original dict — so the bounds bought no strictness and are no longer carried over. String/array constraints still are. Fixes AgentToolkit#119 — the capability gate failed open. ``supports_response_schema`` returns ``False`` for a model litellm has no metadata for, so the follow-up ``get_model_info`` probe raised and ``except Exception: return True`` sent ``response_format`` to models nobody has data about, meaning AgentToolkit#116's prompt fallback never engaged for ``watsonx/gpt-oss-120b`` or ``mistral-large-2512``. Unknown now routes to the prompt-based path, which works everywhere. Because gateway/proxy model strings are usually unknown yet often do honor the kwarg, a tri-state ``native_structured_output`` knob overrides the probe. Fixes AgentToolkit#120 — ``extra="forbid"`` reached only the outermost model, so nested ``$defs`` rendered with no ``additionalProperties`` at all and free-form objects rendered ``additionalProperties: true``; both are rejected by strict schema validation. Forbid is now the default, opted out of only by an explicit ``additionalProperties: true``. ``BaseValidatingOpenAIClient`` defaults to ``free_form_object_as_str=True``, the only shape ``strict: True`` accepts for a free-form object, and ``relax_freeform_object_schema`` now recurses so nested stringified objects still validate (it only walked the top level, which left the existing knob broken for SPARC's nested correction fields). Also strips the validation knobs from the kwargs LiteLLM replays on every completion call. Passing one to the constructor previously put it on the wire ("Unrecognized request arguments supplied: free_form_object_as_str"). Verified against real providers with the seven SPARC runtime metric schemas. Bedrock ``aws/claude-haiku-4-5`` and Azure ``gpt-4o`` through an OpenAI-compatible LiteLLM proxy went from 0/35 to 32/35 calls succeeding, and end-to-end ``SPARCReflectionComponent`` reflection produced the correct approve/reject decision on 98/98 cases across seven provider configurations (sync and async) with zero errors. Signed-off-by: Osher Elhadad <Osher.Elhadad@ibm.com>
Follow-up to the AgentToolkit#118/AgentToolkit#119/AgentToolkit#120 fixes, from measuring requests-per-call against 13 real models. Every model that reaches the native path needs exactly one request per call; all measured retry overhead was on the prompt-based path. So the cheapest schema is the one the provider enforces, and these changes reach that path more often. Bounded integers ride the wire as an ``enum``. Dropping ``minimum``/``maximum`` (AgentToolkit#118) stopped Bedrock rejecting the schema, but it also stopped the provider enforcing the range — ``watsonx/openai/gpt-oss-120b`` promptly returned 0 for a 1-5 score and paid a retry. Strict providers reject those two keywords on an integer yet accept ``enum``, so a narrow integer range is now enumerated: verified accepted by Bedrock, Azure, and watsonx, and the existing ``enum`` -> ``Literal`` conversion already carries it. Wide ranges, half-open bounds, ``number``, and ``boolean`` are untouched. Unknown litellm models attempt native again, but safely. AgentToolkit#119's fix routed every unknown model to the prompt path; measurement showed that gives up too much — ``watsonx/mistral-large-2512``, the very model from the issue, honours native on all seven SPARC schemas (7 requests for 7 calls), and so does ``openai/aws/claude-haiku-4-5``. Native is attempted, and a provider that rejects the schema now downgrades the call in flight and latches the answer for the client's remaining calls, so a wrong guess costs one request instead of the retry budget. A model litellm *knows* to be unsupported still skips native, because those ignore ``response_format`` silently rather than erroring. Empty content under native drops the kwarg. A model that ignores ``response_format`` answers with empty content, not an error — AgentToolkit#119's reported symptom. Re-asking with the kwarg attached returns empty again, so it is dropped on the retry and the schema goes into the prompt instead. The OpenAI and Azure validating clients default ``schema_field`` to ``"response_format"``. All four defaulted to ``None``, so the providers with the strongest native support silently used prompt-based validation on every call. Pass ``schema_field=None`` for the old behaviour. Only a client-side error naming a schema concern counts as a rejection, so a 500 or a rate limit stays retryable and cannot silently disable native output. Measured over the seven shipped SPARC metric schemas: 87 of 139 requests now take the native path, and every native model runs at one request per call (Bedrock haiku/sonnet, Azure gpt-4o via proxy and via the direct SDK, watsonx mistral-large-2512 and llama-4-maverick: 7 requests for 7 calls each). Azure's direct SDK went from never using native to 7/7. Separately, 9 providers x 5 non-SPARC schemas (nested Pydantic models, arrays of objects, deep nesting, enums, nullable fields, free-form objects) pass 45/45, and end-to-end SPARC reflection still returns the correct approve/reject decision on every case. Signed-off-by: Osher Elhadad <Osher.Elhadad@ibm.com>
Measured follow-up. litellm's capability map turned out not to be a reliable reason to skip the native path, so the gate no longer consults it and the litellm override — now identical to the base class — is deleted. Comparing prompt-based against forced-native over the seven SPARC metric schemas, twice each, on the watsonx models litellm reports as *unsupported*: ibm/granite-4-h-small 1/14 prompt 13/14 native meta-llama/llama-3-3-70b-instruct 14/14 prompt 14/14 native mistralai/mistral-medium-2505 14/14 prompt 14/14 native openai/gpt-oss-120b 14/14 prompt 14/14 native mistralai/mistral-small-3-1-24b-...-2503 14/14 prompt 14/14 native Native is never worse and once dramatically better, so trusting the negative verdict cost correctness for nothing. Counting requests per successful call over the same schemas showed why it also costs latency: every model on the native path needs exactly one request per call, and all measured retry overhead came from prompt-based models. Native is now attempted for every model, which is safe because a wrong guess is self-correcting and bounded — a provider that refuses the schema, or a model that answers a native request with empty content, downgrades that call and latches the answer for the client's remaining calls. That costs one request once, not per call. ``native_structured_output=False`` skips the attempt for a model known to waste it. Nine providers x five non-SPARC schemas (nested Pydantic models, arrays of objects, deep nesting, enums, nullable fields, free-form objects) pass 45/45, with all nine now on the native path where seven were before. End-to-end SPARC reflection still returns the correct approve/reject decision on every case for every provider except one reasoning model, where the wasted native attempt is absorbed by the downgrade. Signed-off-by: Osher Elhadad <Osher.Elhadad@ibm.com>
Reverts the previous commit's "attempt native for every model" while keeping
everything else. Measuring the whole fleet, rather than only the models the
earlier comparison covered, showed the change made three watsonx models worse:
mistralai/mistral-small-3-1-24b-instruct-2503 7/7 -> 1/7
meta-llama/llama-3-3-70b-instruct 6/7 -> 3/7
ibm/granite-4-h-small (7 fail) -> 4/7
The cause is visible in the raw reply: under native constrained decoding these
smaller models emit thousands of whitespace-only lines before finishing the
object, so the JSON is truncated at the token limit ("Expecting ',' delimiter:
line 7695"). That is not a schema rejection and not an empty response, so
neither downgrade path catches it, and the retries are spent re-truncating.
So litellm's negative verdict is imperfect but worth trusting: some
known-negative models do prefer native (granite-4-h-small: 1/14 prompt-based
vs 13/14 native), which makes this a per-deployment trade-off rather than a
rule. ``native_structured_output=True`` opts a measured model in.
Unknown models are still attempted natively — that is the AgentToolkit#119 change, and it
is what keeps watsonx/mistral-large-2512 and the gateway/proxy models on the
one-request-per-call path.
Signed-off-by: Osher Elhadad <Osher.Elhadad@ibm.com>
The native/prompt decision now materially affects both cost and correctness on SPARC's schemas, and the default cannot be right for every deployment, so the knobs that override it need to be findable. Records the measured reason to prefer native (one request per call versus retries), the two cases where the default guesses wrong in either direction, and the strict-provider pairing with free_form_object_as_str. Signed-off-by: Osher Elhadad <Osher.Elhadad@ibm.com>
jsntsay
approved these changes
Aug 13, 2026
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Three independent defects, all reported against
0.11.0, made ALTK's nativeresponse_formatpath unusable against strict structured-output providers — including for ALTK's own SPARC metric schemas. Fixed together because they sit on one code path (json_schema_to_pydantic_model→_render_native_schema→ the wire) and any one alone still leaves SPARC failing on Bedrock and Azure.Closes #118, closes #119, closes #120.
Thanks @vz-ibm — the issues were unusually precise, and every root cause and line reference in them held up against the code. Two of the "possible directions" turned out to need adjusting once measured against real providers; that is called out below.
What was wrong, and what changed
#118 — numeric bounds on the wire schema
_CONSTRAINT_ARGSmappedminimum → ge/maximum → leonto the generated PydanticField, so all seven SPARC metrics emitted{"type": "integer", "minimum": 1, "maximum": 5}and Bedrock rejected the request: "For 'integer' type, properties maximum, minimum are not supported".The issue framed this as a genuine tension — the same keywords that keep native output faithful are the ones Bedrock refuses. There is no tension: the generated model is referenced only as the wire value, while
_validaterunsjsonschemaagainst the original schema dict, so the bounds bought zero local strictness.But dropping them outright had a cost that only showed up under load: the provider stopped enforcing the range too, and
watsonx/openai/gpt-oss-120bpromptly returned0for a 1-5 score, paying a retry. So a narrowly bounded integer is now emitted as anenuminstead — strict providers rejectminimum/maximumon an integer but acceptenum, verified against Bedrock, Azure, and watsonx. The range is enforced by the provider again, with no rejected keyword and no retry. Wide ranges, half-open bounds,number, andbooleanare untouched;minLength/maxLength/minItems/patternstill survive as before.#119 — the capability gate failed open
Worth recording why the
exceptbranch was reached at all:litellm.supports_response_schemadoes not raise for an unknown model, it returnsFalse. Theget_model_infoprobe that follows is what raised — so "unknown" landed onreturn Trueandresponse_formatwent to models nobody has capability data for.The issue proposed routing unknown to the prompt-based path. Measurement said otherwise:
watsonx/mistral-large-2512— the model named in this very issue, and unknown to litellm — honours native output on all seven SPARC schemas at one request per call, as doopenai/aws/claude-haiku-4-5and gateway/proxy strings generally. Since a schema the provider enforces costs one request while one enforced by re-asking costs several, unknown now attempts native.That is safe because a wrong guess is self-correcting and bounded: a provider that refuses the schema, or a model that answers a native request with empty content (#119's reported symptom — an ignored
response_formatproduces no error, only emptiness), downgrades that call in flight and latches the answer for the client's remaining calls. One request, once — not the retry budget, and not per call. Only a client-side error naming a schema concern counts as a rejection, so a 500 or a rate limit stays retryable and cannot silently disable native output.I did try going further and ignoring litellm's capability map entirely — on the five watsonx models it reports as unsupported, forced-native scored no worse and once far better (
ibm/granite-4-h-small: 1/14 prompt-based vs 13/14 native). Measuring the whole fleet reversed that: under native constrained decoding, the smaller models emit thousands of whitespace-only lines and truncate at the token limit (Expecting ',' delimiter: line 7695), droppingmistral-small-3-1-24bfrom 7/7 to 1/7 andllama-3-3-70bfrom 6/7 to 3/7. That is neither a rejection nor an empty response, so no downgrade catches it. The known-negative verdict is therefore still trusted, and the commit that removed it is reverted in this branch — the history keeps both measurements.It remains imperfect in the other direction:
ibm/granite-4-h-smallscores 1/21 prompt-based vs 21/21 native, and the default gets that model wrong. So the tri-statenative_structured_outputknob (Noneauto /True/False) exists for both directions, settable per client or via the existing chainableconfigure_validation, and the SPARC README now documents when to reach for it.#120 —
additionalPropertieslost in conversionTwo causes, exactly as diagnosed:
extra="forbid"was gated on the sub-schema repeatingadditionalProperties: false, which nested SPARC sub-schemas never do — so nested$defsrendered with the key absent. Forbid is now the default, opted out of only by an explicitadditionalProperties: true. Inverting the default is both smaller than threading the parent decision through the recursion and more correct, since threading still gets the wrong answer for a root schema that omits the keyword.dict→additionalProperties: true.BaseValidatingOpenAIClientnow defaultsfree_form_object_as_str = True; withstrict: Truethat is the only shape the API accepts for a free-form object. The alternative of emitting a closed empty object was rejected deliberately — it rendersproperties: {}, additionalProperties: false, so the model can only ever return{}forcorrection.corrected_value/tool_call.arguments, and that passes validation, silently discarding the entire correction payload.A prerequisite bug surfaced here:
relax_freeform_object_schemaonly walked top-levelproperties, so nested free-form props were never widened. Sincejson_schema_to_pydantic_modelstringifies free-form objects at any depth, the existingfree_form_object_as_strpath was already broken for SPARC's nested correction fields — a reply withcorrected_value: '{"p": 1}'failed validation with'{"p": 1}' is not of type 'object'. Making it recurse is part of this PR; without it, change 2 would have exposed the breakage.Also fixed: two ways native output was being skipped or broken silently
Validation knobs leaked onto the wire.
LiteLLMClientOutputVal.__init__stashed alllite_kwargsin_lite_kwargsand splatted them into everylitellm.completioncall, so passing a knob to the constructor put it on the wire:Unrecognized request arguments supplied: free_form_object_as_str, native_structured_output. Pre-existing for the other knobs too; they are now stripped via a sharedVALIDATION_KWARGS.The OpenAI and Azure validating clients never used native output. All four defaulted
schema_field=None, so the providers with the strongest native support silently validated by prompt on every call. They now default to"response_format"; passschema_field=Nonefor the old behaviour. This alone took the direct Azure SDK from never using native to 7/7 native at one request per call.Verification against real providers
Every number below is from real API calls, not mocks. Credentials: watsonx, Azure OpenAI (direct SDK), and an OpenAI-compatible LiteLLM proxy fronting Bedrock, Azure, and GCP.
Are there any remaining
response_format/ schema-validation errors?Census over the seven SPARC schemas, at SPARC's real default
retries=3, repeated three times per metric, classifying each failure:REQUEST_REJECTED(provider refused our schema — the #118/#119/#120 class)OUTPUT_INVALID(request accepted; model content failed after all retries)All four were on the prompt-based path and all on one metric (
general_value_format_alignment, an out-of-enumissue_typesuch asMISSING_INFORMATION). Native-strict was 84/84 clean. That is what motivated the native-first work above.Requests per successful call
Counting actual HTTP requests, ideal = 1 per call:
aws/claude-haiku-4-5aws/claude-sonnet-4-5gpt-4o(proxy)gpt-4.1(proxy)gpt-4o(direct SDK)mistral-large-2512llama-4-maverick-17baws/gpt-oss-120bopenai/gpt-oss-120bmistral-medium-2505mistral-small-3-1-24bgcp/gemini-3-flash-previewllama-3-3-70bibm/granite-4-h-smallTotals: 90/98 succeeded in 128 requests (ideal 98). Seven of the fourteen models run at exactly one request per call, all of them on the native path.
granite-4-h-smallis the known-negative model that wants native (native_structured_output=Truetakes it to 21/21); the rest of the overhead is content retries on weaker models.For comparison, the two policies I measured and rejected: ignoring litellm's capability map scored 84/98 in 135 requests, and the original known-negative-plus-unknown-skip scored 87/98 in 139. The shipped policy is both the most correct and the cheapest of the three.
Wire-level, same schemas, before vs after
aws/claude-haiku-4-5(proxy)gpt-4o(proxy)gpt-4o(direct SDK)json_schemaGateway total: 0/35 → 32/35. Forcing the old free-form behaviour back on the direct Azure SDK reproduces the issue's error text verbatim, which pins #120 from both sides:
End-to-end
SPARCReflectionComponentFull static + semantic pipeline over 14 hand-built cases — 5 that must be approved, 9 that must be rejected (wrong city, unit contradicting a stated preference, wrong function for the intent, reversed flight direction, out-of-range value, missing required parameter, wrong type, hallucinated parameter name, non-E.164 phone number, swapped currency codes). Correct decisions, with the expected metric named each time:
aws/claude-haiku-4-5native strictgpt-4o(proxy) native strictgpt-4o(proxy) native strictaws/claude-haiku-4-5auto-detectgpt-4odirect SDKgpt-4odirect SDKmistral-large-2512mistral-large-2512native overrideopenai/gpt-oss-120b(reasoning)125/126 correct approve/reject decisions across 9 provider configurations, 0 errors. The single miss is
Azure/gpt-4oin sync mode disagreeing on one approve case — a model judgment call on a defensible tool call, not a schema or transport failure; the same configuration scores 14/14 in async.Non-SPARC schemas
Because SPARC's metrics are one schema family, the same providers were run against five unrelated shapes: a flat Pydantic model, a nested Pydantic model containing an array of sub-models, JSON Schema with
enum+ bounded integer, four-level-deep nesting with arrays of objects, and nullable + free-form-object fields.45/45 pass across 9 providers × 5 schemas, all 9 on the native path.
Tests
make test: 402 passed, 8 skipped (baseline measured onmainbefore these changes: 359 passed, 8 skipped — +43 net new tests, no regressions).ruff check,ruff format, andmypyclean; all pre-commit hooks pass including detect-secrets.Three existing tests asserted behaviour these changes deliberately reverse, and were rewritten with their rationale rather than deleted:
test_numeric_bounds_survive→test_numeric_bounds_stripped_from_wire_schematest_unknown_model_assumes_native_support→test_native_is_attempted_when_not_known_unsupportedtest_watsonx_capability_is_per_model→ split into that test plustest_known_unsupported_skips_native, which also asserts the opt-in overrideNew coverage, including the regression test #120 proposed:
additionalProperties: true, zero missingadditionalPropertieson object schemas, and zero numeric bounds — parametrized per metric, so a failure names the metric.enum; wide ranges, half-open bounds,number, andbooleando not; out-of-range values still rejected byjsonschema.$defsforbid extras without repeating the keyword; explicitadditionalProperties: truestill honoured.relax_freeform_object_schemawidens at depth;_validateaccepts a nested JSON string and the object literal.schema_fieldto"response_format".Also ran the
-m llmsuite against real credentials: 39 passed, 6 failed — 5 ollama (no local server here) andtest_azure_openai.py::test_json_schema_validation, which fails identically on unmodifiedupstream/mainwith the same credentials (that test passesschema_field=None, so the model free-formstemperatureinstead of the requiredtemperature_c). Pre-existing and out of scope.Compatibility
Two behaviour changes worth release notes:
response_formatby default. Callers who relied on prompt-based validation there should passschema_field=None.native_structured_output=Falseopts out. Models litellm reports as unsupported are unaffected.Happy to split this into three PRs if you would rather review them separately.