- Purpose: Get a validated Pydantic object back from an Agents SDK run on any provider the SDK can route, including ones that reject strict
json_schemastructured output. - Read when: Adding or changing an agent call that sets
output_type=, or debugging aBadRequestErroraboutresponse_formaton a non-OpenAI model. - Owner:
quantmind/utils/structured_output.py(run_structured). A leaf helper; every package may import it. - Status: Current behavior. One bounded fallback — no retry loop, no provider table.
Setting output_type= makes the SDK send a strict response_format={"type": "json_schema"}. Native OpenAI routes accept it, but some LiteLLM-routed providers (DeepSeek, ...) reject that response_format outright — and they reject it at request time, before any output exists, so a parse-stage retry cannot recover. The request itself must change: drop output_type, ask for {"type": "json_object"}, and validate the raw output in code. This must stay invisible to callers, who pass only cfg.model.
run_structured(output_type, *, build_agent, run) runs one strict-first ladder:
flowchart LR
S["run_structured()"] --> A["strict: output_type → json_schema"]
A --> R1{"run(agent)"}
R1 -->|success| OK["validate → return model"]
R1 -->|BadRequestError| Q{"response_format rejected?"}
Q -->|no| RE["re-raise unchanged"]
Q -->|yes| B["fallback: json_object + schema in prompt"]
B --> R2["run → raw string"] --> OK
- Run the agent from
build_agent(False)— it carriesoutput_type=, so the SDK sends strictjson_schemaand returns a parsed model. - If that raises a
BadRequestErrorwhose message names theresponse_format/json_schema(a narrow check), run the agent frombuild_agent(True)— nooutput_type, the JSON Schema pinned into the instructions, andresponse_formatforced tojson_object— then validate the raw string locally.json_object_instructions/json_object_model_settingsbuild that agent; a fenced-JSON strip guards Markdown wrappers.
A BadRequestError unrelated to response_format is re-raised unchanged, never masked by the fallback. Incapability is discovered by the provider's own rejection, so a capable provider always keeps the stronger strict contract.
The helper lives in utils (a leaf every package may import) and takes two callbacks so it owns no runtime policy:
build_agent(json_object)— how the agent is constructed at this call site (name, instructions, tools, model settings).run(agent)— which runner executes it.flowspassesrun_with_observability;mindpasses its ownRunner.run+RunConfig.
This is why mind never imports flows: the shared ladder is in utils, and each layer injects its own runner. No new runtime module is introduced.
- No provider registry or model-name prefix table — detection is the provider's own
BadRequestError, not a static capability list. (litellm's ownsupports_response_schemais unreliable, e.g. it marksopenrouter/openai/*routes as unsupported.) - No unbounded retry — the ladder is exactly two rungs. A malformed
json_objectresult surfaces itsValidationError; it is not re-prompted in a loop. - No second, tool-less "salvage" agent and no fuzzy output repair (alias remapping, UUID regex). Strict schema on capable providers removes the need.
Offline (tests/utils/test_structured_output.py, plus the two call sites' tests): strict is the default; a simulated response_format rejection falls back to json_object and validates a fenced or bare string; an unrelated BadRequestError propagates unswallowed. Live: scripts/verify_structure_e2e.py exercises a real json_object-only provider (DeepSeek) and a strict baseline (GPT).