The schema is only real if the model uses it. The test that proves this is a next-call assertion: inject a specific error, let the model respond, and check the shape of its next tool call. This is a behavioral test, not a schema-validity test.
The harness has four parts:
- A fake tool that returns a fixed error object instead of a result.
- A model with the tool's JSON Schema and your instructions.
- A capture of the model's next tool call: function name, arguments, and any text.
- An assertion on that call.
A minimal Python sketch, with the fake tool returning a scripted error:
def run_error_scenario(error, user_turn, max_turns=3):
messages = [{"role": "user", "content": user_turn}]
calls = []
for _ in range(max_turns):
reply = model(messages=messages, tools=[search_tool_schema])
if not reply.tool_calls:
break
call = reply.tool_calls[0]
calls.append({"name": call.name, "args": call.arguments})
messages.append(reply)
messages.append({"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(error)})
return calls
Then assert per field:
def test_field_is_used():
err = {"code": "missing_required_field",
"message": "The request has no value for 'destination'.",
"retryable": False,
"hint": "Call again with destination as an IATA airport code.",
"field": "destination"}
calls = run_error_scenario(err, "Find flights from LHR on March 1")
assert calls[1]["args"].get("destination") # model fixed the missing field
def test_retryable_false_stops_retry():
err = {"code": "permission_denied",
"message": "The API key lacks booking scope.",
"retryable": False,
"hint": "Do not retry; report the missing scope to the user.",
"field": None}
calls = run_error_scenario(err, "Book the cheapest flight")
assert len(calls) == 1 # no second identical call
def test_retryable_true_triggers_wait():
err = {"code": "rate_limited",
"message": "Rate limit exceeded.",
"retryable": True,
"hint": "Wait 30 seconds, then call again.",
"field": None}
calls = run_error_scenario(err, "Find flights from LHR on March 1")
assert any(c["name"] == "search_flights" for c in calls[1:])
Run each test against several models you ship, because a smaller model may rely on a field a larger one infers. The point is not a benchmark score. It is a boolean per field: does this field change the next call or not.
A checklist for the suite:
| Field | Injected error | Assertion | Fails when |
|---|
| code | known code plus a conflicting message | model branches on code, not message | model keys off prose only |
| message | vague vs specific text | model explains the failure correctly | message unused or misleading |
| retryable | true vs false, same code | retry happens or is suppressed | model ignores flag |
| hint | two different hints for one code | next call matches the hint | hint ignored |
| field | correct vs wrong pointer | model edits the right argument | model edits wrong input |
For code, the strongest test sends two codes with deliberately swapped messages and confirms the model's behavior follows the code. If it chases the message, the code is decoration. Speakeasy's example shows the intended contract: a client reacts to error.code, and the message is for the reader Speakeasy's API error best practices.
For hint, send the same code with two different hints ("retry with backoff" versus "ask the user for input") and confirm the next call differs. A hint that produces the same call in both cases is not earning its tokens.
For retryable, assert the count of repeated calls and check the wait behavior. Temporal's recipe is worth copying here: it maps status classes to retryable or not, honors a Retry-After header when present, and lets an explicit header override the status code Temporal's retry cookbook. Your test suite should prove your flag wins the same way.
Rate-limit errors deserve their own case. A well-formed 429 carries a Retry-After header, so a test can assert that the model waits the stated interval instead of hammering the boundary Zuplo on HTTP 429.