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 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.