# Structured JSON output

You want a typed object back, not prose. There are two ways to do it here and
only one of them is reliable.

:::warning[`response_format` is not a thing on the M-series]
MiniMax's OpenAI-compatible surface does not document `response_format` for the
M-series models. Sending `{"type": "json_object"}` or a `json_schema` block
does not error. It is simply ignored, and you get ordinary prose back. Do not
build on it. See MiniMax's
[OpenAI-compatible parameter reference](https://platform.minimax.io/docs/api-reference/text-openai-api)
for the parameters that are actually honoured.
:::

**Use a tool schema instead.** Tool calling is fully supported, the arguments
come back as JSON, and the JSON Schema you supply actually constrains the
shape. That is the pattern below. A prompt-guided fallback follows for the
cases where a tool call is overkill.

## The reliable way: a schema-shaped tool

Declare one function whose parameters are the object you want. Ask the model to
call it. Parse `tool_calls[0].function.arguments`.

```python
import json
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.hyphen-solution.com/v1",
    api_key=os.environ["HYPHEN_API_KEY"],
)

EXTRACT = {
    "type": "function",
    "function": {
        "name": "record_issue",
        "description": "Record a structured bug report extracted from user text.",
        "parameters": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "One-line summary"},
                "severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
                "component": {"type": "string"},
                "steps": {"type": "array", "items": {"type": "string"}},
                "affected_versions": {"type": "array", "items": {"type": "string"}},
            },
            "required": ["title", "severity", "component", "steps"],
        },
    },
}

REPORT = """
Since 2.4.1 the CSV exporter drops the last row whenever the file has a
trailing newline. Repro: create a 10-row export, open it, count 9. Happens on
2.4.1 and 2.4.2. Blocking our monthly reporting.
"""

resp = client.chat.completions.create(
    model="minimax-m3",
    messages=[
        {"role": "system", "content": "Extract the bug report by calling record_issue. Do not reply in prose."},
        {"role": "user", "content": REPORT},
    ],
    tools=[EXTRACT],
    max_tokens=4000,
)

calls = resp.choices[0].message.tool_calls
if not calls:
    raise RuntimeError("model answered in prose instead of calling the tool")

data = json.loads(calls[0].function.arguments)
print(json.dumps(data, indent=2))
```

```json
{
  "title": "CSV exporter drops last row when file ends with a newline",
  "severity": "high",
  "component": "csv-exporter",
  "steps": [
    "Create a 10-row export",
    "Open the resulting CSV",
    "Observe only 9 rows are present"
  ],
  "affected_versions": ["2.4.1", "2.4.2"]
}
```

Notes that matter:

- **`enum` works.** Use it for anything with a fixed set of values. It is the
  cheapest constraint you get.
- **The model can still decline to call the tool.** Handle the empty
  `tool_calls` case. Retrying with a blunter system prompt usually fixes it.
- **`tool_choice` beyond `"auto"` and `"none"` is ignored.** Tested 2026-07-26: both `"required"` and a named-function object were accepted and then disregarded, so a turn can always come back with no call. Handle that path.
  MiniMax documents those two values. Forcing one specific function by name is
  not documented, so do not depend on it. Instruct the model in the system
  prompt instead.
- **`max_tokens` of 4000 or more.** Reasoning runs before the arguments are
  emitted. See [Choosing a model](/choosing-a-model#the-max_tokens-gotcha).

## Same thing on the Anthropic surface

```python
import os
from anthropic import Anthropic

client = Anthropic(
    base_url="https://api.hyphen-solution.com",
    api_key=os.environ["HYPHEN_API_KEY"],
)

EXTRACT = {
    "name": "record_issue",
    "description": "Record a structured bug report extracted from user text.",
    "input_schema": {
        "type": "object",
        "properties": {
            "title": {"type": "string"},
            "severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
            "component": {"type": "string"},
            "steps": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["title", "severity", "component", "steps"],
    },
}

resp = client.messages.create(
    model="minimax-m3",
    max_tokens=4000,
    system="Extract the bug report by calling record_issue. Do not reply in prose.",
    tools=[EXTRACT],
    messages=[{"role": "user", "content": "The CSV exporter drops the last row on 2.4.1."}],
)

data = next(b.input for b in resp.content if b.type == "tool_use")
print(data)
```

`block.input` is already a parsed dict here. No `json.loads` needed.

## The fallback: prompt-guided JSON, validated

Sometimes a tool call is more machinery than the job deserves. Ask for JSON in
the prompt, then extract and validate it yourself. Assume the model will wrap it
in a fenced code block at least some of the time.

```python
import json
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.hyphen-solution.com/v1",
    api_key=os.environ["HYPHEN_API_KEY"],
)

SCHEMA_HINT = """Reply with a single JSON object and nothing else. Shape:
{"title": string, "severity": "low"|"medium"|"high"|"critical", "tags": string[]}"""


def extract_json(text: str) -> dict:
    """Pull the JSON object out of a model reply, fenced or not.

    Slicing from the first brace to the last one strips any code fence, any
    preamble, and any trailing chatter without needing to match the fence.
    """
    start = text.find("{")
    end = text.rfind("}")
    if start == -1 or end <= start:
        raise ValueError(f"no JSON object in reply: {text[:200]!r}")
    return json.loads(text[start : end + 1])


def ask_for_json(prompt: str, attempts: int = 3) -> dict:
    messages = [
        {"role": "system", "content": SCHEMA_HINT},
        {"role": "user", "content": prompt},
    ]
    for _ in range(attempts):
        resp = client.chat.completions.create(
            model="minimax-m3",
            messages=messages,
            max_tokens=2000,
            temperature=0,
        )
        reply = resp.choices[0].message.content or ""
        try:
            return extract_json(reply)
        except (ValueError, json.JSONDecodeError) as err:
            messages.append({"role": "assistant", "content": reply})
            messages.append(
                {"role": "user", "content": f"That was not valid JSON ({err}). Reply with only the JSON object."}
            )
    raise RuntimeError("model never produced valid JSON")


print(ask_for_json("The CSV exporter drops the last row on 2.4.1. Tag it."))
```

Two things make this work: `temperature=0`, and feeding the parse error back so
the model can fix its own output. Without the retry loop you will see a failure
occasionally.

## Validating against a real schema

Neither approach guarantees the object matches your types. If correctness
matters, validate. Pydantic is the least effort in Python.

```python
from typing import Literal
from pydantic import BaseModel, ValidationError


class Issue(BaseModel):
    title: str
    severity: Literal["low", "medium", "high", "critical"]
    component: str
    steps: list[str]


try:
    issue = Issue.model_validate(data)
except ValidationError as err:
    # Feed err back to the model as a user turn and ask it to correct the object.
    print(err)
```

The same retry-with-the-error trick from the fallback section works here. Send
the validation error back as a user message and the model usually repairs it in
one round.

## What to reach for

| Situation                              | Use                                |
| -------------------------------------- | ---------------------------------- |
| Extraction, classification, typed data | Tool schema. Always.               |
| One quick object, throwaway script     | Prompt-guided plus `extract_json`. |
| Output feeds a database or an API      | Tool schema plus Pydantic.         |
| You were about to use `response_format`| Don't. It is ignored.              |

## Related

- [Tool calling](/recipes/tool-calling): the full two-round-trip pattern.
- [A small agent loop](/recipes/agent-loop): tools doing real work.
- [Choosing a model](/choosing-a-model): why `max_tokens` matters here.
