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.
| Mode | What it guarantees | Where the shape lives | Reach for it when |
|---|---|---|---|
json_object | Syntactically valid JSON | Your prompt describes the fields | You just need parseable JSON and the prompt already pins the shape |
json_schema | Keys and types constrained to your schema | The schema you pass in response_format | A 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.
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.
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.
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.
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-Modelis the model that served itX-Flux-Original-Modelis what you asked for (flux-auto)X-Flux-Routedistruewhen the router chose,falsewhen your pin was honoredX-Flux-Request-Idis the id for this callX-Flux-Cost-Usdis 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.
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.
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.
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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
