# Structured output

Structured output means the model answers with JSON your code can parse, instead of free text. All three chat APIs accept it: `response_format` on [Chat completions](/en/docs/chat), `text.format` on [Responses](/en/docs/responses), and `output_config.format` on [Messages](/en/docs/messages). kenari asks every model for JSON, but it does not check the result, so validate every reply in your own code.

## Ask for JSON

`{"type": "json_object"}` asks for a single valid JSON object. kenari also adds an instruction to answer with JSON to the system prompt, so you do not have to.

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "response_format": {"type": "json_object"},
    "messages": [
      {"role": "system", "content": "Reply with a JSON object and nothing else."},
      {"role": "user", "content": "Extract vendor and total: Invoice from Toko Maju, total 417000 rupiah."}
    ]
  }'
```

## Ask for a schema

`{"type": "json_schema"}` asks for JSON that matches a JSON Schema. Give the schema a `name` and put the schema itself under `schema`.

```json
{
  "model": "step-3-7-flash:free",
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "invoice",
      "schema": {
        "type": "object",
        "properties": {
          "vendor": {"type": "string"},
          "total": {"type": "integer"}
        },
        "required": ["vendor", "total"]
      }
    }
  },
  "messages": [
    {"role": "user", "content": "Extract the invoice fields: Invoice from Toko Maju, total 417000 rupiah."}
  ]
}
```

## On Responses and Messages

On [Responses](/en/docs/responses), put the format in `text.format`. The schema fields sit on the format object itself, not under `json_schema`:

```json
{
  "model": "step-3-7-flash:free",
  "input": "Extract the invoice fields: Invoice from Toko Maju, total 417000 rupiah.",
  "text": {
    "format": {
      "type": "json_schema",
      "name": "invoice",
      "schema": {
        "type": "object",
        "properties": {
          "vendor": {"type": "string"},
          "total": {"type": "integer"}
        },
        "required": ["vendor", "total"]
      }
    }
  }
}
```

`{"type": "json_object"}` works there too. The response echoes the format in its `text` field, and any other `text.format` type returns `400`.

On [Messages](/en/docs/messages), put the schema in `output_config.format`. The older top-level `output_format` is accepted with the same meaning.

```json
{
  "model": "step-3-7-flash:free",
  "max_tokens": 1024,
  "output_config": {
    "format": {
      "type": "json_schema",
      "schema": {
        "type": "object",
        "properties": {
          "vendor": {"type": "string"},
          "total": {"type": "integer"}
        },
        "required": ["vendor", "total"],
        "additionalProperties": false
      }
    }
  },
  "messages": [
    {"role": "user", "content": "Extract the invoice fields: Invoice from Toko Maju, total 417000 rupiah."}
  ]
}
```

## What kenari does with the format

On all three APIs, kenari does two things with a structured-output request:

- It adds a short instruction to the system prompt that asks for JSON, with your schema when you sent one. Every model gets it, so a model answers in JSON even when its provider ignores the format. The instruction is billed as input tokens, like the rest of the prompt.
- It passes the format on in the provider's own field where that provider is known to accept it. There the provider can enforce the schema. Elsewhere the model follows the instruction alone.

`GET /v1/models` does not say which models enforce a schema, and that can change, so plan to validate and retry on every model. If a provider rejects the format, you can get a `400`. A reply can still be invalid JSON, or JSON wrapped in a code fence.

## The reliable pattern: validate and retry

Request JSON, validate the reply against your schema, and send the validation error back when it fails. This works on every model.

```python
import os

from openai import OpenAI
from pydantic import BaseModel, ValidationError


class Invoice(BaseModel):
    vendor: str
    total: int


client = OpenAI(
    base_url="https://kenari.id/v1",
    api_key=os.environ["KENARI_API_KEY"],
)


def extract_invoice(text: str, attempts: int = 3) -> Invoice:
    messages = [
        {"role": "system", "content": "Reply with one JSON object and nothing else."},
        {"role": "user", "content": f"Extract vendor and total from: {text}"},
    ]
    for _ in range(attempts):
        response = client.chat.completions.create(
            model="step-3-7-flash:free",
            messages=messages,
            response_format={
                "type": "json_schema",
                "json_schema": {"name": "invoice", "schema": Invoice.model_json_schema()},
            },
        )
        reply = response.choices[0].message.content or ""
        try:
            return Invoice.model_validate_json(reply)
        except ValidationError as error:
            messages += [
                {"role": "assistant", "content": reply},
                {"role": "user", "content": f"That reply was invalid: {error}. Reply with corrected JSON only."},
            ]
    raise RuntimeError("no valid JSON after retries")


print(extract_invoice("Invoice from Toko Maju, total 417000 rupiah."))
```

Asking for a format adds no separate fee. The instruction kenari adds is billed as input tokens. Each retry is a new request, billed as usual on paid models.

## Structured output with a tool

Forcing a tool call requests arguments shaped by a schema, and works on any model with `tool_call` set to `true`. Define one tool whose `input_schema` is your schema, force it with `tool_choice`, and read the arguments from the call. On Messages:

```python
import os

import anthropic

client = anthropic.Anthropic(
    base_url="https://kenari.id",
    api_key=os.environ["KENARI_API_KEY"],
)

response = client.messages.create(
    model="step-3-7-flash:free",
    max_tokens=1024,
    tools=[
        {
            "name": "record_invoice",
            "description": "Record the fields of an invoice.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "vendor": {"type": "string"},
                    "total": {"type": "integer"},
                },
                "required": ["vendor", "total"],
            },
        }
    ],
    tool_choice={"type": "tool", "name": "record_invoice"},
    messages=[{"role": "user", "content": "Invoice from Toko Maju, total 417000 rupiah."}],
)

invoice = next(block.input for block in response.content if block.type == "tool_use")
print(invoice)
```

`block.input` is already parsed into an object. You do not run the tool or send a result back. The same pattern works on Chat completions with a function tool and `tool_choice: {"type": "function", "function": {"name": "record_invoice"}}`, where the JSON is in `tool_calls[0].function.arguments`. Validate it all the same, and handle a reply that has no tool call. See [Function calling](/en/docs/function-calling).

## Limits and pitfalls

- A reply cut off by `max_tokens` is invalid JSON. On Chat completions, check that `finish_reason` is not `length`. On Responses, check that `status` is not `incomplete`. On Messages, check that `stop_reason` is not `max_tokens`. Reasoning models spend part of `max_tokens` on thinking, so leave room. See [Reasoning](/en/docs/reasoning).
- Do not rely on schema keywords such as `minimum` or `pattern` being enforced. Models differ, so check them in your own code.

## See also

- [Function calling](/en/docs/function-calling)
- [Chat completions](/en/docs/chat)
- [Responses](/en/docs/responses)
- [Messages](/en/docs/messages)
- [Reasoning](/en/docs/reasoning)
