Problem statement
When you change a function-calling or Structured Outputs schema after users have active conversations, older turns and replayed tool calls may still emit the previous wire shape. A simple rename or a newly required field can break the next turn of a conversation that began under the old contract.
Implementation approach (high level)
1) Version the wire schema the model sees. Keep a v1, v2, etc. definition for the JSON the model is encouraged to emit.
2) Validate strictly at the server boundary. Use additionalProperties:false and explicit required lists to make drift visible — failures should be loud and explainable.
3) Normalize into one canonical internal object. Implement an adapter that recognizes known wire versions and translates them into the single canonical shape your application consumes.
4) Keep adapter policy explicit. The adapter is where you decide how to treat renames, nulls-as-optional stand-ins, defaults, and other compatibility choices. Treat unknown shapes as a closed failure.
Expected behavior
- New conversations can be guided toward the newest wire schema without immediately invalidating older ones.
- Replayed or still-in-flight conversations that emit older shapes can be accepted during the compatibility window because the adapter knows how to read them and normalize them into the canonical object.
- Invalid payloads still fail loudly at the validation boundary, making it clear whether the failure is: a model proposal outside the schema, a missing required field for that wire version, or an unrecognized payload that needs policy work.
Limitations and operational notes
- This pattern does not provide infinite backward compatibility. You must decide how long to accept old wire versions and when to remove adapter logic.
- The Structured Outputs and Function Calling guides restrict certain schema features (root-level anyOf, permissive keywords). If compatibility needs rely on unsupported JSON Schema constructs, implement that logic in the adapter rather than the schema.
- Policy decisions matter: renames, null for optional, defaulting, and whether adapters synthesize missing data are organizational choices. Make them explicit and test them.
Why this matters
Separating the model-facing contract from the server adapter keeps downstream code simple and stable while letting the model-facing schema evolve. It makes rollouts reversible, migration windows explicit, and failures actionable.
(Implementation example is inserted by the server.)
Working example
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) };
}
Read the full article: https://chriseugenerodriguez.com/blog/evolving-function-schemas-without-breaking-live-conversations
Top comments (1)
Treating unknown shapes as a closed failure is the part I'd keep. How do you decide when the compatibility window closes: a fixed date, or by logging which wire version the replayed turns still send?