Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/sync/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,16 @@ async function main(): Promise<void> {
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;
Expand Down
57 changes: 46 additions & 11 deletions src/sync/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(".") || "<root>"}: ${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<string, LifecycleRecord> {
export interface RejectedEntry {
entry: unknown;
reason: string;
}

export interface IndexResult {
index: Map<string, LifecycleRecord>;
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<string, LifecycleRecord>();
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):/;
Expand Down
14 changes: 12 additions & 2 deletions tests/sync-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading