Skip to main content
The schema a model is handed is generated by the contract, not the one you wrote — so it can accept forms the wire never will. Three mistakes models make reliably are absorbed at the boundary instead of coming back as a failed call. There is a fourth mistake that is deliberately not absorbed, and it is the last section on this page: an argument the schema does not declare is refused rather than dropped.

'{"requests": [...]}' becomes {"requests": [...]}

Models sometimes serialize a nested object as a JSON string instead of passing it as an object. A model_validator(mode="before") finds any BaseModel/list/dict-typed field whose value is a string starting with { or [, and parses it before field validation runs.

"dateTime" becomes date_time

Models send camelCase keys inside nested objects, matching the API’s own docs. A schema that answered only to its own field names would drop the key as unknown: the field stays None, the API receives {}, and returns a confusing 400 with no local validation error at all. So the contract answers to all three spellings. Every field carries camelCase, PascalCase and snake_case aliases — dateTime, DateTime and date_time are all valid input — and this holds whichever convention the schema’s author wrote in. Stripe’s schemas are snake_case and Gmail’s are camelCase; both resolve in both directions. All three are declared aliases rather than merely tolerated ones, which is what makes the refusal in the next section safe: with unknown keys refused, a spelling that is only “also allowed” would become an error. model_dump() is unaffected — it still returns snake_case field names, so the key-case conversion at serialization time works exactly as before.

An argument the schema does not declare is refused

Pydantic’s default is to ignore unknown keys. At a tool boundary that is the worst available behaviour, because the result is not an error — it is a wrong answer that nothing marks as wrong. A model that invents a filter, or mistypes a real one, previously got a successful call and an unfiltered page:
That sent limit=2, returned two arbitrary customers, and said nothing anywhere about the filter it had thrown away. The agent then reasons over rows it believes were filtered. The same happens for a plausible typo — emails for email — which is the more likely mistake of the two. So generated schemas set extra="forbid", and the call above raises ToolValidationError before the network:
which is a message a model can read and correct, in the retry loop this library already has. to_json_schema() carries additionalProperties: false for the same reason, on the root and on every nested object — so a provider enforcing strict schemas mostly prevents the mistake before a call is made at all.
This tightened in a way a host application can notice. If you were passing an extra key to ainvoke and relying on it being ignored, that call now raises. The fix is to remove the key, or to declare it on the schema if it should have been on the wire. Per-call headers are unaffected: they are a keyword-only argument, not part of the schema, and never were.

A rejection the model can act on

Left to the agent framework, a rejected call comes back wrapped in that framework’s own template, with the entire kwargs payload dumped at the model:
Charter raises ToolValidationError carrying a compact rendering instead:
The structured per-field errors are on the exception too (.errors), so a host application can render its own version.

Malformed JSON says why, not just what

When the JSON-string coercion above fails, the naive behaviour is to pass the string through and let type checking complain:
That is a type error. The model concludes it sent the wrong structure, retries with different content and the same broken syntax, and loops forever — because the real problem was a syntax error, such as one extra closing brace. Charter raises with the decoder’s own detail:
Now the model knows it is a syntax problem and where. The check is unambiguous: a string starting with { or [ for a BaseModel/list/dict-typed field is always an attempted JSON serialization — there is no other case.