Security

Structured Error Schemas Across Providers

Antonio Evans7 min read

A clean technical diagram with three parallel pipelines labeled OpenAI, Anthropic, and Gemini.
Key points

Key points

  1. 1OpenAI returns tool results as a role tool message whose content is a string, so structured errors must be serialized into that string.
  2. 2Anthropic returns a tool_result content block, matched to its tool_use_id, with an optional is_error flag.
  3. 3Gemini returns a functionResponse object with a name and a response object, not a string.
  4. 4A portable error object with code, message, retryable, details and source lets you swap models without rewriting every tool.

Why the payload shape decides your error handling

When a model calls a tool, your code runs it and writes the result back into the conversation. That write is where portability usually breaks. Each provider defines a different envelope for the result, so a tool wrapped for one API will not parse on another without translation.

The underlying loop is identical everywhere: define tools, send the request, detect the call, execute it, return the result, and repeat. The universal six-step pattern holds across vendors the ofox function calling guide describes, and the four-step version (define, call, execute, return) is the same in TypeScript and Python the digitalapplied comparison. The differences are surface syntax, and the result envelope is the part most teams get wrong.

This matters more than it used to. Multiple providers now speak variants of the same interfaces: Gemini documents an OpenAI-compatible endpoint that works by changing three lines of code the Gemini OpenAI compatibility page, while Anthropic keeps its own content-block model the Claude tool-call docs. If your tools return raw strings and your adapters guess the shape, a model swap turns into a debugging session.

The decision rule

Define the error object once, in your own code. Make every tool return that object or a success value, never a provider-specific blob. Let a thin adapter per provider translate your object into the native envelope at the edge. The rule: if any tool source file mentions a provider name, your abstraction has leaked.

The three native envelopes, side by side

Here is what each provider actually accepts when you send a tool result back.

ProviderEnvelopeIdentifier fieldError signalContent type
OpenAImessage with role tooltool_call_idnone, must be embedded in stringstring
Anthropiccontent block of type tool_resulttool_use_idis_error booleanstring or list of blocks
GeminifunctionResponse objectnamenone, must be embedded in responseresponse object

For OpenAI, the current format uses a tools array with type function, replacing the older functions and function_call parameters the digitalapplied comparison. The result you send back is a message, not a content block, and its content is a string.

For Anthropic, your reply is a new user message whose content array holds a tool_result block. It needs tool_use_id to match the original call, an optional content field that can be a string, nested text or image blocks, and an optional is_error flag the Claude tool-call docs. The is_error flag is the cleanest error channel of the three because the model sees a dedicated signal rather than parsing text.

For Gemini, the tool result is a functionResponse with the function name and a response object. Teams that route Gemini through the OpenAI-compatible endpoint get the OpenAI shape instead, which is worth knowing before you write two adapters the Gemini OpenAI compatibility page.

A portable error schema

Five fields cover almost everything a model needs to recover or escalate.

{
  "code": "RATE_LIMITED",
  "message": "Weather API returned 429 after 3 attempts.",
  "retryable": true,
  "details": { "status": 429, "retry_after_seconds": 12, "tool": "get_weather" },
  "source": "tool"
}

Why these five:

  • code is a short stable token your code branches on, so logic never parses free text.
  • message is one sentence the model can read and reason about.
  • retryable tells the orchestration layer whether a silent retry is allowed.
  • details carries machine fields such as status codes or remaining budget.
  • source separates tool failures from provider failures, which route differently.

The retryable field is the most consequential. Transport and network failures and external service errors belong to the orchestration layer, which retries them silently; input validation failures and logic errors belong to the model, which must reread the payload and correct itself the n8n tool-calling guide. Encoding that split in one boolean is what stops an agent from retrying a malformed request forever.

Worked example

A search tool hits a 503 twice and a 400 once. Under the schema, the first two return retryable true with code TRANSPORT and source tool, so your orchestrator retries with exponential backoff and full jitter the n8n tool-calling guide. The third returns retryable false with code INVALID_ARGS, so the string reaches the model and it fixes the arguments. Without the flag, the agent would either retry the 400 three times (wasted tokens) or give up on the 503 (unnecessary failure).

Mapping each provider at the adapter layer

Keep one internal type and three small serializers. Python sketch:

import json

def to_openai(err, tool_call_id):
    return {"role": "tool", "tool_call_id": tool_call_id, "content": json.dumps(err)}

def to_anthropic(err, tool_use_id):
    return {"role": "user", "content": [{
        "type": "tool_result",
        "tool_use_id": tool_use_id,
        "content": json.dumps(err),
        "is_error": not err["retryable"] or err["source"] != "tool",
    }]}

def to_gemini(err, name):
    return {"functionResponse": {"name": name, "response": err}}

Two rules make this survive swaps. First, always emit the full object as JSON, even for OpenAI's string field, so the model sees the same five keys everywhere. Second, set is_error on Anthropic whenever the call did not succeed, since that flag is the provider's native error channel the Claude tool-call docs.

A useful subtlety: Anthropic allows an empty tool_result and content blocks that mix text, images and documents the Claude tool-call docs. That flexibility is a portability trap. If you return image blocks on Anthropic, your OpenAI and Gemini paths need a different plan, so decide early whether multimodal tool output is in scope.

What each model attends to in the result

The schema does not just move bytes; it changes behavior. Models read the envelope differently.

Anthropic's content-block architecture keeps tool calls separate from prose, and published tool-use reliability ratings put Anthropic ahead of Google and OpenAI in the period measured the digitalapplied comparison. A dedicated is_error flag is part of why: the error is a typed fact, not a sentence the model must classify.

OpenAI's string field means everything is prose-plus-JSON to the parser. Models handle it fine when the JSON is clean and well-keyed, but a truncated string or a code fence around the JSON can derail a turn. Always dump with a standard JSON serializer and never wrap it in markdown.

Gemini's response object is the most literal of the three: whatever dict you place under response is what the model receives as the tool output.

The behavior question that matters most is termination. Ambiguous tool feedback is a documented cause of reasoning loops, where tools return partial results or suggest more data might be available and the agent retries the AWS reasoning-loop post. In one demo, ambiguous feedback produced 14 calls while clear success states stopped in 2 the AWS reasoning-loop post. Your error schema should never contain phrases like "more may be available" without a terminal state; instead return a code and a retryable boolean.

Decision table for error routing

code familyretryablesourcewho acts
TIMEOUT, TRANSPORTtruetoolorchestrator retries with backoff
RATE_LIMITEDtrueproviderorchestrator waits, honors retry_after
INVALID_ARGSfalsetoolmodel corrects the payload
EMPTY_RESULTfalsetoolmodel changes approach or escalates
AUTH_DENIEDfalseproviderescalate to human, page on-call

Guard the loop, not just the payload

A clean schema makes failures legible, but legibility alone does not stop runaway agents. Classic circuit breakers catch timeouts and 500 errors and miss a model that returns a formatted answer with a 200 status and no real progress the resilience write-up. Your schema closes part of that gap by giving every failure a code the breaker can count, but you should still cap retries.

Standard breaker designs work at the tool level. One published pattern wraps each tool, opens after a failure threshold, allows one probe in half-open state, and resets on success, with agent-specific adaptations like token-aware thresholds and per-tool granularity the agent circuit breaker pattern. A concrete production example opens after 3 failures, holds for 30 seconds, closes after 2 successful probes, and shares state across replicas through Redis the Cordum breaker guide.

Feeding your code and retryable flag into that breaker is the practical link between the two problems: the schema says whether a retry is legal, and the breaker says whether a retry is currently allowed.

Budget guards belong here too. Step counters catch loops that wall-clock timeouts miss, and token budgets catch expensive individual steps that slip past both the timeout and circuit breaker guide. A token budget that refuses to spend past a ceiling is cheap insurance, and it pairs naturally with an error code such as BUDGET_EXHAUSTED that the model can read.

Testing for a model swap

Prove portability before you need it. A short checklist:

  1. Run one fixture conversation through all three adapters and diff the objects the model sees.
  2. Assert that retryable false errors never trigger an orchestrator retry.
  3. Assert that is_error is set on every failed Anthropic result.
  4. Assert that every OpenAI tool message parses as JSON.
  5. Assert that no adapter lets the model see the raw provider exception string.
  6. Replay a recorded failure trace against each provider and confirm the agent terminates rather than loops.

Keep the fixtures in your repo. When you add a provider, the failure is a test, not a production incident.

When to centralize the adapters

Two providers is a nuisance; four is a maintenance budget. If you are already routing multiple model families, a single gateway that normalizes requests, tool calls, and result envelopes removes the per-team adapter burden. Failures still route differently by layer, but the wire format becomes one contract the n8n tool-calling guide. Quanta Inference exposes text, image, video, audio and embedding models behind one OpenAI-compatible API key, which lets you keep a single adapter shape while you compare behavior.

Whatever gateway you choose, hold it to the same test: pass a failure through it and confirm you get your five fields back, unchanged.

FAQ

FAQ

Why not just return the raw exception string as the tool result?

Raw strings work until the model has to decide whether to retry. A raw stack trace gives it no structured signal, so it guesses, and guessing is how reasoning loops start the AWS reasoning-loop post. Serialize a small object with a code and a retryable flag instead.

Does my error shape need to match if I pass it through an OpenAI-compatible endpoint?

If a provider exposes an OpenAI-compatible mode, you can often reuse the OpenAI serializer and skip a native adapter the Gemini OpenAI compatibility page. Check which mode you are actually calling before you write two code paths.

Should the retryable flag control retries automatically?

No. Treat retryable as permission, not instruction. Feed it to a breaker that decides based on current tool health, with a failure threshold and a bounded reopen window the agent circuit breaker pattern. That keeps a legal retry from becoming a loop.

What belongs in the details field?

Only machine fields the model or your code will actually use: status codes, retry_after_seconds, the tool name, remaining budget. Keep details small, since every key costs input tokens on every turn, and the context grows with each step of the loop the LLM cost control post.

How do I know a model swap will not break my agent?

Replay a recorded failure trace against each adapter and assert on termination and on the fields the model sees. Anthropic's is_error path, OpenAI's string path and Gemini's response object path should all produce the same agent decision the Claude tool-call docs.

Sources

Sources

  1. Function Calling Guide: GPT, Claude & Gemini (2026)
  2. AI Function Calling Guide: OpenAI, Anthropic, Google
  3. OpenAI compatibility | Gemini API - Google AI for Developers
  4. Handle tool calls - Claude Platform Docs
  5. LLM Tool Calling Error Handling: Retries and Fallbacks
  6. How to Prevent AI Agent Reasoning Loops from Wasting ...
  7. Resilience Circuit Breakers for Agentic AI
  8. Agent Circuit Breaker
  9. AI Agent Circuit Breaker Pattern for Production Reliability
  10. AI Agent Timeout & Circuit Breaker Patterns | 2026 Guide
  11. AI Agents Don't Crash, They Spend: LLM Cost Control - sanj.dev
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles