Back to blog
IntegrationIntermediateTechnical article

Reliable JSON with RouterLab: Schemas and Validation

Published 13 Oct 202611 min readRouterLab

Decision summary

A practical guide to defining a JSON contract, requesting structured output from RouterLab models and validating the result before using it.

APIJSONStructured OutputsTypeScriptPythonRouterLab
Reliable JSON with RouterLab: Schemas and Validation

Applications that use an AI model often need precise data: a category, an amount, a reference or a set of fields to store.

Asking a model to “respond only with JSON” does not always get you there. It may add an introduction, omit a property or choose a category your application does not recognize.

Structured Outputs provide more control over these responses. When a model and its route support the feature, you send a schema that describes the expected structure.

Reliable results require three things to work together: a precise data contract, constrained generation and validation in your application.

This guide uses models from the RouterLab catalog and walks through a complete support-ticket classification example. The information and API interfaces were checked on October 13, 2026; model capabilities can change.

1. Valid JSON, schema adherence and accurate data

These are three different properties.

PropertyWhat it checks
Valid JSONA JSON parser can read the text.
Schema adherenceFields, types and allowed values match the contract.
AccuracyThe data correctly represents the input being analyzed.

For example:

json
RouterLab
{
  "categorie": "urgent",
  "resume": "Un problème de paiement."
}

This is valid JSON. It does not match a schema that requires a category from facturation, technique, compte and autre, plus a priorite field.

Conversely, an object can match the schema perfectly while incorrectly assigning a payment issue to technique.

Structured outputs control the response format. The quality of the analysis must be evaluated separately. At OpenAI, this also explains the difference between JSON mode and Structured Outputs: JSON mode ensures valid syntax, while Structured Outputs add adherence to the supported schema. OpenAI documentation.

2. Use RouterLab models and their published routes

The examples in this guide use these identifiers:

ModelRouterLab identifierPublished request format
GPT-6 Solgpt-6-solOpenAI-compatible
Claude Sonnet 5.5claude-sonnet-5-5Claude Messages
Gemini 3.8 Flashgemini-3.8-flashOpenAI-compatible
Qwen 3.8 Flashqwen3.8-flashOpenAI-compatible
DeepSeek V4.1 Flashdeepseek-v4.1-flashOpenAI-compatible

Available formats are listed in the RouterLab catalog. Check which models your key can access and which format each model page publishes.

The model provider and your client's protocol are separate choices. On RouterLab, a Gemini model can use an OpenAI-compatible route. A Claude Messages route expects a different request body.

Protocol compatibility does not mean every model accepts every parameter or every schema constraint.

3. Define the contract with Zod or Pydantic

Zod is a validation library for TypeScript. Pydantic serves the same purpose in Python.

These libraries define the expected data and validate incoming results. They keep validation rules from being scattered throughout your application.

Our support ticket has four fields:

  • categorie: facturation, technique, compte or autre, meaning billing, technical, account or other;
  • priorite: basse, normale or haute, meaning low, normal or high;
  • resume: a short description of the issue;
  • reference: a reference present in the message, or null.

The examples retain the same French field names, enum values and sample ticket across all language versions. This gives every version the same data contract and comparison input.

The last rule matters. If the customer supplies no reference, the model must be able to express that absence. Requiring a string in every case can encourage an invented value.

Instructions should also define classification rules. A pending payment does not automatically mean high priority. You might reserve that priority for a blocking incident or an explicitly stated deadline.

4. A complete TypeScript example

We will use RouterLab's OpenAI-compatible route:

text
RouterLab
POST https://api.routerlab.ch/v1/chat/completions

The OpenAI SDK supports a custom base URL. Authentication uses your RouterLab key. RouterLab integration guide.

Install the dependencies

In a Node.js project:

bash
RouterLab
npm install openai zod
npm install --save-dev tsx @types/node

Set your key in the server environment.

On Linux or macOS:

bash
RouterLab
export ROUTERLAB_API_KEY="your-routerlab-key"

In PowerShell:

powershell
RouterLab
$env:ROUTERLAB_API_KEY = "your-routerlab-key"

To check the identifiers available to your key:

bash
RouterLab
curl https://api.routerlab.ch/v1/models \
  -H "Authorization: Bearer $ROUTERLAB_API_KEY"

Send the request and validate the response

Create a file named classer-ticket.ts:

typescript
RouterLab
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";

const TicketSchema = z.strictObject({
  categorie: z.enum(["facturation", "technique", "compte", "autre"]),
  priorite: z.enum(["basse", "normale", "haute"]),
  resume: z.string(),
  reference: z.string().nullable(),
});

const responseFormat = zodResponseFormat(TicketSchema, "ticket_support");

async function main() {
  const apiKey = process.env.ROUTERLAB_API_KEY;
  if (!apiKey) throw new Error("ROUTERLAB_API_KEY is missing.");

  const client = new OpenAI({
    apiKey,
    baseURL: "https://api.routerlab.ch/v1",
    timeout: 60_000,
    maxRetries: 0,
  });

  const model = process.env.ROUTERLAB_MODEL ?? "gpt-6-sol";
  const start = performance.now();

  const response = await client.chat.completions.create({
    model,
    stream: false,
    response_format: responseFormat,
    messages: [
      {
        role: "system",
        content: [
          "Classe le ticket de support fourni.",
          "Utilise autre si la catégorie est ambiguë.",
          "La priorité normale est la valeur habituelle.",
          "Réserve haute à un incident bloquant ou urgent",
          "explicitement décrit dans le message.",
          "Résume le problème en français.",
          "Copie la référence seulement si elle est présente.",
          "Sinon, utilise null.",
        ].join(" "),
      },
      {
        role: "user",
        content:
          "Mon paiement a été accepté, mais mes crédits " +
          "ne sont toujours pas disponibles.",
      },
    ],
  });

  const choice = response.choices[0];
  if (!choice) throw new Error("No completion was returned.");
  if (choice.message.refusal) throw new Error("The model refused the request.");
  if (choice.finish_reason === "length") {
    throw new Error("The response was truncated.");
  }
  if (choice.finish_reason !== "stop") {
    throw new Error(`Unexpected finish reason: ${choice.finish_reason}`);
  }

  const content = choice.message.content;
  if (!content) throw new Error("The response is empty.");

  let data: unknown;
  try {
    data = JSON.parse(content);
  } catch {
    throw new Error("The response is not valid JSON.");
  }

  const validation = TicketSchema.safeParse(data);
  if (!validation.success) {
    console.error(validation.error.issues);
    throw new Error("The response does not match the schema.");
  }

  console.log(JSON.stringify(validation.data, null, 2));
  console.error({
    model: response.model,
    request: response.id,
    duration_ms: Math.round(performance.now() - start),
    usage: response.usage,
  });
}

main().catch((error) => {
  if (error instanceof OpenAI.APIError) {
    console.error("API error:", error.status, error.message);
  } else {
    console.error(error instanceof Error ? error.message : "Unexpected error.");
  }
  process.exitCode = 1;
});

Run the script:

bash
RouterLab
npx tsx classer-ticket.ts

Here is an illustrative response that matches the contract:

json
RouterLab
{
  "categorie": "facturation",
  "priorite": "normale",
  "resume": "Le paiement est accepté, mais les crédits ne sont pas disponibles.",
  "reference": null
}

The summary wording can vary. The schema still defines the expected categories and structure.

The zodResponseFormat helper constructs the schema format sent to the server. Local validation uses safeParse, which explicitly returns success or an error. z.strictObject also rejects unexpected properties. Zod validation, strict objects.

Strict-mode constraints

In the OpenAI format, use json_schema with strict: true to request schema adherence. Objects must declare additionalProperties: false, and properties must be required. A type that accepts null can represent an unknown value.

Not every JSON Schema keyword is supported. A complex business schema may need a simplified provider version. The SDK helper does not add a capability to the model or route. OpenAI supported schemas.

5. Reuse the example with Gemini or Qwen

For another OpenAI-compatible route, the script keeps the same structure. Change the model identifier:

bash
RouterLab
export ROUTERLAB_MODEL="gemini-3.8-flash"
npx tsx classer-ticket.ts

Or:

bash
RouterLab
export ROUTERLAB_MODEL="qwen3.8-flash"
npx tsx classer-ticket.ts

In PowerShell, use for example $env:ROUTERLAB_MODEL = "gemini-3.8-flash", then run the script again.

These trials check both support for the structured request on your selected route and the quality of its classification.

An HTTP 200 response is not enough to demonstrate a schema guarantee. Keep local validation and examine results across varied inputs.

Gemini 3.8 Flash supports structured outputs in its native API. When accessed through RouterLab, the contract shown here is the OpenAI-compatible route's contract. Official Gemini 3.8 Flash model page.

6. Claude Sonnet 5.5 uses a different format

Claude Sonnet 5.5 uses the published Claude Messages route here:

text
RouterLab
POST https://api.routerlab.ch/v1/messages

It uses the x-api-key and anthropic-version headers. Request bodies and response blocks differ from Chat Completions. RouterLab Claude Messages guide.

For Claude's native JSON outputs, the schema is passed through output_config.format. Reusing the responseFormat defined above, a structured request body follows this organization:

typescript
RouterLab
const corpsClaude = {
  model: "claude-sonnet-5-5",
  max_tokens: 4096,
  system:
    "Classe le ticket. Utilise une priorité normale " +
    "sauf urgence explicite. Une référence absente vaut null.",
  messages: [
    {
      role: "user",
      content:
        "Mon paiement a été accepté, mais mes crédits " +
        "ne sont toujours pas disponibles.",
    },
  ],
  output_config: {
    format: {
      type: "json_schema",
      schema: responseFormat.json_schema.schema,
    },
  },
};

This excerpt describes the native format to verify on your selected route. Confirm support for output_config.format before integrating it into your application.

To process the response, check its stop_reason, retrieve the text block and apply the same local validation schema.

Claude's native JSON outputs and strict tool arguments are separate features. Forcing a tool does not replace strict mode; Claude Sonnet 5.5 and Opus 5.5 reject tool_choice configurations of type tool or any. Claude structured outputs, tool-call restrictions.

7. Validate the result in Python with Pydantic

A Python application can apply the same contract using Pydantic.

Install the library:

bash
RouterLab
python -m pip install pydantic

In a Linux or macOS terminal, save the JSON produced by the TypeScript script:

bash
RouterLab
npx tsx classer-ticket.ts > ticket.json

Diagnostics stay separate because they are written to stderr. On Windows, make sure ticket.json is saved as UTF-8.

Create valider-ticket.py:

python
RouterLab
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, ConfigDict


class Ticket(BaseModel):
    model_config = ConfigDict(extra="forbid")
    categorie: Literal["facturation", "technique", "compte", "autre"]
    priorite: Literal["basse", "normale", "haute"]
    resume: str
    reference: str | None


content = Path("ticket.json").read_text(encoding="utf-8")
ticket = Ticket.model_validate_json(content, strict=True)
print(ticket.model_dump())

Then run it:

bash
RouterLab
python valider-ticket.py

The reference field must be present even when its value is null. Extra properties are rejected. A structural error raises a validation exception. JSON parsing and validation with Pydantic.

8. Handle failures without storing partial data

Different errors need different responses.

SituationAppropriate action
Rejected schema or parameterCheck compatibility between the model, route and schema.
Model refusalTreat the refusal as a separate result.
Truncated responseReview generation limits and the size of the task.
Invalid JSONDiscard the result and investigate why the expected contract was not obtained.
Schema mismatchRetain the validation error and discard the result.
Schema-compliant but incorrect dataImprove instructions, examples and evaluation criteria.

A schema-related 400 error will not be fixed by identical retries. A temporary outage may justify a few retries with backoff.

Set a maximum number of attempts and measure their cost. The example disables automatic retries so that each execution corresponds to one visible attempt.

When streaming, incoming fragments can be incomplete. Wait until the response is finished and validated before storing the final object.

9. Check data accuracy too

Schema validation is not enough to decide whether a ticket was classified correctly.

Prepare examples with expected answers:

  • an accepted payment with missing credits;
  • a rejected API key;
  • a request to change an email address;
  • a message too vague to identify a category;
  • an explicitly described urgent incident;
  • a message containing a reference to copy.

For each model, measure schema adherence and accuracy separately. An allowed but incorrect category is a business error even if the JSON is flawless.

Extracted fields must also be checked against the source. A reference with the correct format may still have been invented.

10. Compare the cost of an accepted result

A useful comparison keeps the same ticket set, rules and acceptance criteria.

Record:

  • the schema-adherence rate;
  • the correct-classification rate;
  • refusals and truncations;
  • latency;
  • the number of retries;
  • the billed cost of obtaining an accepted result.

Compare GPT-6 Sol and Gemini 3.8 Flash using the same script, then Claude Sonnet 5.5 using its appropriate protocol. This guide provides a comparison method; the displayed responses are illustrative and do not constitute a model benchmark.

Structured outputs do not guarantee the same bill as a free-form response. Claude adds system instructions billed as input tokens. The first use of a schema can also add preparation latency at OpenAI. Claude documentation, OpenAI documentation.

The useful metric is the cost of a correct, accepted result, including the retries needed to obtain it.

Before deploying to production

  • Define the contract and unknown values in your code.
  • Check the RouterLab model identifier, protocol and capabilities.
  • Confirm schema support on your selected route.
  • Handle refusals, truncations and validation errors.
  • Check business rules before storing any data.
  • Evaluate several models on representative inputs.
  • Measure quality, latency and the total cost of the workflow.
RouterLab endpoint

Try the RouterLab API

Move from the article to a real request: start a trial, get a key, and call models through an OpenAI-compatible API.

https://api.routerlab.ch/v1

Audience measurement

Allow Google Analytics and RouterLab to measure public visits and link campaign sources to your signup? This is optional and can be changed at any time. If you decline, only anonymous visit totals are kept.