Structured output: JSON and JSON Schema
Get valid JSON out of any model with response_format json_object or json_schema, in Python and Node, plus the Persian-text pitfalls that break parsers.
Updated: September 7, 2026
Once the model's output is going straight into your code — filling a form, pulling fields off an invoice, classifying a ticket — free text is not good enough. response_format tells the model to return JSON and nothing else, and in json_schema mode it pins down the exact shape as well. uttapen passes the field through untouched.
Which models support it
The json badge on the models page means the model understands response_format. In GET /v1/models the equivalent is "response_format" in supported_parameters, plus "structured_outputs" for strict schema enforcement. A model without structured_outputs may reject json_schema with a 400, or silently downgrade to plain JSON mode.
json_object mode
The simplest form: the model guarantees the output is valid JSON, and you describe the shape in the prompt yourself.
import json
from openai import OpenAI
client = OpenAI(base_url="https://api.uttapen.ir/v1", api_key="sk-up-...")
resp = client.chat.completions.create(
model="openai/gpt-5-mini",
messages=[
{"role": "system", "content": "Return JSON only, with the keys name, city and phone. Use null for anything you cannot determine."},
{"role": "user", "content": "Ali Rezaei from Isfahan, phone 09131234567"},
],
response_format={"type": "json_object"},
)
data = json.loads(resp.choices[0].message.content)
print(data["city"])
Providers enforce one rule here: the word "JSON" must appear somewhere in the messages, or the request is rejected. The system prompt above already satisfies it.
json_schema mode
You supply a JSON Schema and the model conforms its output to it exactly. With strict: true, no field is ever added or dropped.
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"city": {"type": ["string", "null"]},
"phone": {"type": ["string", "null"], "description": "latin digits, no spaces"},
"intent": {"type": "string", "enum": ["order", "complaint", "question"]},
},
"required": ["name", "city", "phone", "intent"],
"additionalProperties": False,
}
resp = client.chat.completions.create(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "Ali Rezaei from Isfahan called, unhappy about a late order. 09131234567"}],
response_format={
"type": "json_schema",
"json_schema": {"name": "contact", "strict": True, "schema": schema},
},
)
contact = json.loads(resp.choices[0].message.content)
In strict mode every property has to be listed in required and additionalProperties: false is mandatory, so an "optional" field is expressed as the type ["string", "null"]. If you use the Python SDK with Pydantic, client.chat.completions.parse(..., response_format=ContactModel) builds that schema from the class for you and hands back the object in resp.choices[0].message.parsed.
Node.js with Zod
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const Contact = z.object({
name: z.string(),
city: z.string().nullable(),
intent: z.enum(["order", "complaint", "question"]),
});
const client = new OpenAI({ baseURL: "https://api.uttapen.ir/v1", apiKey: process.env.UTTAPEN_API_KEY });
const resp = await client.chat.completions.parse({
model: "openai/gpt-5-mini",
messages: [{ role: "user", content: "Ali Rezaei from Isfahan, asking about the warranty" }],
response_format: zodResponseFormat(Contact, "contact"),
});
console.log(resp.choices[0].message.parsed);
Common pitfalls with Persian text
- Persian digits. Models happily return
phonewritten in Persian-Indic digits (U+06F0-U+06F9), which is meaningless to yourint()or your regex. Ask for latin digits in the field'sdescription, and normalise in code anyway: map U+06F0–U+06F9 (Persian) and U+0660–U+0669 (Arabic-Indic) onto 0–9. - Numbers as strings. An amount like 1,250,000 toman tends to come back quoted. Declare the field as
numberin the schema and tell the model not to use thousands separators. - Unicode escapes. Some models emit Persian text as escaped code points (
"\u0639\u0644\u06cc") rather than literal characters. That is still perfectly valid JSON andjson.loadsdecodes it correctly — it is only ugly in raw logs. Nothing to fix. - Invisible characters. ZWNJ (U+200C), which Persian uses inside compound words, and the directional marks (U+200F and friends) end up inside string values and quietly break exact comparisons. Before matching against fixed values such as a city name, either constrain the field with
enumor strip those code points. - Arabic yeh and kaf. U+064A and U+0643 are extremely common in user input and come back in model output where the Persian U+06CC and U+06A9 are expected. If you look these values up in a database, normalise both sides first.
- Text around the JSON. In
json_objectmode some models wrap the object in a```jsonfence.json_schemamode does not have this problem. If you are stuck with a model that does, slice from the first{to the last}.
When the model has no json_schema
With tool calling and a forced tool_choice, the function arguments are in practice JSON matching your schema. For open-weight models that list tools but not structured_outputs, this is far more reliable than asking nicely in the prompt.
Cost
The schema is not part of the prompt, but providers usually apply it as a grammar constraint on the decoder, and some are slightly slower on the first call because of it. JSON output is more compact than prose and burns fewer tokens. Set max_tokens to fit your largest plausible output: truncated JSON (finish_reason: "length") cannot be parsed, and you still paid for it.