fix: make structured output work with reasoning models and empty responses - #116
Merged
jsntsay merged 1 commit intoJul 31, 2026
Conversation
…onses
Semantic SPARC calls failed on every request against watsonx
gpt-oss-120b, and native structured output was unusable on Azure/OpenAI.
Four independent defects, all in the shared validating-client path:
1. `json_schema_to_pydantic_model` was lossy. Nested object properties
collapsed to a bare `dict`, array `items` to a bare `list`, and
`minimum`/`maximum`/`enum`/`additionalProperties` were dropped
entirely. Providers therefore received a much weaker schema than the
one the response was later validated against, so models emitted
out-of-vocabulary enum values and extra keys that then failed
validation. `enum` is now a real `Literal` so it survives inside
`items`, where a Field-level constraint cannot reach.
2. Azure/OpenAI native structured output always raised `TypeError: You
tried to pass a BaseModel class to chat.completions.create()`. Both
clients register `create`, not `parse`, so the schema is now rendered
as the equivalent `{"type": "json_schema", ..., "strict": true}` dict
via a `_render_native_schema` hook.
3. The retry loop never retried a contentless response (AgentToolkit#115). The
generate call sat outside the `try`, so the `ValueError: No content or
tool calls found in response` raised by `_parse_llm_response` escaped
immediately and the configured `retries` were never used. The call is
now inside the `try` and `ValueError` is caught alongside
`OutputValidationError`. Two related failure modes are fixed with it:
an empty reply no longer appends an empty assistant turn (several
backends answer a padded conversation with another empty response,
burning every attempt), and a reply truncated by the token limit
escalates `max_tokens` instead of re-asking with a budget already
known to be too small.
4. Models that ignore `response_format` were still sent it. Chain-of-
thought models such as gpt-oss on watsonx return empty content when it
is present, so the schema is now injected into the system prompt for
them, selected per model from litellm's own capability data rather
than a hardcoded list. A negative answer is only trusted for a model
litellm actually knows, so unknown models keep the previous behavior.
`_parse_llm_response` also falls back to `reasoning_content` when
`content` is empty, which is the long-term ALTK fix requested in
rossoctl/cortex#676 and removes the need for the downstream
monkey-patch.
Verified against live providers with the real SPARC
`function_selection_appropriateness` schema (nested object arrays,
`["string","null"]` unions, `additionalProperties: false`):
- watsonx `openai/gpt-oss-120b`: end-to-end SPARC reflection went from
`decision: error` on every call to a correct APPROVE/REJECT verdict
with zero LLM errors, stable across repeated runs.
- Azure `gpt-4o-2024-08-06`: native structured output went from a hard
`TypeError` to valid output; SPARC returns correct verdicts.
- `anthropic/claude-haiku-4-5` via litellm: valid, all required fields.
Fixes AgentToolkit#115
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
This was referenced Aug 11, 2026
jsntsay
pushed a commit
that referenced
this pull request
Aug 13, 2026
…119, #120) (#121) * fix: make native structured output work with strict providers 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 #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 #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 #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 #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> * perf: prefer native structured output so schemas cost no retries Follow-up to the #118/#119/#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`` (#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. #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 — #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> * perf: attempt native structured output for every model 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> * fix: trust litellm's known-unsupported verdict again 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 #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> * docs: document native vs prompt-based structured output for SPARC 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> --------- Signed-off-by: Osher Elhadad <Osher.Elhadad@ibm.com> Co-authored-by: Osher Elhadad <Osher.Elhadad@ibm.com>
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.
Fixes #115. Also implements the "Long-term fix (ALTK)" requested in
rossoctl/cortex#676, which lets that repo drop its downstream monkey-patch.Problem
Semantic SPARC calls returned
decision: erroron every request against watsonxopenai/gpt-oss-120b, and native structured output was entirely unusable on Azure/OpenAI. Investigating with real credentials turned up four independent defects, all in the shared validating-client path.1.
json_schema_to_pydantic_modelwas lossyNested object properties collapsed to a bare
dict, arrayitemsto a barelist, andminimum/maximum/enum/additionalPropertieswere dropped outright. Providers received a far weaker schema than the one the response was later validated against, so models legitimately emitted out-of-vocabulary enum values and extra keys — which then failed validation.Measured against the real SPARC
function_selection_appropriatenessschema, every failure was anenumviolation inside an array item:enumis now a realLiteraltype, so it survives insideitemswhere a Field-level constraint cannot reach.2. Azure/OpenAI native structured output always raised
Both clients register
create, notparse. Rather than rewire the method configs, the schema is rendered as the equivalent{"type": "json_schema", ..., "strict": true}dict through a new_render_native_schemahook — so native structured output keeps working on the method already registered.3. The retry loop never retried a contentless response (#115)
The issue proposes widening the
except, which is necessary but not sufficient: the_generatecall sat outside thetry, so theValueError: No content or tool calls found in responseraised by_parse_llm_responseescaped before any handler saw it. The call is now inside thetry, andValueErroris caught alongsideOutputValidationError.Two related failure modes were found while verifying this and are fixed with it:
finish_reason='length'means the budget was too small; re-asking with the samemax_tokenstruncates identically. It now escalates. This is what made SPARC fail deterministically: watsonx defaultsmax_tokensto 1024 and SPARC's ~17k-char prompts spend it all on reasoning tokens before emitting any content.4. Models that ignore
response_formatwere still sent itChain-of-thought models such as gpt-oss on watsonx return empty content when
response_formatis present. The schema is now injected into the system prompt for those models instead, selected per model from litellm's ownsupports_response_schemadata rather than a hardcoded list. A negative answer is only trusted for a model litellm actually knows, so unknown models keep the previous behavior._parse_llm_responseadditionally falls back toreasoning_contentwhencontentis empty — the long-term ALTK fix asked for inrossoctl/cortex#676.Both duplicated copies of
_parse_llm_responseinlitellm.pywere fixed; patching only one would have left the sibling client broken.Verification against live providers
All checks use the real SPARC
function_selection_appropriatenessschema (nested object arrays,["string","null"]unions,additionalProperties: false) — not a simplified stand-in. Native provider structured output is used wherever the model supports it.openai/gpt-oss-120b(end-to-end SPARC)decision: error, 100% of callsopenai/gpt-oss-120b(schema call, 10 trials)gpt-4o-2024-08-06TypeErroranthropic/claude-haiku-4-5via litellmPer-model routing confirmed:
watsonx/openai/gpt-oss-120b→ prompt-based,watsonx/mistralai/mistral-large→ native, Azure → native.Tests
11 new tests covering each fix: schema-fidelity (bounds, enum-in-items, nested array objects,
additionalProperties, nullable enum), retry behavior (ValueErrorretried, no empty assistant turn,max_tokensescalation, capability-based fallback), and thereasoning_contentfallback.tests/core: 142 passed.ruff check,ruff format --check,mypy .(370 files), anduv lock --checkall clean.One updated assertion:
test_freeform_flag_keeps_nested_objects_as_dictasserted nested objects staydict. That encoded the very limitation causing these failures — and a nested model satisfies OpenAI strict mode better than a baredict, which cannot emitadditionalProperties: false. Renamed totest_freeform_flag_recurses_into_nested_objects.tests/core/test_auto_from_env.py::test_selecting_watsonxfails onmainas well, independent of this branch (it reads a local~/.wcaconfig); left untouched.