Structured outputs
Get reliable JSON from Tase models: JSON mode with response_format, schema instructions in the system message, validation with retries, and when to use tool calling instead.
Chat is only half of what a language model is good for. The other half is turning messy human input into data your code can act on — a task with a due date, an expense with an amount and category, a form filled from a voice note. That requires the model to answer in JSON your program can parse every single time. This guide shows how to make that reliable.
JSON mode with response_format
Set response_format to { "type": "json_object" } and the model is constrained to emit syntactically valid JSON — no prose, no markdown fences, no trailing commentary.
curl https://api.tase.app/v1/chat/completions \
-H "Authorization: Bearer tase_sk_..." \
-H "Content-Type: application/json" \
-d '{
"model": "tase-8b",
"response_format": { "type": "json_object" },
"messages": [
{
"role": "system",
"content": "Extract task details from the user message. Reply with JSON only, using exactly these keys: title (string), due_date (YYYY-MM-DD or null), priority (one of: low, medium, high)."
},
{
"role": "user",
"content": "Remind me to renew my passport by August 15, it is urgent"
}
]
}'The reply arrives in the usual place, choices[0].message.content, as a JSON string ready to parse:
{"title": "Renew passport", "due_date": "2026-08-15", "priority": "high"}JSON mode guarantees syntax, not shape
json_object mode.Put the schema in the system message
The schema description is the contract. Be literal: name every key, give its type, list allowed values for enums, and say what to do when a value is unknown. A compact convention that works well:
Extract task details from the user message. Reply with JSON only,
using exactly these keys:
- title: string, a short imperative phrase
- due_date: string in YYYY-MM-DD format, or null if no date is mentioned
- priority: one of "low", "medium", "high"; default to "medium"
Do not add keys. Do not wrap the JSON in markdown.SDK examples
import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.TASE_API_KEY,
baseURL: 'https://api.tase.app/v1',
});
const completion = await client.chat.completions.create({
model: 'tase-8b',
response_format: { type: 'json_object' },
temperature: 0,
messages: [
{
role: 'system',
content:
'Extract task details from the user message. Reply with JSON only, ' +
'using exactly these keys: title (string), due_date (YYYY-MM-DD or null), ' +
'priority (one of: low, medium, high).',
},
{ role: 'user', content: 'Remind me to renew my passport by August 15, it is urgent' },
],
});
const task = JSON.parse(completion.choices[0].message.content);
console.log(task.title, task.due_date, task.priority);import json
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["TASE_API_KEY"],
base_url="https://api.tase.app/v1",
)
completion = client.chat.completions.create(
model="tase-8b",
response_format={"type": "json_object"},
temperature=0,
messages=[
{
"role": "system",
"content": (
"Extract task details from the user message. Reply with JSON only, "
"using exactly these keys: title (string), due_date (YYYY-MM-DD or null), "
"priority (one of: low, medium, high)."
),
},
{"role": "user", "content": "Remind me to renew my passport by August 15, it is urgent"},
],
)
task = json.loads(completion.choices[0].message.content)
print(task["title"], task["due_date"], task["priority"])Use temperature 0 for extraction
Validate, then retry
Treat model output like user input: parse it, check the shape, and never let unvalidated JSON reach your database. When validation fails, do not silently retry the identical request — feed the bad output and the failure back into the conversation so the model can correct itself.
import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.TASE_API_KEY,
baseURL: 'https://api.tase.app/v1',
});
const PRIORITIES = ['low', 'medium', 'high'];
function validateTask(task) {
if (typeof task.title !== 'string' || task.title.length === 0) {
throw new Error('title must be a non-empty string');
}
if (task.due_date !== null && !/^\d{4}-\d{2}-\d{2}$/.test(task.due_date)) {
throw new Error('due_date must be YYYY-MM-DD or null');
}
if (!PRIORITIES.includes(task.priority)) {
throw new Error('priority must be low, medium, or high');
}
return task;
}
async function extractTask(text, attempts = 3) {
const messages = [
{
role: 'system',
content:
'Extract task details from the user message. Reply with JSON only, ' +
'using exactly these keys: title (string), due_date (YYYY-MM-DD or null), ' +
'priority (one of: low, medium, high).',
},
{ role: 'user', content: text },
];
for (let attempt = 1; attempt <= attempts; attempt++) {
const completion = await client.chat.completions.create({
model: 'tase-8b',
response_format: { type: 'json_object' },
temperature: 0,
messages,
});
const raw = completion.choices[0].message.content;
try {
return validateTask(JSON.parse(raw));
} catch (err) {
// Feed the failure back so the next attempt can correct itself.
messages.push({ role: 'assistant', content: raw });
messages.push({
role: 'user',
content:
'That reply failed validation: ' + err.message + '. ' +
'Reply again with JSON only, matching the schema exactly.',
});
}
}
throw new Error('No valid JSON after ' + attempts + ' attempts');
}
const task = await extractTask('Remind me to renew my passport by August 15, it is urgent');
console.log(task);import json
import os
import re
from openai import OpenAI
client = OpenAI(
api_key=os.environ["TASE_API_KEY"],
base_url="https://api.tase.app/v1",
)
PRIORITIES = {"low", "medium", "high"}
SCHEMA_PROMPT = (
"Extract task details from the user message. Reply with JSON only, "
"using exactly these keys: title (string), due_date (YYYY-MM-DD or null), "
"priority (one of: low, medium, high)."
)
def validate_task(task: dict) -> dict:
if not isinstance(task.get("title"), str) or not task["title"]:
raise ValueError("title must be a non-empty string")
due = task.get("due_date")
if due is not None and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", due):
raise ValueError("due_date must be YYYY-MM-DD or null")
if task.get("priority") not in PRIORITIES:
raise ValueError("priority must be low, medium, or high")
return task
def extract_task(text: str, attempts: int = 3) -> dict:
messages = [
{"role": "system", "content": SCHEMA_PROMPT},
{"role": "user", "content": text},
]
for _ in range(attempts):
completion = client.chat.completions.create(
model="tase-8b",
response_format={"type": "json_object"},
temperature=0,
messages=messages,
)
raw = completion.choices[0].message.content
try:
return validate_task(json.loads(raw))
except (json.JSONDecodeError, ValueError) as err:
messages.append({"role": "assistant", "content": raw})
messages.append({
"role": "user",
"content": (
"That reply failed validation: " + str(err) + ". "
"Reply again with JSON only, matching the schema exactly."
),
})
raise RuntimeError("No valid JSON after " + str(attempts) + " attempts")
task = extract_task("Remind me to renew my passport by August 15, it is urgent")
print(task)Structured outputs vs tool calling
The tools parameter also produces structured JSON — the model emits a function name plus arguments matching a JSON Schema you define. The two features solve different problems, and picking the wrong one is a common source of awkward prompts.
- Use response_format when the JSON is the answer: extraction, classification, transforming text into a record. Every reply is JSON, unconditionally.
- Use tools when the model should decide whether to act: it can answer in plain text, or call one of several functions with typed arguments, and you send the result back in a
toolmessage for the next turn. - Combining them: a chat assistant typically uses
toolsfor its actions, and plain conversational replies otherwise. A batch pipeline that parses documents into rows wantsresponse_formatwith a strict schema prompt.
// Tool calling: the model decides IF and WITH WHAT arguments to act.
const completion = await client.chat.completions.create({
model: 'tase-8b',
messages: [{ role: 'user', content: 'Add milk to my shopping list' }],
tools: [
{
type: 'function',
function: {
name: 'add_task',
description: 'Add a task to the user task list',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
due_date: { type: 'string', description: 'YYYY-MM-DD, optional' },
},
required: ['title'],
},
},
},
],
tool_choice: 'auto',
});
const message = completion.choices[0].message;
const toolCall = message.tool_calls?.[0];
if (toolCall) {
const args = JSON.parse(toolCall.function.arguments);
// Run your function, then send the result back as a { role: 'tool' } message.
} else {
// The model chose to reply in plain text instead.
console.log(message.content);
}Do I still need to validate when using response_format?+
Yes. JSON mode guarantees the output parses, not that it matches your schema. Validate keys, types, and enum values before using the data, and retry with feedback on failure.
Can I combine response_format with streaming?+
Yes — the JSON arrives as deltas like any other reply. Accumulate the full string first and parse once after the stream completes; partial JSON does not parse.
Why does the model sometimes wrap JSON in markdown fences?+
That happens when JSON is requested only via the prompt. Setting response_format to json_object removes the fences, since the constraint forbids anything outside the JSON value.
Review the fundamentals
Roles, parameters, multi-turn history, and token usage — the base layer under every structured request.
Back to chat completions