From 2bd08c768ea3b23ef99d66b047bdd776bef2c5e8 Mon Sep 17 00:00:00 2001 From: Guillaume Gay Date: Fri, 21 Aug 2026 12:24:24 +0200 Subject: [PATCH] fix: fail the lifecycle sync on malformed deprecation entries The sync silently dropped entries that failed the deprecation schema, so schema drift on modeldeprecations.dev (e.g. a new status value) went unnoticed. Report each rejected entry with its zod reason and exit non-zero instead. --- src/sync/run.ts | 11 +++++++- src/sync/status.ts | 57 +++++++++++++++++++++++++++++++-------- tests/sync-status.test.ts | 14 ++++++++-- 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/sync/run.ts b/src/sync/run.ts index f3cf79c0..e07a40be 100644 --- a/src/sync/run.ts +++ b/src/sync/run.ts @@ -45,7 +45,16 @@ async function main(): Promise { throw new Error(`fetch ${url} returned HTTP ${response.status}`); } const payload = (await response.json()) as DeprecationsPayload; - const index = indexDeprecations(payload.models); + const { index, rejected } = indexDeprecations(payload.models); + + if (rejected.length > 0) { + for (const { entry, reason } of rejected) { + console.error(` ✖ ${JSON.stringify(entry)}\n ${reason}`); + } + throw new Error( + `${rejected.length} deprecation entr${rejected.length === 1 ? "y" : "ies"} failed schema validation (see above)`, + ); + } const files = await walkYamlFiles(MODELS_DIR); let matched = 0; diff --git a/src/sync/status.ts b/src/sync/status.ts index b11f5f47..d50b8e62 100644 --- a/src/sync/status.ts +++ b/src/sync/status.ts @@ -31,37 +31,72 @@ const DeprecationEntrySchema = z.object({ replacements: z.array(ReplacementSchema).optional(), }); -export function toLifecycleRecord(entry: unknown): LifecycleRecord | undefined { +function describeZodError(error: z.ZodError): string { + return error.issues + .map((issue) => `${issue.path.join(".") || ""}: ${issue.message}`) + .join("; "); +} + +export type LifecycleParseResult = + | { ok: true; record: LifecycleRecord } + | { ok: false; reason: string }; + +export function parseLifecycleRecord(entry: unknown): LifecycleParseResult { const parsed = DeprecationEntrySchema.safeParse(entry); - if (!parsed.success) return undefined; + if (!parsed.success) { + return { ok: false, reason: describeZodError(parsed.error) }; + } const { provider, model, status, shutdown_on, replacements } = parsed.data; const recommended = replacements?.find((replacement) => replacement.recommended) ?? replacements?.[0]; return { - provider, - model, - status, - replacement: recommended ? `${recommended.provider}/${recommended.model}` : undefined, - shutdownOn: shutdown_on ?? undefined, + ok: true, + record: { + provider, + model, + status, + replacement: recommended ? `${recommended.provider}/${recommended.model}` : undefined, + shutdownOn: shutdown_on ?? undefined, + }, }; } +export function toLifecycleRecord(entry: unknown): LifecycleRecord | undefined { + const result = parseLifecycleRecord(entry); + return result.ok ? result.record : undefined; +} + export function keyOf(provider: string, model: string): string { return `${provider}/${model}`; } -export function indexDeprecations(models: unknown): Map { +export interface RejectedEntry { + entry: unknown; + reason: string; +} + +export interface IndexResult { + index: Map; + rejected: RejectedEntry[]; +} + +export function indexDeprecations(models: unknown): IndexResult { if (!Array.isArray(models)) { throw new Error("deprecations payload `models` must be an array"); } const index = new Map(); + const rejected: RejectedEntry[] = []; for (const entry of models) { - const record = toLifecycleRecord(entry); - if (record) index.set(keyOf(record.provider, record.model), record); + const result = parseLifecycleRecord(entry); + if (result.ok) { + index.set(keyOf(result.record.provider, result.record.model), result.record); + } else { + rejected.push({ entry, reason: result.reason }); + } } - return index; + return { index, rejected }; } const LIFECYCLE_KEYS = /^(status|replacement|shutdownOn):/; diff --git a/tests/sync-status.test.ts b/tests/sync-status.test.ts index 53667b0b..793788aa 100644 --- a/tests/sync-status.test.ts +++ b/tests/sync-status.test.ts @@ -66,14 +66,24 @@ describe("toLifecycleRecord", () => { }); describe("indexDeprecations", () => { - it("keys records by provider/model", () => { - const index = indexDeprecations([ + it("keys records by provider/model and reports malformed entries", () => { + const { index, rejected } = indexDeprecations([ { provider: "anthropic", model: "claude-opus-4-1-20250805", status: "retired" }, { provider: "xai", model: "grok-4.5", status: "active" }, { junk: true }, ]); expect(index.size).toBe(2); expect(index.get("anthropic/claude-opus-4-1-20250805")?.status).toBe("retired"); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.reason).toContain("provider"); + }); + + it("rejects entries whose status is not a known lifecycle status", () => { + const { rejected } = indexDeprecations([ + { provider: "openai", model: "gpt-4", status: "sunset" }, + ]); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.reason).toContain("status"); }); it("rejects a non-array payload", () => {