Melious

Sometimes you want the model to return JSON, not prose. Two shapes — JSON mode (valid JSON, any shape) and JSON Schema (valid JSON matching a schema).

JSON mode

from openai import OpenAI
import json

client = OpenAI(
    api_key="sk-mel-<YOUR_API_KEY>",
    base_url="https://api.melious.ai/v1",
)

response = client.chat.completions.create(
    model="<MODEL_ID>",
    messages=[
        {"role": "system", "content": "Reply with JSON only."},
        {"role": "user", "content": "Pick three Hanseatic cities. Include name and modern country."},
    ],
    response_format={"type": "json_object"},
)

data = json.loads(response.choices[0].message.content)
print(data)

The system prompt matters — in JSON mode, the model needs to be told to produce JSON. Otherwise it may return an empty object or a weird shape because you didn't tell it what you wanted. "Reply with JSON only" is the minimum viable instruction.

JSON Schema

Stricter — the response is constrained to match your schema:

schema = {
    "type": "object",
    "properties": {
        "cities": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "country": {"type": "string"},
                    "founded_year": {"type": "integer"},
                },
                "required": ["name", "country", "founded_year"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["cities"],
    "additionalProperties": False,
}

response = client.chat.completions.create(
    model="<MODEL_ID>",
    messages=[
        {"role": "user", "content": "Pick three Hanseatic cities with founding years."},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "cities_list",
            "schema": schema,
            "strict": True,
        },
    },
)

Models with _meta.capabilities.json_schema == true constrain generation to your schema. Every other model rejects a json_schema request with 400 before anything runs — switch to json_object for those, or pick a model that has the capability.

What happens when the model produces not-quite-valid JSON

On non-streaming requests with json_object or json_schema, we run the model's text through a JSON repairer before returning it: trailing commas, unquoted keys, markdown fences, and a missing closing brace get fixed. Two honest limits: streams are not repaired, and when nothing is recoverable you get the original text or an empty string — never an error. Wrap your json.loads in a try and check finish_reason == "length" when it fails.

Which models support what

  • json_object mode — almost every chat model. Check _meta.capabilities.structured_output.
  • json_schema (strict) — subset of models. Check _meta.capabilities.json_schema.

Asking for a format a model doesn't support returns a 400 with type: invalid_request_error, code: model_not_capable.

Responses shape

The Responses API takes the schema in text.format, in the flat shape the OpenAI SDK's client.responses.parse() sends:

response = client.responses.create(
    model="<MODEL_ID>",
    input="Pick three Hanseatic cities with founding years.",
    text={
        "format": {
            "type": "json_schema",
            "name": "cities_list",
            "schema": schema,
            "strict": True,
        }
    },
)
data = json.loads(response.output_text)

client.responses.parse() works too — it sends this shape and parses the result for you. We forward name, schema, strict, and description as you sent them, and we still accept the nested {"type": "json_schema", "json_schema": {...}} shape from Chat Completions. {"format": {"type": "json_object"}} is JSON mode. The capability rule is the same as above: the model needs _meta.capabilities.json_schema, or you get the 400 before a stream starts.

Anthropic shape

The Messages API takes the schema in output_config.format. We translate it to the same strict json_schema mode as Chat Completions, so the model needs _meta.capabilities.json_schema:

response = client.messages.create(
    model="<MODEL_ID>",
    max_tokens=512,
    output_config={"format": {"type": "json_schema", "schema": schema}},
    messages=[{"role": "user", "content": "Pick three Hanseatic cities..."}],
)

The JSON comes back in the first text block. Only type: "json_schema" does anything; other format types are ignored. If you'd rather not depend on json_schema support, the tool-call pattern works on every model that calls tools:

client.messages.create(
    model="claude-sonnet-4",
    max_tokens=512,
    tools=[{
        "name": "record_cities",
        "description": "Record a list of Hanseatic cities",
        "input_schema": schema,
    }],
    tool_choice={"type": "tool", "name": "record_cities"},
    messages=[{"role": "user", "content": "Pick three Hanseatic cities..."}],
)

Force the model to call record_cities — the input field on the resulting tool_use block is your structured output.

Patterns that work

A few things we've seen:

  • Use structured output for extraction, not generation. "Pull fields X/Y/Z from this text" works well; "generate creative JSON with arbitrary keys" works less well.
  • additionalProperties: false matters. Without it, models sometimes invent extra keys.
  • Keep required fields tight. Every required field the model skips forces a regeneration; optional fields are free.
  • When a schema includes enums, list the options in the description. The model reads descriptions more attentively than raw schema enum lists.

Gotchas

  • max_tokens truncation is the most common failure mode. You may get back a trimmed but valid object, or unparseable text — there is no error for this. Either way, check finish_reason == "length" and raise max_tokens if needed.
  • Nested arrays of arrays confuse some models. Flatten to keyed objects where you can.

Chat completions for the full response_format field • Responses for text.format • Tool calling for the tool-based extraction pattern on Messages.

On this page