Skip to content

feat: schema-driven structured outputs across all LLM providers #94

@JTCorrin

Description

@JTCorrin

Summary

Add schema-driven structured outputs as a universal feature across all LLM providers. Users pass a Pydantic model OR a JSON Schema dict; Esperanto translates to each provider's native shape and returns the response normalized — including, for Pydantic input, an instantiated model on response.choices[0].message.parsed.

This complements (does not replace) the existing structured={"type": "json"} JSON-mode toggle.

API Surface

New per-call parameter on chat_complete / achat_complete

def chat_complete(
    self,
    messages: List[Dict[str, Any]],
    ...
    response_schema: Optional[Union[Type[BaseModel], Dict[str, Any]]] = None,
) -> Union[ChatCompletion, Generator[ChatCompletionChunk, None, None]]:
  • None (default): no schema constraint.
  • Type[BaseModel]: a Pydantic v2 model class. Esperanto calls Model.model_json_schema() internally, sends to the provider, parses the JSON response, and instantiates the model.
  • Dict[str, Any]: raw JSON Schema dict. Esperanto sends as-is; the response's parsed is the parsed JSON dict.

Response shape

response = model.chat_complete(messages, response_schema=Event)
event = response.choices[0].message.parsed   # Event instance (Pydantic) or dict
raw = response.choices[0].message.content    # JSON string (always populated)

parsed is added to the message type. content continues to hold the raw JSON string for backward compat.

Coexistence with existing structured flag

  • structured={"type": "json"} — instance-level JSON mode (any valid JSON). Unchanged.
  • response_schema=X — per-call schema-driven (specific shape). New.
  • Precedence: per-call response_schema overrides instance-level structured for that call only.
  • Both features remain useful and supported.

Per-Provider Translation

Each provider's request-builder converts the canonical JSON Schema dict to the provider's native shape:

Provider Native shape
OpenAI / Azure / openai-compatible response_format={"type": "json_schema", "json_schema": {"name": "<schema-name>", "strict": true, "schema": <dict>}}
Anthropic output_format={"type": "json_schema", "schema": <dict>} (per Anthropic's structured outputs API)
Google / Vertex generation_config = {"response_schema": <dict>, "response_mime_type": "application/json"} (handle Gemini-specific schema type renaming as needed)
Mistral response_format={"type": "json_schema", "json_schema": {...}}
Ollama format=<dict> (newer Ollama; works with most local models)
OpenRouter / DeepSeek / Groq / xAI / DashScope / MiniMax Same as openai-compatible (response_format json_schema). Per-profile supports_response_format honored.
Perplexity Same as openai-compatible.

A shared helper module src/esperanto/utils/structured_output.py provides:

  • pydantic_to_schema(model_or_dict) -> Dict[str, Any] — unified normalization
  • parse_structured_response(content, response_schema) -> parsed — JSON parse + optional Pydantic instantiation
  • Schema massaging for OpenAI strict mode (ensure additionalProperties: false, all fields required where Pydantic allows)

Provider request-builders call into this helper rather than reimplementing.

Output Normalization

  • Provider returns content as a JSON-encoded string.
  • parse_structured_response():
    • json.loads(content) → dict
    • If response_schema was a Pydantic class: model.model_validate(parsed_dict) → instance
    • If response_schema was a dict: return the parsed dict
  • Result attached to response.choices[0].message.parsed.

Files

New

  • src/esperanto/utils/structured_output.py — schema helpers + parser
  • tests/utils/test_structured_output.py — helper tests

Modified

  • src/esperanto/providers/llm/base.py — add response_schema to chat_complete / achat_complete signatures
  • src/esperanto/common_types/response.py (or wherever Message lives) — add parsed: Optional[Any] to message type
  • All provider implementations in src/esperanto/providers/llm/*.py — accept the new param and route through the per-provider translation
  • tests/providers/llm/test_*.py — per-provider tests for both Pydantic and dict input, both sync and async
  • docs/features/structured-output.md (NEW) or extend an existing structured-output doc

Acceptance Criteria

  • model.chat_complete(messages, response_schema=PydanticClass) returns an instance of PydanticClass on response.choices[0].message.parsed for every supported provider
  • Same call with a JSON Schema dict returns the parsed dict
  • Async equivalent works identically
  • content field continues to hold the raw JSON string (backward compat)
  • Existing structured={"type": "json"} JSON mode behavior is unchanged
  • Per-call response_schema correctly overrides instance-level structured for that call
  • Provider tests cover at least: openai, anthropic, google, mistral, ollama, openai-compatible (covers profiles)
  • Documentation includes a worked example per major provider

Caveats / Documented Limitations

  • OpenAI strict mode requires additionalProperties: false and all fields required. Some Pydantic patterns (Optional fields without defaults) need massaging. Helper handles this; document the supported subset.
  • Discriminated unions vary in support across providers. Document which work where.
  • Streaming + structured output — out of scope for v1. Document and raise a clear error if stream=True and response_schema are both set, until a follow-up issue tackles it.
  • Pydantic v1 not supported (we already require v2).

Out of Scope (Separate Issues)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    readyIssue is fully specified and ready for the development team to pick up

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions