Two models. Same prompt, same tool description, same request. One of them returned this:
{ "kind": "entity", "entityName": "todo", "definition": { "fields": { "title": "text" } } }
The other returned this:
{ "kind": "entity", "name": "todo", "fields": { "title": "text" } }
The second one is wrong, and our downstream patcher rejected it with a 422 that told nobody anything useful. What took me a while to work out was why the first model got it right, because the answer turned out to have nothing to do with being smarter.
This was May 2026, Opus 4.7 and Sonnet 4.6 at the time. The story generalizes to whatever pair of models you're holding today.
We have a tool called apply_patches. An LLM reads a user request plus a source file and emits a list of structural change operations: add this entity, replace that handler, remove that metric. Each operation carries a pattern, the canonical object form of the thing being changed.
The tool schema for that pattern parameter was, in effect:
{ "type": "object", "properties": { "kind": { "type": "string" } }, "required": ["kind"] }
kind is a string and everything else is whatever. The actual shape lived in the tool description, a paragraph of prose with examples, the way most people write tool definitions.
The big model complied anyway. The smaller one didn't. Both had read the same description.
It had seen the shape before.
entityName and definition.fields are our field names, from our framework. To a model with training exposure to that shape, "emit an entity pattern" retrieves a memory. To a model without it, "emit an entity pattern" is a guess from the description text, and if you're guessing what an entity looks like, { name, fields } is a better guess than the truth. It's what everyone else's API would call those things.
So this is an exposure gap rather than a capability gap, which matters because you can't fix an exposure gap by paying for a bigger model. It will show up for any model on any shape that isn't in its training data, which is to say on your proprietary shapes, indefinitely. The less your schema looks like the rest of the internet, the harder your description has to work. And descriptions are not what the model is validated against.
The tool schema is. So we moved the contract into it.
We have around twenty pattern kinds. Nine of them account for roughly 85% of everything the model emits. The other dozen (relation, workspace, secret, claimKey, systemScope and friends) show up rarely.
Writing strict schemas for all twenty would have been a week of work and a permanent maintenance tax, so we didn't. Each common kind became a discriminated oneOf branch with a real required list:
{
title: "EntityPattern",
properties: {
kind: { const: "entity" },
entityName: { type: "string" },
definition: { type: "object" },
},
required: ["kind", "entityName", "definition"],
}
The long tail got one fallback branch that requires nothing but kind:
{
title: "OtherPattern",
properties: {
kind: {
type: "string",
not: {
enum: [
"entity", "requires", "toggleable", "nav", "writeHandler",
"queryHandler", "hook", "notification", "metric",
],
},
},
},
required: ["kind"],
}
Rare kinds still go through unvalidated at the schema layer, and the runtime patcher catches them. That split, tight on the discriminator values you see constantly and permissive on the ones you don't, is the part worth stealing. It costs an afternoon instead of a week and it targets the failures you actually get.
We did the same for the natural keys that replace and remove operations use, and pinned the per-operation requirements with allOf plus if/ then, so the model can't hand us a replace with nothing to replace:
allOf: [
{ if: { properties: { op: { const: "replace" } } }, then: { required: ["id", "pattern"] } },
{ if: { properties: { op: { const: "add" } } }, then: { required: ["pattern"] } },
{ if: { properties: { op: { const: "remove" } } }, then: { required: ["id"] } },
]
Both Anthropic and OpenAI honor oneOf and allOf/ if/ then in tool input schemas. Most people skip them because the flat version works well enough on whichever model they tested with.
The evidence is thinner than I'd like, and it points the right way.
We ran three fixtures live, twice, for about $0.18 total. Before the change: two passed, one failed. The failure was the rename-entity case, emitting { kind: "entity", name, fields }. After: three passed, and the fixture that had been failing emitted { kind: "entity", entityName: "todo", definition: { fields: ... } }, byte for byte the shape the big model had been producing all along.
Three fixtures is not a benchmark. What convinced me was which failure disappeared and what replaced it. The smaller model stopped inventing field names and started producing the canonical shape, on the exact case that had been failing.
The real payoff came later. Once the smaller model could reliably emit the structured shape, it became viable as the default. That's usually the whole business case for schema work: a tight schema is what makes the cheap model good enough.
Two weeks later, a code review caught something the tests hadn't.
For replace and remove operations we have a parallel set of variants describing just the natural key. One of them is a singleton fallback: a kind and nothing else. And { kind: "entity" } matched both the entity branch and the fallback branch.
oneOf means exactly one. Two matches is a violation. Anthropic tolerated it; OpenAI's strict mode rejects the schema outright. Same schema, one provider silently fine, the other refusing to run.
The fix is the not.enum you saw above, where the fallback explicitly excludes every kind that has its own branch. Worth internalizing if you're building discriminated unions in JSON Schema: a fallback branch is not automatically disjoint from the specific ones. You have to make it disjoint by hand, and a test has to hold it that way, because the day someone adds a tenth specific branch and forgets the exclusion list is the day one of your providers starts 400ing.
Different bug, same week. A generate_feature call came back with featureName, packageDescription, rationale, and no source. Source being the entire point of the call.
The model had emitted rationale first, written about 700 tokens of thoughtful design commentary, and hit maxTokens: 4000 before it got to the field that mattered. Nothing errored. We just got a well-argued explanation of a file that didn't exist.
Three fixes, in descending order of how much I trust them:
source.minLength: 100 and rationale.maxLength: 600, plus a blunt "BRIEF, 2-3 sentences" in the description. These are real constraints and they're what actually holds.maxTokens to 8000 for this one tool. The bounded tools stayed at 4000, since an unbounded budget everywhere just makes the truncation rarer and weirder.source first in the schema. Anthropic has been observed to emit arguments in declaration order. Observed, not documented. It costs nothing, it might help, and if a provider update changes it tomorrow nothing fails loudly. I left a comment in the source saying exactly that, and you should treat it the same way: a hint, not a contract.
Scores on the affected fixture went from 0.50 to 0.85, on both models, which is the tell that this was never a model-quality issue. The remaining 0.15 is genuine content quality: the model doesn't reach for one of our helpers when it should. That's a prompt and few-shot problem, and no amount of schema work will fix it. Knowing which of your failures are schema-shaped saves you from tightening things that were never loose.
These schemas are a hand-written mirror of our framework's real pattern types. Two definitions of the same shape, in two files, with nothing but discipline between them. When someone adds a required field to the real type, the schema doesn't know.
We pin what we can in a contract test: the number of variants, the required-field list per kind, the not.enum exclusion list. Drift fails a test instead of confusing a model in production six weeks later. That's a smoke alarm rather than a solution. If you generate your tool schemas from your actual types, you're ahead of us. If you're hand-writing them like we are, at least pin them.
The obvious objection: you just made your prompt bigger, on every single call.
About 3KB bigger, in our case. With prompt caching that's one cache write at 1.25× input rate, roughly $0.0002 for the schema chunk, and every subsequent call in the cache window reads it at 10%. The schema sits in the stable prefix, which is exactly where caching is designed to put it.
Structured output used to carry a real tradeoff between schema size and bill. With caching it mostly doesn't. If token cost is what's keeping your tool definitions vague, go measure it. The number is probably smaller than the cost of one confusing 422 in production.
oneOf will bite you on the strictest provider you support.