Structured Outputs on FluxRouter: JSON That Actually Parses

Get schema-valid JSON out of Flux with one field. json_object vs json_schema, runnable Python and curl, why support rides on the served model, how to pin one, how to read which model answered, and the one validation line you still owe yourself.

Cover Image for Structured Outputs on FluxRouter: JSON That Actually Parses

By Sean Donahoe · Published July 17, 2026 · accurate as of this date

You asked for JSON. What came back opened with Sure! Here's the object you wanted: and then, somewhere below the pleasantries, the actual JSON. And json.loads() threw on the S.

Everyone who parses model output has that scar. The reach-for-a-regex reflex, stripping ```json fences off a string and hoping. Put the pliers down. On Flux you fix this with one field on the call you're already making.

Want the argument for why this belongs in the API call and not your prompt? That's its own piece. This one is pure how-to.

How do I get JSON that actually parses out of Flux?

Set response_format on a standard /v1/chat/completions call. Two modes. json_object gives you syntactically valid JSON and you describe the shape in the prompt. json_schema locks the keys and types to a schema you hand it. It's the same call you'd make to OpenAI. The only Flux-specific parts are the base URL and the flux-auto model.

That's the whole trick. The rest of this page is the part the one-liner skips: which mode to reach for, what happens when the served model doesn't do strict schema, how to tell which model actually answered you, and the one validation line you still owe yourself no matter which mode you picked. And no, this isn't a paid feature. It's the plain chat path, no extra plan needed.

The easy button: hand this to your coding agent

This one's code, not a config toggle. So there's no Flux Desktop one-click here, and I'm not going to fake you one. Desktop wires up tools, it doesn't write your app code. What you can do is hand the job to whatever coding agent you already run and let it add a typed helper for you.

Commit first so you've got a clean rollback. The prompt below only adds one new file, it won't touch anything you've written.

Add a typed JSON extraction helper to this project.

- Use the OpenAI SDK pointed at Flux: base_url "https://api.fluxrouter.ai/v1",
  model "flux-auto", API key from the FLUX_API_KEY env var.
- The helper takes a JSON schema and a prompt, sends response_format with a
  json_schema of that shape, parses the response, and validates the parsed
  object against the schema before returning it.
- Raise a clear error if a required key is missing or a type is wrong.
  Do not trust the parse alone.
- Add it as ONE new file. Don't modify existing files. I've committed first
  so I can roll it back.

Read the file it writes before you run it. Then come back here for what's actually happening under that helper, because the interesting part is the stuff the agent won't tell you.

json_object vs json_schema: which one

Two modes, two different jobs.

ModeWhat it guaranteesWhere the shape livesReach for it when
json_objectSyntactically valid JSONYour prompt describes the fieldsYou just need parseable JSON and the prompt already pins the shape
json_schemaKeys and types constrained to your schemaThe schema you pass in response_formatA downstream system depends on exact keys and types

Rule of thumb: if something three functions downstream breaks when a key goes missing, use json_schema. If you're just eyeballing the output or the prompt already nails the shape, json_object is less ceremony.

Here's json_object in Python. Flux key, standard SDK, one extra line.

python
from openai import OpenAI

client = OpenAI(
    api_key="sk-...",  # your Flux key from the dashboard
    base_url="https://api.fluxrouter.ai/v1",
)

resp = client.chat.completions.create(
    model="flux-auto",
    response_format={"type": "json_object"},
    messages=[
        {"role": "system", "content": "Return a JSON object with keys `name` (string) and `age` (integer)."},
        {"role": "user", "content": "Ada is 36."},
    ],
)

print(resp.choices[0].message.content)
# {"name":"Ada","age":36}

Same thing in curl, if you'd rather see the wire.

bash
curl https://api.fluxrouter.ai/v1/chat/completions \
  -H "Authorization: Bearer sk-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "flux-auto",
    "response_format": {"type": "json_object"},
    "messages": [
      {"role": "system", "content": "Return a JSON object with keys name (string) and age (integer)."},
      {"role": "user", "content": "Ada is 36."}
    ]
  }'

Now json_schema, where you stop describing the shape and start enforcing it. The keys, the types, required, and additionalProperties: false so nothing extra sneaks in.

python
resp = client.chat.completions.create(
    model="flux-auto",
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "person",
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"},
                },
                "required": ["name", "age"],
                "additionalProperties": False,
            },
        },
    },
    messages=[
        {"role": "user", "content": "Ada is 36."},
    ],
)

print(resp.choices[0].message.content)
# {"age":36,"name":"Ada"}

That's the shape the field wants. name for the schema, schema for the object, required for the keys that must show up, additionalProperties: false for the door being shut. One thing the field alone doesn't settle: how hard those constraints are actually enforced still depends on the model that serves the call, which is the next section.

The catch: schema enforcement rides on the served model

The one-liner sets a field. What that field buys you is the part worth spelling out.

Strict schema is not something Flux bolts onto the request. It's a property of the model that actually serves it. JSON mode is widely supported across models. Strict json_schema enforcement is honored by fewer of them. flux-auto routes you to a capable model, but it doesn't itself promise a given mode is enforced.

So when a real feature depends on strict schema, don't leave it to chance. Pin a model you've verified and send that instead of flux-auto. Pass a flux-pinned-* id, flux-pinned-gpt-5 say, or whichever id you verified against the catalog. The full list lives behind GET /v1/models and on the Models page.

python
resp = client.chat.completions.create(
    model="flux-pinned-gpt-5",   # deterministic: this model, every call
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "person",
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"},
                },
                "required": ["name", "age"],
                "additionalProperties": False,
            },
        },
    },
    messages=[{"role": "user", "content": "Ada is 36."}],
)

Why bother pinning? Because which model flux-auto picks is never a promise. On 2026-07-17 I sent the same shape of schema request at it across several calls and it served qwen-plus every time, all schema-valid. Probe it another day and you can land somewhere else entirely, an earlier run the day before hit deepseek-v4-flash. The pinned flux-pinned-gpt-5 call, by contrast, served gpt-5.4-2026-03-05 on every call. All correct. But if your test suite depends on the same model answering every time, flux-auto isn't that promise and a pin is.

(Those model names are a dated snapshot, 2026-07-17. They'll drift. The behavior won't: auto picks a capable model per request and its pick can change day to day, a pin gives you the same one every time.)

Which model produced your JSON

Every response tells you who answered. Five headers, all documented.

  • X-Flux-Model is the model that served it
  • X-Flux-Original-Model is what you asked for (flux-auto)
  • X-Flux-Routed is true when the router chose, false when your pin was honored
  • X-Flux-Request-Id is the id for this call
  • X-Flux-Cost-Usd is what the request cost

One nuance worth knowing, because it trips people up. On flux-auto, X-Flux-Model is the concrete model that ran, qwen-plus say. On a pin, that header echoes your pin id back (flux-pinned-gpt-5), and the concrete underlying model shows up in the response body's model field instead (gpt-5.4-2026-03-05). Two places to look, depending on whether you pinned.

Read the headers with the SDK's raw-response accessor.

python
resp = client.chat.completions.with_raw_response.create(
    model="flux-auto",
    response_format={"type": "json_object"},
    messages=[{"role": "user", "content": "Ada is 36."}],
)

print(resp.headers["x-flux-model"])           # concrete served model, e.g. qwen-plus
print(resp.headers["x-flux-original-model"])  # flux-auto
print(resp.headers["x-flux-routed"])          # true when the router chose
print(resp.headers["x-flux-request-id"])
print(resp.headers["x-flux-cost-usd"])        # what this request cost, e.g. 0.000176

data = resp.parse()
print(data.choices[0].message.content)

On the /v1 path that cost also comes back inside the body, in usage.cost_usd, so you can price a call whether you're reading headers or parsing the JSON.

Always validate what you parse

Schema mode makes bad JSON far less likely. It doesn't make validation someone else's job.

Flux does not repair your JSON and it does not silently retry a malformed response for you. There's no cleanup fairy in the pipe. What json_schema buys you is a much smaller chance you ever need a reprompt loop, not a promise you'll never see a surprise. The docs say it plain: parse it, check the keys, check the types.

Three lines. That's the whole tax.

python
import json

data = json.loads(resp.choices[0].message.content)
assert {"name", "age"}.issubset(data), f"missing keys: {data}"
assert isinstance(data["age"], int), f"age is not an int: {data}"

Schema-constrain the model, then validate the delivery. That's the honest version. Not "the router fixes your JSON," because it doesn't.

The Anthropic path is different: use a tool

If you're hitting the Anthropic-compatible base at https://api.fluxrouter.ai/anthropic, forget response_format. It isn't there. The Anthropic wire format doesn't have that field.

You get structured output a different way: define a tool whose input_schema is your target shape, force the model to call it, and read the input off the tool_use block. Same idea, the shape lives in the API contract, different lever.

python
import anthropic

client = anthropic.Anthropic(
    api_key="sk-...",
    base_url="https://api.fluxrouter.ai/anthropic",
)

msg = client.messages.create(
    model="flux-auto",
    max_tokens=256,
    tools=[{
        "name": "record_person",
        "description": "Record a person's structured details.",
        "input_schema": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "age": {"type": "integer"},
            },
            "required": ["name", "age"],
        },
    }],
    tool_choice={"type": "tool", "name": "record_person"},
    messages=[{"role": "user", "content": "Ada is 36."}],
)

for block in msg.content:
    if block.type == "tool_use":
        print(block.input)
        # {"name": "Ada", "age": 36}

Cost works differently on this path too. The Anthropic usage block carries token counts only, input_tokens, output_tokens, the cache fields. No cost_usd in there. To see what the call cost, read the X-Flux-Cost-Usd header on the non-streaming response.

Now that it parses, run these

Five extraction jobs you can paste and run today. Each ships the schema ready to drop into the json_schema block above. Swap the input, keep the shape.

Contact details out of an email. Point it at a signature block and get an object back.

json
{
  "name": "contact",
  "schema": {
    "type": "object",
    "properties": {
      "full_name": {"type": "string"},
      "email": {"type": "string"},
      "phone": {"type": "string"},
      "company": {"type": "string"}
    },
    "required": ["full_name", "email"],
    "additionalProperties": false
  }
}

Invoice lines into an array. One object per line item, totals as numbers.

json
{
  "name": "invoice",
  "schema": {
    "type": "object",
    "properties": {
      "invoice_number": {"type": "string"},
      "line_items": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "description": {"type": "string"},
            "quantity": {"type": "integer"},
            "unit_price": {"type": "number"},
            "amount": {"type": "number"}
          },
          "required": ["description", "quantity", "amount"]
        }
      },
      "total": {"type": "number"}
    },
    "required": ["line_items", "total"],
    "additionalProperties": false
  }
}

Sentiment plus the reason, as an enum. No free-text sentiment labels sneaking in.

json
{
  "name": "sentiment",
  "schema": {
    "type": "object",
    "properties": {
      "sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]},
      "reason": {"type": "string"}
    },
    "required": ["sentiment", "reason"],
    "additionalProperties": false
  }
}

Resume into structured fields. Feed the raw text, get the parts you actually index on.

json
{
  "name": "resume",
  "schema": {
    "type": "object",
    "properties": {
      "name": {"type": "string"},
      "years_experience": {"type": "integer"},
      "skills": {"type": "array", "items": {"type": "string"}},
      "most_recent_title": {"type": "string"}
    },
    "required": ["name", "skills"],
    "additionalProperties": false
  }
}

Support ticket, triaged. Category, priority, one-line summary. Straight into your queue.

json
{
  "name": "ticket",
  "schema": {
    "type": "object",
    "properties": {
      "category": {"type": "string", "enum": ["billing", "bug", "feature_request", "account", "other"]},
      "priority": {"type": "string", "enum": ["low", "medium", "high", "urgent"]},
      "summary": {"type": "string"}
    },
    "required": ["category", "priority", "summary"],
    "additionalProperties": false
  }
}

Run one, watch it come back clean, then wire it into the helper your agent wrote. That's a feature shipped this afternoon.

Right-size every prompt, see what each call costs, and pay only for what you use. That is the kind of thing we built Flux to handle.

One key. Pay only for what you use.