How should teams evolve function schemas and Structured Outputs without breaking live conversations?
Version the model-facing schema, normalize accepted payloads into one canonical server shape, and use strict validation to evolve OpenAI function calling and Structured Outputs without breaking older conversations.
Changing a tool schema is easy when nothing has shipped yet. The hard part begins once older conversations, stored tool calls, and live handlers still expect the previous shape. In that situation, a single renamed field can break replay, and a newly required field can break the next turn of a conversation that started under the older contract.
The safe answer is not to freeze schema changes forever. It is to separate the model-facing schema from the server-facing adapter, version the wire shape, normalize accepted payloads into one canonical internal object, and reject everything else loudly enough that the failure is obvious before it reaches business logic.
The contract has two surfaces: what the model may propose and what the server will still accept
When a team changes a function schema, it is usually changing two different contracts at once. The first is the proposal surface: the tool definition or Structured Outputs schema that tells the model which shape to emit. The second is the server-side contract that accepts, normalizes, and executes that shape. Those surfaces can drift independently, and live conversations fail when they do. A field rename, a new requirement, or a stricter object shape is therefore a behavioral change, not a cosmetic one.
The OpenAI docs make that boundary visible. Function calling defines tools with JSON Schema and returns function_call items whose arguments your application must interpret. Structured Outputs uses a schema to constrain what the model can emit directly. In both cases, the schema is part of runtime behavior. A change to that schema can break an in-flight conversation even if the underlying business intent has not changed.
That is why the implementation in this article keeps the wire shape versioned and the application shape canonical. The model can be guided toward a newer schema while the server continues to recognize older shapes during a transition window. The goal is not to keep every old payload valid forever. The goal is to decide exactly which old payloads are still acceptable, which are rejected, and how accepted values become one stable internal object.
export const v1FunctionSchema = {
type: 'object',
additionalProperties: false,
required: ['orderId', 'items'],
properties: {
orderId: { type: 'string' },
items: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'qty'],
properties: {
id: { type: 'string' },
qty: { type: 'number' }
}
}
}
}
};
export const v2FunctionSchema = {
type: 'object',
additionalProperties: false,
required: ['order_id', 'items', 'priority'],
properties: {
order_id: { type: 'string' },
items: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'qty'],
properties: {
id: { type: 'string' },
qty: { type: 'number' }
}
}
},
priority: { type: ['boolean', 'null'] }
}
};
function matchesType(value, typeDef) {
if (Array.isArray(typeDef)) return typeDef.some((t) => matchesType(value, t));
if (typeDef === 'null') return value === null;
if (typeDef === 'string') return typeof value === 'string';
if (typeDef === 'number') return typeof value === 'number' && Number.isFinite(value);
if (typeDef === 'boolean') return typeof value === 'boolean';
if (typeDef === 'array') return Array.isArray(value);
if (typeDef === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
return false;
}
function checkObject(value, schema, path) {
if (!matchesType(value, schema.type)) {
return { ok: false, reason: `${path || 'root'} must be ${Array.isArray(schema.type) ? schema.type.join('|') : schema.type}` };
}
const properties = schema.properties || {};
const required = schema.required || [];
if (schema.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!Object.hasOwn(properties, key)) {
return { ok: false, reason: `${path || 'root'} additional property: ${key}` };
}
}
}
for (const key of required) {
if (!Object.hasOwn(value, key)) {
return { ok: false, reason: `${path || 'root'} missing required: ${key}` };
}
}
for (const [key, propSchema] of Object.entries(properties)) {
if (!Object.hasOwn(value, key)) continue;
const child = value[key];
const childPath = path ? `${path}.${key}` : key;
if (propSchema.type === 'array') {
if (!Array.isArray(child)) return { ok: false, reason: `${childPath} must be array` };
for (let i = 0; i < child.length; i++) {
const item = child[i];
if (propSchema.items?.type === 'object') {
const result = checkObject(item, propSchema.items, `${childPath}[${i}]`);
if (!result.ok) return result;
} else if (!matchesType(item, propSchema.items?.type)) {
return { ok: false, reason: `${childPath}[${i}] wrong type` };
}
}
continue;
}
if (propSchema.type === 'object') {
const result = checkObject(child, propSchema, childPath);
if (!result.ok) return result;
continue;
}
if (!matchesType(child, propSchema.type)) {
return { ok: false, reason: `${childPath} wrong type` };
}
}
return { ok: true };
}
export function validateStrict(args, schema) {
return checkObject(args, schema, '');
}
export function isRejectedBySchema(args, schema) {
const result = validateStrict(args, schema);
return result.ok ? { rejected: false } : { rejected: true, reason: result.reason };
}
export function adaptToCanonical(args) {
if (args && typeof args === 'object' && !Array.isArray(args)) {
if (Object.hasOwn(args, 'orderId')) {
return {
orderId: String(args.orderId),
items: Array.isArray(args.items)
? args.items.map((item) => ({ id: String(item.id), qty: Number(item.qty) }))
: [],
priority: false
};
}
if (Object.hasOwn(args, 'order_id')) {
return {
orderId: String(args.order_id),
items: Array.isArray(args.items)
? args.items.map((item) => ({ id: String(item.id), qty: Number(item.qty) }))
: [],
priority: args.priority === null ? false : Boolean(args.priority)
};
}
}
throw new Error('unrecognized argument shape: cannot adapt');
}
export function vetModelFunctionCall(argumentsJSON, schema) {
let parsed;
try {
parsed = JSON.parse(argumentsJSON);
} catch {
return { accepted: false, reason: 'invalid JSON' };
}
const validation = validateStrict(parsed, schema);
if (!validation.ok) return { accepted: false, reason: validation.reason };
return { accepted: true, canonical: adaptToCanonical(parsed) };
}
SourcesOpenAI Function Calling guide (opens a new tab)OpenAI Structured Outputs guide (opens a new tab)
Strict mode makes drift obvious, but strict mode alone does not solve compatibility
Strict schemas are useful because they fail loudly. The guides say that strict mode requires additionalProperties:false and that all fields must be required. They also say that unsupported schema keywords are rejected. That is the right behavior when the question is, “Did the model propose something outside the contract?” A loud failure is better than quietly handing a malformed payload to application code.
But strictness is not the same thing as compatibility. If you replace one schema with another in place, older conversations do not magically rewrite themselves. A payload that used `orderId` yesterday may still arrive tomorrow, even if the model is now encouraged to emit `order_id`. A schema that adds a required field can also strand a conversation that started under the previous version. Strict mode protects the boundary; it does not invent a migration path.
The fixture behind this article treats that distinction as the core design decision. Validation decides whether a payload is acceptable for a specific schema version. Adaptation decides whether an accepted payload can be translated into the canonical internal shape. Those are different jobs. If you collapse them into one step, you lose the ability to explain whether a failure came from the model proposal, the version bridge, or the business logic that follows.
SourcesOpenAI Function Calling guide (opens a new tab)OpenAI Structured Outputs guide (opens a new tab)
Keep one canonical internal shape and treat versioned wire schemas as adapters around it
The implementation uses two wire schemas. Version 1 exposes `orderId` and `items`. Version 2 renames the order field to `order_id` and adds `priority`. The important decision is that no downstream code is allowed to depend on either wire shape directly. Instead, the application consumes one canonical object: `{ orderId, items, priority }`. That canonical shape is what the rest of the system sees, stores, and reasons about.
The adapter function is the compatibility policy. If it sees a v1 payload, it reads `orderId` and defaults `priority` to `false`. If it sees a v2 payload, it reads `order_id` and converts `priority` from `true`, `false`, or `null` into the same canonical field. If it sees neither shape, it throws. That failure is intentional. An unrecognized payload should not be guessed into existence just because the surrounding workflow would prefer a best-effort answer.
This separation keeps schema evolution local. You can introduce a newer model-facing schema without forcing every consumer to understand both wire formats forever. At the same time, you can keep replayed conversations alive during a migration because the adapter remains responsible for the old version. The rest of the app only sees one stable representation, which is exactly the shape you want for business logic and persistence.
SourcesOpenAI Structured Outputs guide (opens a new tab)OpenAI Function Calling guide (opens a new tab)
The tests prove acceptance, rejection, and normalization at the boundary
The regression tests are small on purpose. One test feeds a v2-shaped JSON string into the v2 schema and expects the canonical normalized object in return. That proves the accepted path: a current payload can pass validation and be adapted into the internal contract. Another test removes the `items` field and expects a rejection that names the missing required field. Another adds an unexpected property and expects `additionalProperties:false` to reject it. Those are the exact failure modes the documentation warns about, and the test names make those failures visible.
A separate test covers the v1 shape. It validates that the older payload still passes the v1 schema and that the adapter fills the canonical default for `priority`. That is the compatibility window this article is really about. The server can keep old conversations alive while the model moves forward to the newer wire shape. If you removed the adapter and swapped only the schema, that older payload would stop being safe to replay.
The last test throws on an unrecognized object. That matters because the adapter is not a permissive parser. It should not guess at missing fields or synthesize meaning from a random object. The point of the boundary is to recognize known versions and fail closed on everything else. The test suite proves that distinction, but it does not prove anything about the live OpenAI service or about a full agent loop. It proves the contract that the application owns.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
v1FunctionSchema,
v2FunctionSchema,
validateStrict,
isRejectedBySchema,
adaptToCanonical,
vetModelFunctionCall
} from './main.mjs';
test('accepts a v2-shaped tool call and normalizes it to the canonical shape', () => {
const call = JSON.stringify({
order_id: 'ABC-123',
items: [
{ id: 'sku-1', qty: 2 },
{ id: 'sku-2', qty: 1 }
],
priority: true
});
const result = vetModelFunctionCall(call, v2FunctionSchema);
assert.equal(result.accepted, true);
assert.deepEqual(result.canonical, {
orderId: 'ABC-123',
items: [
{ id: 'sku-1', qty: 2 },
{ id: 'sku-2', qty: 1 }
],
priority: true
});
});
test('rejects a v2-shaped call that omits a required field', () => {
const parsed = {
order_id: 'ABC-123',
priority: true
};
const result = isRejectedBySchema(parsed, v2FunctionSchema);
assert.equal(result.rejected, true);
assert.match(result.reason, /missing required: items/);
});
test('rejects an unexpected extra property under strict schema rules', () => {
const parsed = {
orderId: 'OLD-7',
items: [{ id: 'sku-9', qty: 3 }],
unexpected: 'not allowed'
};
const result = validateStrict(parsed, v1FunctionSchema);
assert.equal(result.ok, false);
assert.match(result.reason, /additional property: unexpected/);
});
test('accepts a v1-shaped call and fills the canonical default for priority', () => {
const parsed = {
orderId: 'OLD-42',
items: [{ id: 'sku-9', qty: 3 }]
};
const validation = validateStrict(parsed, v1FunctionSchema);
assert.equal(validation.ok, true);
const canonical = adaptToCanonical(parsed);
assert.deepEqual(canonical, {
orderId: 'OLD-42',
items: [{ id: 'sku-9', qty: 3 }],
priority: false
});
});
test('throws when the payload does not match any known wire shape', () => {
assert.throws(() => adaptToCanonical({ foo: 'bar' }), /unrecognized argument shape/);
});
SourcesOpenAI Function Calling guide (opens a new tab)OpenAI Structured Outputs guide (opens a new tab)
The hard cases are policy decisions: renames, null defaults, and unsupported schema features
Two edge cases need an explicit policy, because the docs do not decide them for you. The first is a rename. A rename is a breaking change unless the compatibility layer understands both names during the migration window. If a live conversation still emits the old field name and the new handler only knows the new one, the conversation breaks even if the rest of the payload is valid. That is why version markers and adapters matter: they let you recognize which shape you received instead of guessing from a partially overlapping set of keys.
The second edge case is null as an optional stand-in. Structured Outputs requires all fields to be required, so the docs recommend a null union when you need an optional-like field. In the fixture, `priority` is declared as `['boolean', 'null']`, which makes the field required while still allowing the model to express “present, but undecided” as null. The adapter then chooses the application default of `false` for v1 payloads and converts a v2 null to the same canonical meaning. That keeps the schema valid without pretending the model can omit the field entirely.
A third edge case is schema shape itself. The Structured Outputs guide rejects root-level anyOf, requires additionalProperties:false, and excludes several JSON Schema keywords. That means a migration plan cannot rely on the broadest possible schema language. If a desired compatibility shape depends on unsupported constructs, move the compatibility logic into the server adapter instead of trying to force the schema to carry every rule. The schema should constrain proposal shape; the adapter should carry compatibility policy.
SourcesOpenAI Function Calling guide (opens a new tab)OpenAI Structured Outputs guide (opens a new tab)
The practical rule: version the wire shape, normalize at the boundary, and keep the failure visible
The simplest rule that survives production pressure is this: version the schema the model sees, normalize at the server boundary, and never let downstream code depend on the wire shape. That rule gives you a migration window, a single canonical internal object, and a clear place to reject malformed proposals. It also keeps the rollout reversible. If the new schema causes trouble, you can keep accepting the older one while you revise the model-facing hint or the adapter.
That approach matches the official guidance instead of fighting it. The function-calling guide explains how to define tools and handle function calls. The Structured Outputs guide explains which schema shapes are supported and which are rejected. What those docs do not give you is a live-migration playbook for conversations already in flight. The versioned-schema pattern fills that gap without pretending the platform will maintain compatibility for you.
The useful outcome is practical, not theoretical. New conversations can move toward the newer schema. Old conversations can continue to resolve through the adapter while they are still valid. Invalid payloads still fail loudly. And every downstream consumer sees the same canonical shape, which keeps the rest of the application simple even while the wire contract changes around it.
SourcesOpenAI Structured Outputs guide (opens a new tab)OpenAI Function Calling guide (opens a new tab)


