Tool Arguments That Arrive as a JSON String, Not an Object: Parsing and Validating Tool Calls Across Six Backends
Across Claude, GPT, DeepSeek, Qwen, GLM, and Kimi, tool call arguments may arrive either as a JSON-encoded string or as an already-parsed object. This guide explains why the shape varies, shows a defensive normalization step, and adds schema validation so one code path works for every backend behind a single API key.
Why the shape differs
When a model emits a tool call, the arguments are conceptually a JSON document. How that document is delivered to your client is an implementation detail of each backend and SDK.
- Some backends return
argumentsas a string containing serialized JSON, which you must parse. - Others return it as an already-decoded object.
- Streaming paths may accumulate partial string fragments that only become valid JSON once the stream completes.
- A few paths may return an empty string or
nullwhen a tool takes no arguments.
If you assume a single shape, your handler breaks the moment you route to a different model. The robust answer is to normalize before you validate, and validate before you act.
A defensive normalization step
Treat the raw value as untrusted and coerce it into a plain object. The function below handles the common cases without assuming any one backend.
import json
from typing import Any, Mapping
def normalize_arguments(raw: Any) -> dict:
if raw is None:
return {}
if isinstance(raw, Mapping):
return dict(raw)
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", errors="strict")
if isinstance(raw, str):
text = raw.strip()
if not text:
return {}
parsed = json.loads(text)
if not isinstance(parsed, dict):
raise ValueError("tool arguments must decode to an object")
return parsed
raise TypeError(f"unsupported arguments type: {type(raw).__name__}")
Key decisions:
- Empty means empty object. A missing or blank payload for a no-argument tool should not raise.
- Objects pass through. Copy them so downstream code cannot mutate a shared reference.
- Strings get parsed once. Do not loop or retry blindly; a parse failure is a real error you want to surface.
- Reject non-objects. A bare array or scalar is not a valid tool argument payload.
Schema validation after parsing
Parsing only proves the bytes are JSON. It does not prove the fields are correct. Follow normalization with schema validation, ideally against the same schema you expose to the model.
from jsonschema import validate, ValidationError
def prepare_arguments(raw: Any, schema: dict) -> dict:
args = normalize_arguments(raw)
try:
validate(instance=args, schema=schema)
except ValidationError as e:
raise ValueError(f"invalid tool arguments: {e.message}") from e
return args
Practical schema rules:
- Set
additionalProperties: falsewhen you control the tool definition, so unexpected keys fail loudly. - Mark only genuinely optional fields as optional; a model that omits a required field should trigger a correction turn, not a silent default.
- Keep types strict. Do not accept
"5"where you declaredinteger; coerce deliberately if your product needs it. - Validate enums and ranges in the schema rather than in scattered checks.
Handling streaming fragments
If you stream tool calls, the arguments value often arrives in pieces. Do not parse each chunk. Buffer the concatenated string per tool-call index, and normalize plus validate only when the call is complete.
- Key the buffer by tool call id or index, not by arrival order alone.
- Skip validation until the backend signals the call is finished.
- Apply the same
prepare_argumentsat the end, so streaming and non-streaming paths converge.
Error handling and repair loops
A normalization or validation failure is recoverable in most agent loops. Return the error text to the model as a tool result and let it retry with corrected arguments. Guard this with a retry cap so a malformed call cannot loop forever. Distinguish:
- Parse errors — the payload was not valid JSON; usually a truncation or stream-assembly bug.
- Validation errors — valid JSON, wrong shape; usually the model needs the schema echoed back.
Log the raw value (redacting sensitive fields) alongside the normalized result so you can tell which backend produced which shape.
One client, many backends
Because the shape varies per backend, centralize normalization rather than patching each call site. A single wrapper that does normalize → validate → dispatch keeps your handlers identical whether the call went to Claude, GPT, DeepSeek, Qwen, GLM, or Kimi.
Routing many models through one endpoint with one API key makes that wrapper the natural place to enforce the contract. On this platform you top up with USDC on Base without KYC, call the supported models with a single key, and pay official price × 1.3. Contributors who help improve the service are credited official price × 1.1 (premium × 1.2) in USDC.
Checklist
- Normalize
argumentsbefore any business logic runs. - Treat empty or missing payloads as
{}. - Parse strings exactly once; surface parse failures.
- Validate against a strict schema, with
additionalProperties: falsewhere possible. - Buffer streamed fragments and validate only when complete.
- Return validation errors to the model with a retry cap.
- Log raw and normalized values per backend for debugging.