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 jsonimport osfrom openai import OpenAIclient = 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 atrailing newline. Repro: create a 10-row export, open it, count 9. Happens on2.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_callsif 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 osfrom anthropic import Anthropicclient = 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 jsonimport osfrom openai import OpenAIclient = 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 Literalfrom pydantic import BaseModel, ValidationErrorclass 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.