diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 3229b1567..f2dc712c2 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -63,7 +63,10 @@ export interface SyncProvider { parseModels(raw: unknown): SourceModel[]; translateModel( model: SourceModel, - context: { existing(id: string): ExistingModel | undefined }, + context: { + existing(id: string): ExistingModel | undefined; + authored(id: string): ExistingModel | undefined; + }, ): { id: string; model: SyncedModel; metadata?: { id: string; model: SyncedMetadata } } | undefined; } @@ -141,6 +144,9 @@ export async function syncProvider( existing(id) { return existing.get(`${id}.toml`)?.toml; }, + authored(id) { + return existing.get(`${id}.toml`)?.authored; + }, }); if (translated === undefined) { if (provider.sourceID !== undefined) skippedRemote.push(provider.sourceID(sourceModel)); @@ -205,7 +211,7 @@ export async function syncProvider( desired.set(relativePath, { model: parsed.data, - content: formatToml(parsed.data), + content: (existing.get(relativePath)?.header ?? "") + formatToml(parsed.data), }); } @@ -216,10 +222,11 @@ export async function syncProvider( for (const [relativePath, file] of desiredMetadata) { const filePath = path.join(metadataDir, relativePath); const currentFile = Bun.file(filePath); - const current = await currentFile.exists() + const currentText = await currentFile.exists() ? await currentFile.text() : undefined; + const current = currentText !== undefined ? ModelMetadata.safeParse({ id: relativePath.slice(0, -5), - ...Bun.TOML.parse(await currentFile.text()) as Record, + ...Bun.TOML.parse(currentText) as Record, }) : undefined; if (current?.success && stable(current.data) === stable(file.model)) continue; @@ -228,7 +235,7 @@ export async function syncProvider( console.log(`Would ${current === undefined ? "create" : "update"} metadata ${relativePath}`); } else { await mkdir(path.dirname(filePath), { recursive: true }); - await Bun.write(filePath, file.content); + await Bun.write(filePath, (currentText !== undefined ? leadingComments(currentText) : "") + file.content); } } @@ -394,6 +401,7 @@ async function readExisting(modelsDir: string) { const existing = new Map(); const brokenSymlinks = new Set(); @@ -425,7 +433,7 @@ async function readExisting(modelsDir: string) { ? authored : resolveBaseModel(authored, modelMetadata ?? {}, filePath); - existing.set(file, { authored, toml, symlink }); + existing.set(file, { authored, toml, header: leadingComments(text), symlink }); } return { models: existing, brokenSymlinks, modelMetadata }; @@ -675,6 +683,23 @@ function quote(value: string) { return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; } +// Preserve the leading comment block (header) authored at the top of a TOML file. +// `Bun.TOML.parse` discards comments, so the serializer must re-attach them or +// every rewrite would silently delete hand-authored documentation. +function leadingComments(text: string) { + const header: string[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + header.push(line); + } else { + break; + } + } + while (header.length > 0 && header[header.length - 1]?.trim() === "") header.pop(); + return header.length > 0 ? `${header.join("\n")}\n` : ""; +} + function formatInteger(n: number) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, "_"); } diff --git a/packages/core/src/sync/providers/openrouter.ts b/packages/core/src/sync/providers/openrouter.ts index 57c94a14d..29fda1ba6 100644 --- a/packages/core/src/sync/providers/openrouter.ts +++ b/packages/core/src/sync/providers/openrouter.ts @@ -7,7 +7,6 @@ import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from " const API_ENDPOINT = "https://openrouter.ai/api/v1/models"; const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); -const MODEL_NAME_BLACKLIST = ["fable-5"]; const modelMetadataByID = new Map>(); const modelMetadataFilesByProvider = new Map>(); @@ -91,12 +90,18 @@ export const openrouter = { return response.json(); }, parseModels(raw) { - return OpenRouterResponse.parse(raw).data.filter((model) => { - const name = `${model.id} ${model.name}`.toLowerCase(); - return MODEL_NAME_BLACKLIST.every((value) => !name.includes(value)); - }); + return OpenRouterResponse.parse(raw).data; }, translateModel(model, context) { + // OpenRouter serves deprecated/unavailable routes as degraded stubs: + // negative pricing (`"-1"`) and an empty `supported_parameters` array. Syncing + // those would wrongly flip `reasoning`/`tool_call`/`structured_output` to false + // and strip `reasoning_options`. Leave the authored file untouched instead, and + // skip the model entirely when we have nothing to preserve. + if (isUnavailable(model)) { + const authored = context.authored(model.id); + return authored === undefined ? undefined : { id: model.id, model: authored as SyncedModel }; + } return { id: model.id, model: buildOpenRouterModel(model, context.existing(model.id)), @@ -104,6 +109,14 @@ export const openrouter = { }, } satisfies SyncProvider; +function isUnavailable(model: OpenRouterModel) { + return ( + model.supported_parameters.length === 0 || + Number(model.pricing.prompt) < 0 || + Number(model.pricing.completion) < 0 + ); +} + function dateFromTimestamp(timestamp: number) { return new Date(timestamp * 1000).toISOString().slice(0, 10); } diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 21800ffd0..feed50df4 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -1,7 +1,10 @@ import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; -import { formatToml, preserveReasoningOptions } from "../src/sync/index.js"; -import { buildOpenRouterModel, type OpenRouterModel } from "../src/sync/providers/openrouter.js"; +import { formatToml, preserveReasoningOptions, syncProvider, type SyncProvider } from "../src/sync/index.js"; +import { buildOpenRouterModel, openrouter, type OpenRouterModel } from "../src/sync/providers/openrouter.js"; test("formats interleaved as a root field before reasoning option tables", () => { const content = formatToml({ @@ -148,6 +151,113 @@ test("upgrades empty OpenRouter reasoning options from model metadata", () => { }); }); +test("preserves the authored header comment block when rewriting a changed model", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "sync-header-")); + const modelsDir = path.join(dir, "providers", "example", "models"); + await Bun.write(path.join(modelsDir, "example-model.toml"), [ + "# Documented quirk: this route needs a manual note.", + "# https://example.com/docs (accessed 2026-06-25)", + 'name = "Example Model"', + 'release_date = "2026-01-01"', + 'last_updated = "2026-01-01"', + "attachment = false", + "reasoning = false", + "tool_call = true", + "open_weights = false", + "", + "[cost]", + "input = 1", + "output = 2", + "", + "[limit]", + "context = 1_000", + "output = 100", + "", + "[modalities]", + 'input = ["text"]', + 'output = ["text"]', + "", + ].join("\n")); + + const provider: SyncProvider<{ id: string }> = { + id: "example", + name: "Example", + modelsDir, + deleteMissing: false, + async fetchModels() { + return [{ id: "example-model" }]; + }, + parseModels(raw) { + return raw as { id: string }[]; + }, + translateModel(model) { + return { + id: model.id, + model: { + name: "Example Model", + release_date: "2026-01-01", + last_updated: "2026-01-01", + attachment: false, + reasoning: false, + tool_call: true, + open_weights: false, + cost: { input: 3, output: 9 }, + limit: { context: 1_000, output: 100 }, + modalities: { input: ["text"], output: ["text"] }, + }, + }; + }, + }; + + try { + const result = await syncProvider(provider); + expect(result.updated).toBe(1); + const written = await readFile(path.join(modelsDir, "example-model.toml"), "utf8"); + expect(written).toStartWith( + "# Documented quirk: this route needs a manual note.\n# https://example.com/docs (accessed 2026-06-25)\n", + ); + expect(written).toContain("input = 3"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("retains authored data when OpenRouter reports an unavailable stub", () => { + const authored = { + name: "Claude Fable Latest", + reasoning: true as const, + reasoning_options: [{ type: "effort" as const, values: ["low", "high"] as const }], + tool_call: true as const, + structured_output: true as const, + }; + const translated = openrouter.translateModel(unavailableStub(), { + existing: () => undefined, + authored: () => authored as never, + }); + + expect(translated).toEqual({ id: "~anthropic/claude-fable-latest", model: authored as never }); +}); + +test("skips an unavailable OpenRouter stub with no authored file", () => { + const translated = openrouter.translateModel(unavailableStub(), { + existing: () => undefined, + authored: () => undefined, + }); + + expect(translated).toBeUndefined(); +}); + +function unavailableStub(): OpenRouterModel { + return openRouterModel({ + id: "~anthropic/claude-fable-latest", + name: "Anthropic: Claude Fable Latest", + supported_parameters: [], + pricing: { prompt: "-1", completion: "-1" }, + reasoning: { mandatory: true }, + top_provider: { context_length: null, max_completion_tokens: null }, + }); +} + function openRouterModel(overrides: Partial = {}): OpenRouterModel { return { id: "anthropic/claude-sonnet-5",