Skip to content

fix: make structured output work with reasoning models and empty responses - #116

Merged
jsntsay merged 1 commit into
AgentToolkit:mainfrom
OsherElhadad:fix/empty-response-retry-and-reasoning-content
Jul 31, 2026
Merged

jsntsay merged 1 commit into
AgentToolkit:mainfrom
OsherElhadad:fix/empty-response-retry-and-reasoning-content

Conversation

@OsherElhadad

Copy link
Copy Markdown
Contributor

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: error on every request against watsonx openai/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_model was lossy

Nested object properties collapsed to a bare dict, array items to a bare list, and minimum / maximum / enum / additionalProperties were 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_appropriateness schema, every failure was an enum violation inside an array item:

INVALID @['correction', 'reason_types', 1]: 'Incorrect tool selection' is not one of ['IRRELEVANT_FUNCTION', ...]

enum is now a real Literal type, 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()`;
You must use `chat.completions.parse()` instead

Both clients register create, not parse. Rather than rewire the method configs, the schema is rendered as the equivalent {"type": "json_schema", ..., "strict": true} dict through a new _render_native_schema hook — 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 _generate call sat outside the try, so the ValueError: No content or tool calls found in response raised by _parse_llm_response escaped before any handler saw it. The call is now inside the try, and ValueError is caught alongside OutputValidationError.

Two related failure modes were found while verifying this and are fixed with it:

  • Empty assistant turns poisoned the retry. The loop echoed the bad output back as an assistant message; when that output was empty, the padded conversation made the backend return empty again, burning every remaining attempt. Traced live:
    CALL 1 (1 msg)  -> content_len=2745   schema INVALID
    CALL 2 (3 msgs) -> content_len=0
    CALL 3 (5 msgs) -> content_len=0   <- assistant turn with len=0 sent
    
    An empty reply carries no mistake to correct, so it now retries the original prompt untouched.
  • Truncated replies retried with an identical budget. finish_reason='length' means the budget was too small; re-asking with the same max_tokens truncates identically. It now escalates. This is what made SPARC fail deterministically: watsonx defaults max_tokens to 1024 and SPARC's ~17k-char prompts spend it all on reasoning tokens before emitting any content.

4. Models that ignore response_format were still sent it

Chain-of-thought models such as gpt-oss on watsonx return empty content when response_format is present. The schema is now injected into the system prompt for those models instead, selected per model from litellm's own supports_response_schema 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 additionally falls back to reasoning_content when content is empty — the long-term ALTK fix asked for in rossoctl/cortex#676.

Both duplicated copies of _parse_llm_response in litellm.py were fixed; patching only one would have left the sibling client broken.

Verification against live providers

All checks use the real SPARC function_selection_appropriateness schema (nested object arrays, ["string","null"] unions, additionalProperties: false) — not a simplified stand-in. Native provider structured output is used wherever the model supports it.

Provider / model Before After
watsonx openai/gpt-oss-120b (end-to-end SPARC) decision: error, 100% of calls correct APPROVE and REJECT verdicts, 0 LLM errors, stable across repeated runs
watsonx openai/gpt-oss-120b (schema call, 10 trials) 0/9 valid 9/10 valid; the one failure is genuine backend flakiness with all retries correctly consumed
Azure gpt-4o-2024-08-06 hard TypeError native structured output valid; SPARC returns correct verdicts
anthropic/claude-haiku-4-5 via litellm valid, all required fields present

Per-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 (ValueError retried, no empty assistant turn, max_tokens escalation, capability-based fallback), and the reasoning_content fallback.

tests/core: 142 passed. ruff check, ruff format --check, mypy . (370 files), and uv lock --check all clean.

One updated assertion: test_freeform_flag_keeps_nested_objects_as_dict asserted nested objects stay dict. That encoded the very limitation causing these failures — and a nested model satisfies OpenAI strict mode better than a bare dict, which cannot emit additionalProperties: false. Renamed to test_freeform_flag_recurses_into_nested_objects.

tests/core/test_auto_from_env.py::test_selecting_watsonx fails on main as well, independent of this branch (it reads a local ~/.wca config); left untouched.

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

@jsntsay jsntsay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jsntsay
jsntsay merged commit ff01f86 into AgentToolkit:main Jul 31, 2026
9 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ValidatingLLMClient.generate_async does not retry on ValueError from _parse_llm_response

2 participants