Structured Output (Response Schema)¶
Some calls do not want prose back. A triage step wants a department and an urgency flag, a judge wants a verdict, a classifier wants a label. Asking for JSON in the prompt and parsing whatever comes back works most of the time, and fails on the answer that wraps the object in a sentence or stops halfway through it.
AIContext.response_schema removes that guesswork. It carries a JSON Schema,
the provider constrains its output natively, and generate() returns one JSON
document that satisfies the schema. RoomKit checks the document against the
schema before handing it back, whatever the server claims to have done. If
there is no such document, generate() raises an error that says why. It never
returns prose in its place.
Quick start¶
import json
from roomkit import ResponseSchemaError
from roomkit.providers.ai import AIContext, AIMessage
from roomkit.providers.gemini import GeminiAIProvider, GeminiConfig
TRIAGE = {
"type": "object",
"properties": {
"department": {"type": "string", "enum": ["billing", "technical", "sales", "other"]},
"urgent": {"type": "boolean"},
"summary": {"type": "string", "description": "One short sentence."},
},
"required": ["department", "urgent", "summary"],
"additionalProperties": False,
}
provider = GeminiAIProvider(GeminiConfig(api_key="..."))
context = AIContext(
system_prompt="You triage customer support messages.",
messages=[AIMessage(role="user", content="I was charged twice for March.")],
response_schema=TRIAGE,
)
try:
response = await provider.generate(context)
except ResponseSchemaError as exc:
print(f"no triage: {exc.reason}")
else:
triage = json.loads(response.content)
response.content stays a string. Turning it into a typed value (with
json.loads, or a Pydantic model's model_validate_json) is the caller's work.
A runnable version is in examples/ai_response_schema.py. It runs offline on
the mock, or against Gemini, Anthropic, OpenAI or a local Ollama.
The portable subset¶
Every provider reads its own dialect of JSON Schema. OpenAI's strict mode wants every property required; Anthropic refuses numeric bounds; Gemini reads its own subset. RoomKit accepts only what they all take, so a schema written once runs on any provider that supports response schemas:
| Keyword | Rule |
|---|---|
type |
object, array, string, number, integer or boolean, as a single string. The root is an object. |
properties |
Required on an object, possibly empty. |
required |
Required on an object, and lists every property, each once. |
additionalProperties |
Required on an object, and false. |
items |
Required on an array. |
enum |
On a string only: a non-empty list of distinct strings. |
title, description |
Allowed anywhere, as strings. The model reads them. |
Nothing else passes: no null or optional fields, no anyOf, no $ref, no
minimum or maxLength. For an answer that may be absent, use a required
field with an agreed empty value ("", []).
The check runs when the context is built, when the field is assigned, and
through model_copy(update=...). A schema outside the subset fails there,
before any provider is called. check_portable_schema(schema) runs the same
check on its own, for a schema you build at startup.
Providers¶
| Provider | Native field | Supported |
|---|---|---|
| OpenAI, Azure, xAI, Cerebras, OpenRouter, LiteLLM, vLLM, llama.cpp | strict json_schema response_format |
yes |
| DeepSeek, Qwen | — | no: only free-form JSON mode is documented |
| Anthropic | output_config.format |
yes |
| Gemini, Gemini Vertex | response_mime_type + response_json_schema |
yes |
| Mistral | strict json_schema response_format |
yes |
| Ollama | format |
yes |
| PolarGrid | json_schema response_format |
yes |
On OpenRouter, a constrained request also asks for provider.require_parameters,
so it is only routed to an upstream that honours the format. Routing
preferences set in extra_body are kept.
provider.supports_response_schema gives the provider's answer. It is a
default, not a promise for every model: a model older than the feature may
still reject the request, which surfaces as a ProviderError.
Whether an OpenAI-compatible server applies the format depends on the server,
so OpenAIConfig, AzureAIConfig and VLLMConfig take
supports_response_schema= to state it:
OpenAIConfig(
api_key="...",
base_url="http://my-server:8000/v1",
model="my-model",
supports_response_schema=False, # this server ignores response_format
)
When there is no document¶
ResponseSchemaError is a ProviderError that is never retryable: the same
request fails the same way. Its reason tells the cases apart:
reason |
Meaning |
|---|---|
unsupported |
The call cannot carry a schema: the provider does not support one, or cannot support one beside the turn's tools. Raised before any request is sent. |
refusal |
The model declined to answer: a refusal field (OpenAI), a refusal stop reason (Anthropic), a safety stop or a blocked prompt (Gemini), a content filter. |
truncated |
The answer was cut: the output cap, or the context window filling up. Raise max_tokens; a reasoning model spends part of it thinking. |
invalid_json |
The text is not a JSON document satisfying the schema: not JSON at all, or JSON of another shape. That happens with a server that accepted the constraint and did not apply it. |
Running on any provider¶
A component that must work with every provider checks the property first, and
where it is false asks for JSON in the prompt and checks the answer itself.
schema_mismatch is the same check the providers run:
from roomkit.providers.ai import schema_mismatch
if provider.supports_response_schema:
context = context.model_copy(update={"response_schema": TRIAGE})
triage = json.loads((await provider.generate(context)).content)
else:
instruction = "Answer with one JSON object matching: " + json.dumps(TRIAGE)
context.system_prompt = f"{context.system_prompt or ''}\n{instruction}".strip()
triage = json.loads((await provider.generate(context)).content) # may raise
if (problem := schema_mismatch(TRIAGE, triage)) is not None:
raise ValueError(problem)
Streaming¶
generate_stream() and generate_structured_stream() carry the schema too. The
text deltas are the JSON as the model writes it, partial and provisional; the
whole document is checked before the done event, and the error takes the done
event's place when the check fails. So a consumer shows the text as it streams,
if it likes, and acts on it only once StreamDone arrives:
async for event in provider.generate_structured_stream(context):
if isinstance(event, StreamTextDelta):
buffer.append(event.text) # partial JSON, provisional
elif isinstance(event, StreamDone):
triage = json.loads("".join(buffer)) # checked against the schema
Tools in the same turn¶
A turn may carry tools and a schema together where
provider.supports_response_schema_with_tools is true: OpenAI's own endpoint and
Azure, Anthropic, Gemini. The model either calls tools or answers in the schema.
A response carrying tool calls is a step of the loop and is not checked; the
final answer, the one without tool calls, is.
Where the constraint is a decoding grammar (Ollama, vLLM, llama.cpp, PolarGrid)
the pair is refused before any request. The grammar forces every token into the
schema, so the model cannot call the tool: asked for the weather with a
get_weather tool and a schema, a local Ollama model answered a schema-valid
temperature it had made up. supports_response_schema_with_tools=True on
OpenAIConfig, AzureAIConfig or VLLMConfig states otherwise for a server
you know combines them.
AIChannel turns¶
An AIChannel answers in a schema the same way. Set it for every turn, per
turn from a config provider, or per room in the binding metadata; the binding
wins, then the config provider, then the channel:
triage = AIChannel("triage", provider=provider, response_schema=TRIAGE)
# One room answers in another shape
await kit.attach_channel(
"vip-room", "triage", category=ChannelCategory.INTELLIGENCE,
metadata={"response_schema": URGENCY},
)
The turn's answer is then one JSON document, checked against the schema before
any of it reaches the room. A streamed answer is held until the check passes and
arrives in one piece; one that fails the check is never delivered nor stored,
and the turn fails through ON_ERROR.
What fails the turn:
| Case | When | Reason |
|---|---|---|
| The provider cannot honour a schema | before any request | unsupported |
| The turn has tools and the provider cannot combine them with a schema | before any request | unsupported |
| The model refuses, the output cap or the context window cuts the answer, or the text is not a document satisfying the schema | after the answer | refusal, truncated, invalid_json |
The tool loop stops before a final answer (max_tool_rounds, the loop timeout, a turn budget, a stop) |
when the loop ends | truncated |
The turn's tools include the ones the channel adds itself: skills, the
sandbox, planning (enable_planning=True), and orchestration tools such as
submit_result. A channel with any of them needs a provider that combines tools
and a schema.
When the model calls tools, a round can carry text of its own ("Let me look that up."). That text reaches the room as its own message, as in any turn; the turn's last text message is the JSON document.
A channel default outside the portable subset fails when the channel is built.
Vision¶
A VisionProvider answers a frame in a schema the same way:
analyze_frame(frame, prompt=..., response_schema=SCHEMA) returns a
VisionResult whose description is the JSON document, checked before it is
returned. GeminiVisionProvider and OpenAIVisionProvider support it
(OpenAIVisionConfig(supports_response_schema=False) for a server that does
not); vision.supports_response_schema says which do. The screen input tools'
click_element uses it to locate an element.
The rules live in RFC §6.7.
Testing¶
MockAIProvider(response_schema=True) follows the same contract. The scripted
answer must be JSON, and an AIResponse scripted with
finish_reason="refusal" or "length" raises the matching error: