HyphenHyphen
HomeConsole
  • Get Started
  • Connect your coding agent
  • Build with Hyphen
  • Chat UIs & automation
  • Recipes
  • Guides
  • API Reference
Streaming chatTool callingStructured JSONAgent loopHandling 429Long context
powered by Zudoku
Recipes

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.

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

Code
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))
Code
{ "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.

Same thing on the Anthropic surface

Code
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.

Code
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.

Code
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

SituationUse
Extraction, classification, typed dataTool schema. Always.
One quick object, throwaway scriptPrompt-guided plus extract_json.
Output feeds a database or an APITool schema plus Pydantic.
You were about to use response_formatDon't. It is ignored.

Related

  • Tool calling: the full two-round-trip pattern.
  • A small agent loop: tools doing real work.
  • Choosing a model: why max_tokens matters here.
Last modified on July 28, 2026
Tool callingAgent loop
On this page
  • The reliable way: a schema-shaped tool
  • Same thing on the Anthropic surface
  • The fallback: prompt-guided JSON, validated
  • Validating against a real schema
  • What to reach for
  • Related
JSON