diff --git a/docs/plugins/manifest/models.md b/docs/plugins/manifest/models.md index 1b48778db9f7..4032a364d174 100644 --- a/docs/plugins/manifest/models.md +++ b/docs/plugins/manifest/models.md @@ -124,6 +124,29 @@ Provider fields: | `defaultUtilityModel` | `string` | Optional provider-recommended small model id for short internal utility tasks (titles, progress narration). Used when `agents.defaults.utilityModel` is unset and this provider serves the agent's primary model. | | `models` | `object[]` | Required model rows. Rows without an `id` are ignored. | +`recommendedModels` is an optional ordered shortlist of distinct model +ids from this provider's `models`. Ids are trimmed and must be non-empty. The field +is reserved for picker ordering and is not yet used. It is published only in catalog +v2, never v1. Invalid manifest lists are omitted; invalid remote v2 lists are rejected. + +The catalog generator opts into local paired output with `--out --out-v2 `. +It validates both bundles and prepares candidate bytes and previous-file backups +before replacing either output. Paired destinations must be distinct regular files +or absent; output-directory aliases are resolved before preparation. The v1-only +writer is unchanged. + +Each file is replaced separately: this is **not** a multi-file atomic transaction. +Use a single publisher and do not serve or deploy the pair until the command succeeds. +If publication fails or the process stops between replacements, inspect the +`.catalog-pair-*` directories beside both outputs. Each contains `next.json`, +`previous.json` when the output existed, and `RECOVERY.txt` mapping both destinations +and recovery directories. Stop competing writers, compare the current outputs with +these artifacts, and explicitly restore or finish the pair before retrying. There +is no automatic rollback or replay that could overwrite another writer's replacement. +Identity checks detect observed changes but are not filesystem compare-and-swap; +this protocol does not promise power-loss durability. After successful publication, +cleanup failures warn with retained paths without reporting the pair as unpublished. + Model fields: | Field | Type | What it means | diff --git a/packages/model-catalog-core/src/model-catalog-normalize.test.ts b/packages/model-catalog-core/src/model-catalog-normalize.test.ts index 92201b8c4727..9c036546ff0a 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.test.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.test.ts @@ -4,6 +4,36 @@ import { normalizeModelCatalog, normalizeModelCatalogProviderRows } from "./inde import { buildModelCatalogMergeKey, buildModelCatalogRef } from "./model-catalog-refs.js"; describe("model catalog normalization", () => { + it.each([ + { input: [" model-2 ", "model-0"], expected: ["model-2", "model-0"] }, + { input: ["missing"], expected: undefined }, + { input: ["model-0", " model-0 "], expected: undefined }, + { input: [" "], expected: undefined }, + { input: [42], expected: undefined }, + { input: [], expected: undefined }, + { input: "model-0", expected: undefined }, + { input: ["foreign-only"], expected: undefined }, + { + input: Array.from({ length: 12 }, (_, index) => `model-${11 - index}`), + expected: Array.from({ length: 12 }, (_, index) => `model-${11 - index}`), + }, + ])("normalizes a complete recommendation list or omits it: $input", ({ input, expected }) => { + const catalog = normalizeModelCatalog( + { + providers: { + openai: { + recommendedModels: input, + models: Array.from({ length: 12 }, (_, index) => ({ id: `model-${index}` })), + }, + foreign: { recommendedModels: ["foreign-only"], models: [{ id: "foreign-only" }] }, + }, + }, + { ownedProviders: new Set(["openai"]) }, + ); + expect(catalog?.providers?.openai?.recommendedModels).toEqual(expected); + expect(catalog?.providers).not.toHaveProperty("foreign"); + }); + it("normalizes catalog ownership, aliases, suppressions, and row fields", () => { const catalog = normalizeModelCatalog( { diff --git a/packages/model-catalog-core/src/model-catalog-normalize.ts b/packages/model-catalog-core/src/model-catalog-normalize.ts index 3270be5be5be..f67931c1e8a0 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.ts @@ -509,12 +509,22 @@ function normalizeModelCatalogProvider(value: unknown): ModelCatalogProvider | u const headers = normalizeStringMap(value.headers); const defaultModel = normalizeOptionalString(value.defaultModel) ?? ""; const defaultUtilityModel = normalizeOptionalString(value.defaultUtilityModel) ?? ""; + const recommended = Array.isArray(value.recommendedModels) + ? value.recommendedModels.map(normalizeOptionalString) + : []; + const recommendedModels = + recommended.length > 0 && + new Set(recommended).size === recommended.length && + recommended.every((id): id is string => Boolean(id) && models.some((model) => model.id === id)) + ? recommended + : []; return { ...(baseUrl ? { baseUrl } : {}), ...(api ? { api } : {}), ...(headers ? { headers } : {}), ...(defaultModel ? { defaultModel } : {}), ...(defaultUtilityModel ? { defaultUtilityModel } : {}), + ...(recommendedModels.length > 0 ? { recommendedModels } : {}), models, }; } diff --git a/packages/model-catalog-core/src/model-catalog-types.ts b/packages/model-catalog-core/src/model-catalog-types.ts index f2c3f675ac8d..7c99f06bf95a 100644 --- a/packages/model-catalog-core/src/model-catalog-types.ts +++ b/packages/model-catalog-core/src/model-catalog-types.ts @@ -259,6 +259,8 @@ export type ModelCatalogProvider = { defaultModel?: string; /** Provider-recommended small model id for short internal utility tasks. */ defaultUtilityModel?: string; + /** Ordered provider shortlist reserved for picker ordering; not yet used. */ + recommendedModels?: string[]; models: ModelCatalogModel[]; }; diff --git a/packages/model-catalog-core/src/remote-catalog-bundle.test.ts b/packages/model-catalog-core/src/remote-catalog-bundle.test.ts index 6404bad100e4..3a03c6e5993b 100644 --- a/packages/model-catalog-core/src/remote-catalog-bundle.test.ts +++ b/packages/model-catalog-core/src/remote-catalog-bundle.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { parseRemoteModelCatalogBundle, + parseRemoteModelCatalogBundleV2, validateAndSanitizeRemoteModelCatalogBundle, + validateAndSanitizeRemoteModelCatalogBundleV2, } from "./remote-catalog-bundle.js"; const validBundle = { @@ -113,3 +115,232 @@ describe("remote model catalog bundle", () => { ).toThrow("contextWindowDefault must reference a declared contextWindows option"); }); }); + +const validBundleV2 = { + schemaVersion: 2, + generatedAt: 1_753_500_000_000, + sourceCommit: "abc123", + providers: { + first: { defaultModel: "vendor/model" }, + second: { defaultUtilityModel: "vendor/model" }, + }, + models: [ + { + id: "vendor/model", + provider: "first", + input: ["text"], + pricing: { + status: "known", + currency: "USD", + unit: "million_tokens", + source: "native-feed", + input: 0, + output: 0, + }, + }, + { id: "vendor/model", provider: "second", pricing: { status: "unknown" } }, + ], +} as const; + +describe("remote model catalog v2", () => { + it("preserves ordered provider recommendations only in v2", () => { + const providers = { first: { recommendedModels: [" other ", "vendor/model"] }, second: {} }; + const bundle = parseRemoteModelCatalogBundleV2({ + ...validBundleV2, + providers, + models: [ + ...validBundleV2.models, + { id: "other", provider: "first", pricing: { status: "unknown" } }, + ], + }); + expect(bundle.providers.first?.recommendedModels).toEqual(["other", "vendor/model"]); + expect(() => + parseRemoteModelCatalogBundle({ + ...validBundle, + providers: { + anthropic: { ...validBundle.providers.anthropic, recommendedModels: ["claude-test"] }, + }, + }), + ).toThrow("recommendedModels"); + }); + + it.each([ + ["unknown id", ["missing"]], + ["duplicate id", ["vendor/model", " vendor/model "]], + ["empty id", [" "]], + ["non-string id", [42]], + ["non-list", "vendor/model"], + ["other provider", ["second-only"]], + ])("rejects recommended models with %s", (_name, recommendedModels) => { + expect(() => + parseRemoteModelCatalogBundleV2({ + ...validBundleV2, + providers: { first: { recommendedModels }, second: {} }, + models: [ + ...validBundleV2.models, + { id: "second-only", provider: "second", pricing: { status: "unknown" } }, + ], + }), + ).toThrow(); + }); + + it("keeps native ids distinct by provider and unknown prices distinct from free", () => { + const bundle = parseRemoteModelCatalogBundleV2(validBundleV2); + expect(bundle.providers).toEqual(validBundleV2.providers); + expect(bundle.models.map(({ id, provider, pricing }) => ({ id, provider, pricing }))).toEqual([ + { + id: "vendor/model", + provider: "first", + pricing: { + status: "known", + currency: "USD", + unit: "million_tokens", + source: "native-feed", + input: 0, + output: 0, + }, + }, + { id: "vendor/model", provider: "second", pricing: { status: "unknown" } }, + ]); + }); + + it("preserves partial rates, context tiers, and authoritative unavailable prices", () => { + const tiers = [ + { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0, range: [0, 200_000] }, + { input: 4, output: 12, cacheRead: 1, cacheWrite: 0, range: [200_000] }, + ]; + const bundle = parseRemoteModelCatalogBundleV2({ + ...validBundleV2, + models: [ + { + id: "partial", + provider: "first", + pricing: { status: "known", currency: "USD", unit: "million_tokens", input: 2 }, + }, + { + id: "tiered", + provider: "first", + pricing: { + status: "known", + currency: "USD", + unit: "million_tokens", + tieredPricing: tiers, + }, + }, + { + id: "unpriced", + provider: "first", + pricing: { status: "unavailable", source: "native-feed" }, + }, + ], + }); + expect(bundle.models[0]?.pricing).toEqual({ + status: "known", + currency: "USD", + unit: "million_tokens", + input: 2, + }); + expect(bundle.models[1]?.pricing).toMatchObject({ tieredPricing: tiers }); + expect(bundle.models[2]?.pricing).toEqual({ status: "unavailable", source: "native-feed" }); + }); + + it("retains model metadata while stripping nested transport overrides", () => { + const bundle = validateAndSanitizeRemoteModelCatalogBundleV2({ + ...validBundleV2, + models: [ + { + ...validBundleV2.models[0], + contextWindows: [{ id: "large", label: "Large", contextWindow: 200_000 }], + contextWindowDefault: "large", + compat: { + supportsTools: true, + nested: { baseUrl: "https://bad.test", headers: { X: "bad" } }, + }, + }, + ], + }); + expect(bundle.models[0]).toMatchObject({ + contextWindowDefault: "large", + contextWindows: [{ id: "large", label: "Large", contextWindow: 200_000 }], + compat: { supportsTools: true, nested: {} }, + }); + }); + + it("preserves provider identities that resemble transport fields", () => { + const bundle = validateAndSanitizeRemoteModelCatalogBundleV2({ + ...validBundleV2, + providers: { headers: {}, baseUrl: {} }, + models: [ + { ...validBundleV2.models[0], provider: "headers" }, + { ...validBundleV2.models[1], provider: "baseUrl" }, + ], + }); + expect(Object.keys(bundle.providers)).toEqual(["headers", "baseUrl"]); + expect(bundle.models.map((model) => model.provider)).toEqual(["headers", "baseUrl"]); + expect(() => parseRemoteModelCatalogBundleV2(bundle)).not.toThrow(); + }); + + it.each([ + { + name: "duplicate tuple", + models: [validBundleV2.models[0], { ...validBundleV2.models[0], id: " vendor/model " }], + error: "duplicate provider/model", + }, + { + name: "undeclared provider", + models: [{ ...validBundleV2.models[0], provider: "missing" }], + error: "undeclared model provider", + }, + { + name: "transport override", + models: [{ ...validBundleV2.models[0], baseUrl: "https://bad.test" }], + error: "baseUrl", + }, + { + name: "invalid context choice", + models: [{ ...validBundleV2.models[0], contextWindowDefault: "missing" }], + error: "contextWindowDefault", + }, + { + name: "unknown price with rates", + models: [{ ...validBundleV2.models[0], pricing: { status: "unknown", input: 0 } }], + error: "input", + }, + { + name: "empty known price", + models: [ + { + ...validBundleV2.models[0], + pricing: { status: "known", currency: "USD", unit: "million_tokens" }, + }, + ], + error: "known pricing must contain rates", + }, + { + name: "negative rate", + models: [ + { ...validBundleV2.models[0], pricing: { ...validBundleV2.models[0].pricing, input: -1 } }, + ], + error: "input", + }, + ])("rejects $name", ({ models, error }) => { + expect(() => parseRemoteModelCatalogBundleV2({ ...validBundleV2, models })).toThrow(error); + }); + + it("keeps each version strict and rejects a detached pricing map in v2", () => { + expect(() => parseRemoteModelCatalogBundle(validBundleV2)).toThrow(); + expect(() => parseRemoteModelCatalogBundleV2(validBundle)).toThrow(); + expect(() => parseRemoteModelCatalogBundleV2({ ...validBundleV2, schemaVersion: 3 })).toThrow(); + expect(() => parseRemoteModelCatalogBundleV2({ ...validBundleV2, pricing: {} })).toThrow(); + }); + + it.each([ + { name: "unsourced rate", upstreamPricing: { "vendor/model": { input: 1, output: 2 } } }, + { name: "bare model key", upstreamPricing: { model: { input: 1, output: 2, source: "x" } } }, + ])("rejects standalone pricing with $name", ({ upstreamPricing }) => { + expect(() => parseRemoteModelCatalogBundleV2({ ...validBundleV2, upstreamPricing })).toThrow(); + expect(() => + parseRemoteModelCatalogBundleV2({ ...validBundleV2, providerPricing: upstreamPricing }), + ).toThrow(); + }); +}); diff --git a/packages/model-catalog-core/src/remote-catalog-bundle.ts b/packages/model-catalog-core/src/remote-catalog-bundle.ts index 06df68179337..ef90b56d72a8 100644 --- a/packages/model-catalog-core/src/remote-catalog-bundle.ts +++ b/packages/model-catalog-core/src/remote-catalog-bundle.ts @@ -4,7 +4,7 @@ import { MODEL_CATALOG_MAX_CONTEXT_WINDOWS, MODEL_CATALOG_THINKING_LEVELS, } from "./model-catalog-types.js"; -import type { ModelCatalogProvider } from "./model-catalog-types.js"; +import type { ModelCatalogModel, ModelCatalogProvider } from "./model-catalog-types.js"; export const REMOTE_CATALOG_MAX_FUTURE_SKEW_MS = 24 * 60 * 60_000; @@ -52,47 +52,52 @@ const contextWindowOptionSchema = z }) .strict(); -const modelSchema = z - .object({ - id: z.string().trim().min(1), - name: z.string().optional(), - api: z.enum(MODEL_CATALOG_APIS).optional(), - baseUrl: z.string().optional(), - headers: stringMapSchema.optional(), - input: z.array(z.enum(["text", "image", "document"])).optional(), - reasoning: z.boolean().optional(), - contextWindow: z.number().finite().positive().optional(), - contextWindows: z - .array(contextWindowOptionSchema) - .max(MODEL_CATALOG_MAX_CONTEXT_WINDOWS) - .optional(), - contextWindowDefault: z.string().trim().min(1).optional(), - contextTokens: z.number().int().positive().optional(), - maxTokens: z.number().finite().positive().optional(), - thinkingLevelMap: z - .partialRecord(z.enum(MODEL_CATALOG_THINKING_LEVELS), z.string().nullable()) - .optional(), - cost: costSchema.optional(), - compat: z.record(z.string(), z.unknown()).optional(), - mediaInput: z.record(z.string(), z.unknown()).optional(), - status: z.enum(["available", "preview", "deprecated", "disabled"]).optional(), - statusReason: z.string().optional(), - replaces: z.array(z.string()).optional(), - replacedBy: z.string().optional(), - tags: z.array(z.string()).optional(), - }) - .superRefine((model, context) => { - if ( - model.contextWindowDefault && - !model.contextWindows?.some((option) => option.id === model.contextWindowDefault) - ) { - context.addIssue({ - code: "custom", - message: "contextWindowDefault must reference a declared contextWindows option", - path: ["contextWindowDefault"], - }); - } - }); +const modelFieldsSchema = z.object({ + id: z.string().trim().min(1), + name: z.string().optional(), + api: z.enum(MODEL_CATALOG_APIS).optional(), + baseUrl: z.string().optional(), + headers: stringMapSchema.optional(), + input: z.array(z.enum(["text", "image", "document"])).optional(), + reasoning: z.boolean().optional(), + contextWindow: z.number().finite().positive().optional(), + contextWindows: z + .array(contextWindowOptionSchema) + .max(MODEL_CATALOG_MAX_CONTEXT_WINDOWS) + .optional(), + contextWindowDefault: z.string().trim().min(1).optional(), + contextTokens: z.number().int().positive().optional(), + maxTokens: z.number().finite().positive().optional(), + thinkingLevelMap: z + .partialRecord(z.enum(MODEL_CATALOG_THINKING_LEVELS), z.string().nullable()) + .optional(), + cost: costSchema.optional(), + compat: z.record(z.string(), z.unknown()).optional(), + mediaInput: z.record(z.string(), z.unknown()).optional(), + status: z.enum(["available", "preview", "deprecated", "disabled"]).optional(), + statusReason: z.string().optional(), + replaces: z.array(z.string()).optional(), + replacedBy: z.string().optional(), + tags: z.array(z.string()).optional(), +}); + +function validateContextWindowDefault( + model: { contextWindowDefault?: string; contextWindows?: Array<{ id: string }> }, + context: z.RefinementCtx, +) { + if ( + model.contextWindowDefault && + !model.contextWindows?.some((option) => option.id === model.contextWindowDefault) + ) { + context.addIssue({ + code: "custom", + message: "contextWindowDefault must reference a declared contextWindows option", + path: ["contextWindowDefault"], + }); + } +} + +const modelSchema = modelFieldsSchema.superRefine(validateContextWindowDefault); export const remoteModelCatalogProviderSchema = z .object({ @@ -142,6 +147,136 @@ export type RemoteModelCatalogBundle = Omit< providers: Record; }; +// "Known" describes the supplied rates, not a complete tariff. Preserve omitted +// components so ingestion retains the existing partial-cost contract. +const knownPricingV2Schema = costSchema + .extend({ + status: z.literal("known"), + currency: z.literal("USD"), + unit: z.literal("million_tokens"), + source: z.string().trim().min(1).optional(), + }) + .refine( + (pricing) => + pricing.input !== undefined || + pricing.output !== undefined || + pricing.cacheRead !== undefined || + pricing.cacheWrite !== undefined || + Boolean(pricing.tieredPricing?.length), + { message: "known pricing must contain rates" }, + ); + +const pricingV2Schema = z.discriminatedUnion("status", [ + knownPricingV2Schema, + z + .object({ + status: z.enum(["unknown", "unavailable"]), + source: z.string().trim().min(1).optional(), + }) + .strict(), +]); + +export type RemoteModelCatalogPricingV2 = z.infer; + +// Standalone rates cover models outside `models`, in USD per million tokens. +// `upstreamPricing` is keyed by the source's vendor/model ID and serves passthrough +// gateways plus same-vendor lookups. The entry's own rate wins; `alternatives` keep +// later sources' rates for providers that allow only those sources. `passthroughOnly` +// entries are owned by catalog rows and serve gateways alone. `providerPricing` holds +// provider-owned rates for models without a catalog row. +const sourcedPricingV2Schema = hostedPricingSchema + .extend({ source: z.string().trim().min(1) }) + .strict(); +const upstreamPricingV2Schema = sourcedPricingV2Schema + .extend({ + passthroughOnly: z.literal(true).optional(), + alternatives: z.array(sourcedPricingV2Schema).min(1).optional(), + }) + .strict(); +const standalonePricingKeySchema = z + .string() + .regex(/^[^/\s]+\/\S+$/u, "standalone pricing keys must be provider/model"); + +const modelV2Schema = modelFieldsSchema + .omit({ cost: true, baseUrl: true, headers: true }) + .extend({ provider: z.string().trim().min(1), pricing: pricingV2Schema }) + .strict() + .superRefine(validateContextWindowDefault); + +export const remoteModelCatalogBundleV2Schema = remoteModelCatalogBundleSchema + .omit({ providers: true, pricing: true }) + .extend({ + schemaVersion: z.literal(2), + providers: z.record( + z.string().trim().min(1), + z + .object({ + api: z.enum(MODEL_CATALOG_APIS).optional(), + defaultModel: z.string().optional(), + defaultUtilityModel: z.string().optional(), + recommendedModels: z + .array(z.string().trim().min(1)) + .refine((ids) => new Set(ids).size === ids.length, "duplicate recommended model id") + .optional(), + }) + .strict(), + ), + models: z.array(modelV2Schema).min(1), + upstreamPricing: z.record(standalonePricingKeySchema, upstreamPricingV2Schema).optional(), + providerPricing: z.record(standalonePricingKeySchema, sourcedPricingV2Schema).optional(), + }) + .superRefine((bundle, context) => { + const providers = new Map>(); + for (const [index, model] of bundle.models.entries()) { + if (!Object.hasOwn(bundle.providers, model.provider)) { + context.addIssue({ + code: "custom", + message: `undeclared model provider: ${model.provider}`, + path: ["models", index, "provider"], + }); + } + const ids = providers.get(model.provider) ?? new Set(); + if (ids.has(model.id)) { + context.addIssue({ + code: "custom", + message: `duplicate provider/model: ${model.provider}/${model.id}`, + path: ["models", index, "id"], + }); + } + ids.add(model.id); + providers.set(model.provider, ids); + } + for (const [provider, entry] of Object.entries(bundle.providers)) { + for (const [index, id] of (entry.recommendedModels ?? []).entries()) { + if (!providers.get(provider)?.has(id)) { + context.addIssue({ + code: "custom", + message: `recommended model must reference a model of provider ${provider}: ${id}`, + path: ["providers", provider, "recommendedModels", index], + }); + } + } + } + }); + +export type RemoteModelCatalogModelV2 = Omit< + ModelCatalogModel, + "cost" | "baseUrl" | "headers" | "upstreamModel" +> & { + provider: string; + pricing: RemoteModelCatalogPricingV2; +}; + +export type RemoteModelCatalogBundleV2 = Omit< + z.infer, + "models" +> & { models: RemoteModelCatalogModelV2[] }; + +export function parseRemoteModelCatalogBundleV2(value: unknown): RemoteModelCatalogBundleV2 { + // SAFETY: the schema validates every model field; shared catalog types narrow compat metadata. + return remoteModelCatalogBundleV2Schema.parse(value) as RemoteModelCatalogBundleV2; +} + export function parseRemoteModelCatalogBundle(value: unknown): RemoteModelCatalogBundle { return remoteModelCatalogBundleSchema.parse(value) as RemoteModelCatalogBundle; } @@ -177,3 +312,18 @@ export function validateAndSanitizeRemoteModelCatalogBundle( ): RemoteModelCatalogBundle { return sanitizeRemoteModelCatalogBundle(parseRemoteModelCatalogBundle(value)); } + +export function validateAndSanitizeRemoteModelCatalogBundleV2( + value: unknown, +): RemoteModelCatalogBundleV2 { + const bundle = parseRemoteModelCatalogBundleV2(value); + // Provider dictionary keys are identities, even when named "headers" or "baseUrl". + // Their strict defaults contain no transport overrides; only model metadata needs stripping. + return { + ...bundle, + models: bundle.models.map((model) => { + // SAFETY: strict v2 fields exclude transport keys; only freeform metadata can contain them. + return stripRemoteTransportOverrides(model) as RemoteModelCatalogModelV2; + }), + }; +} diff --git a/scripts/lib/publish-model-catalog-files.mts b/scripts/lib/publish-model-catalog-files.mts new file mode 100644 index 000000000000..267876ca5453 --- /dev/null +++ b/scripts/lib/publish-model-catalog-files.mts @@ -0,0 +1,175 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + readRegularFileSync, + sameFileIdentity, + writeSiblingTempFile, +} from "@openclaw/fs-safe/advanced"; + +type Output = { file: string; content: string }; +type Snapshot = ReturnType | undefined; +type Recovery = { + dir: string; + identity: fs.Stats; + files: Map; +}; + +function snapshot(file: string): Snapshot { + return fs.lstatSync(file, { throwIfNoEntry: false }) + ? readRegularFileSync({ filePath: file }) + : undefined; +} + +function assertUnchanged(file: string, previous: Snapshot): void { + const current = snapshot(file); + if ( + previous + ? !current || + !sameFileIdentity(previous.stat, current.stat) || + !previous.buffer.equals(current.buffer) + : current + ) { + throw new Error(`Catalog output changed during preparation: ${file}`); + } +} + +function saveRecoveryFile(recovery: Recovery, name: string, content: string | Buffer): void { + const file = path.join(recovery.dir, name); + // Capture ownership before writing: even a partial write remains cleanable. + const fd = fs.openSync(file, "wx", 0o600); + try { + recovery.files.set(file, fs.fstatSync(fd)); + fs.writeFileSync(fd, content); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +function cleanupRecovery(recovery: Recovery): void { + if (!sameFileIdentity(recovery.identity, fs.lstatSync(recovery.dir))) { + throw new Error("recovery directory was replaced"); + } + // No recursive removal and no exit hook: interrupted publication must retain + // its backups. Preserve observed substitutes and unknown children. + for (const [file, identity] of recovery.files) { + const current = fs.lstatSync(file, { throwIfNoEntry: false }); + if (current) { + if (!sameFileIdentity(identity, current)) { + throw new Error("recovery file was replaced"); + } + fs.unlinkSync(file); + } + } + fs.rmdirSync(recovery.dir); +} + +/** Local artifact publication, not a transaction across two filesystem names. */ +export async function publishModelCatalogPair( + outputs: [Output, Output], + warn: (message: string) => void, +): Promise { + // Canonical parents catch aliases without changing the v1-only writer contract. + const prepared = outputs.map((output) => { + fs.mkdirSync(path.dirname(output.file), { recursive: true }); + const parent = fs.realpathSync(path.dirname(output.file)); + return { ...output, file: path.join(parent, path.basename(output.file)), parent }; + }); + if (new Set(prepared.map((output) => output.file)).size !== outputs.length) { + throw new Error("--out and --out-v2 must name different files"); + } + const plans = prepared.map((output) => + Object.assign(output, { previous: snapshot(output.file) }), + ); + const recoveries: Array = []; + let publicationStarted = false; + try { + for (const output of plans) { + const dir = fs.mkdtempSync(path.join(output.parent, ".catalog-pair-")); + recoveries.push({ ...output, dir, identity: fs.lstatSync(dir), files: new Map() }); + } + for (const [index, recovery] of recoveries.entries()) { + saveRecoveryFile(recovery, "next.json", recovery.content); + const previous = recovery.previous; + if (previous) { + saveRecoveryFile(recovery, "previous.json", previous.buffer); + } + saveRecoveryFile( + recovery, + "RECOVERY.txt", + [ + "Catalog pair recovery artifacts; not proof of publication.", + ...recoveries.map((entry, i) => `output ${i + 1}: ${entry.file}; recovery: ${entry.dir}`), + `This directory belongs to output ${index + 1}.`, + previous + ? `previous.json holds original bytes; mode ${(previous.stat.mode & 0o777).toString(8)}.` + : "The output was absent before this attempt.", + "next.json holds the validated candidate bytes.", + "Stop writers and inspect BOTH outputs before restoring or completing the pair.", + "Do not blindly overwrite a replacement or rerun publication to recover.", + "Retained artifacts are never automatically replayed or removed by a later run.", + "", + ].join("\n"), + ); + if ( + !fs.readFileSync(path.join(recovery.dir, "next.json")).equals(Buffer.from(recovery.content)) + ) { + throw new Error("prepared catalog bytes changed"); + } + } + // Finish both preparations before any final-path mutation. Recheck each + // destination again at publication; this is cooperative, not rename CAS. + recoveries.forEach((output) => assertUnchanged(output.file, output.previous)); + publicationStarted = true; + for (const output of recoveries) { + await writeSiblingTempFile({ + dir: output.parent, + chmodDir: false, + producerIsolation: "private-directory", + mode: output.previous ? output.previous.stat.mode & 0o777 : undefined, + syncTempFile: true, + writeTemp: async (tempPath) => { + fs.writeFileSync(tempPath, output.content, { + flag: "wx", + mode: output.previous ? output.previous.stat.mode & 0o777 : 0o666, + }); + }, + resolveFinalPath: () => { + assertUnchanged(output.file, output.previous); + return output.file; + }, + }); + } + } catch (cause) { + if (publicationStarted) { + // A failed rename/verification can already have published. Never roll back + // over a foreign replacement; retain both old/new sets for reconciliation. + throw new Error( + `Catalog pair publication incomplete; inspect both outputs. Recovery retained: ${recoveries.map((entry) => entry.dir).join(", ")}. Cause: ${String(cause)}`, + { cause }, + ); + } + throw cause; + } finally { + if (!publicationStarted) { + for (const recovery of recoveries) { + try { + cleanupRecovery(recovery); + } catch (error) { + warn(`Catalog recovery cleanup failed; retained ${recovery.dir}: ${String(error)}`); + } + } + } + } + // Publication succeeded. Cleanup failure is a warning, not a false failed + // publication or an excuse to roll back an already visible pair. + for (const recovery of recoveries) { + try { + cleanupRecovery(recovery); + } catch (error) { + warn( + `Catalog pair published; recovery cleanup failed; retained ${recovery.dir}: ${String(error)}`, + ); + } + } +} diff --git a/scripts/publish-model-catalog.mts b/scripts/publish-model-catalog.mts index 1071a54bdbb1..52c159252763 100644 --- a/scripts/publish-model-catalog.mts +++ b/scripts/publish-model-catalog.mts @@ -18,9 +18,12 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { ModelCatalogModel } from "../packages/model-catalog-core/src/model-catalog-types.js"; import type { RemoteModelCatalogBundle, + RemoteModelCatalogBundleV2, RemoteModelCatalogPricing, + RemoteModelCatalogPricingV2, } from "../packages/model-catalog-core/src/remote-catalog-bundle.js"; import { importToolingTypeScript } from "./lib/import-tooling-typescript.mts"; +import { publishModelCatalogPair } from "./lib/publish-model-catalog-files.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; type ModelCatalogManifestInput = { @@ -55,6 +58,7 @@ type ModelsDevModel = Record & { type ModelCatalogHydrationCounts = { added: number; filled: number; skipped: number }; type ModelCatalogHydrationResult = Record; type ModelCatalogSourceLoader = (url: string, label: string) => Promise; +type PricingSelection = Pick; const MODEL_CATALOG_MIN_VERSION = "2026.7.0"; export const MODEL_CATALOG_MIN_MODELS = 200; @@ -87,6 +91,7 @@ export function parsePublishModelCatalogArgs(args: string[]) { let dryRun = false; let pricing = false; let out: string | undefined; + let outV2: string | undefined; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--dry-run") { @@ -102,12 +107,17 @@ export function parsePublishModelCatalogArgs(args: string[]) { index += 1; continue; } + if (arg === "--out-v2") { + outV2 = requireOptionValue(args, index, arg); + index += 1; + continue; + } throw new Error(`unknown argument: ${arg}`); } if (!dryRun && !out) { throw new Error("provide --out or --dry-run"); } - return { dryRun, pricing, ...(out ? { out } : {}) }; + return { dryRun, pricing, ...(out ? { out } : {}), ...(outV2 ? { outV2 } : {}) }; } export function readModelCatalogManifests( @@ -131,16 +141,20 @@ export function readModelCatalogManifests( .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); } -async function loadClientBundleValidator() { +async function loadClientBundleValidator(version: 1 | 2 = 1) { const modulePath = path.join( defaultRootDir, "packages/model-catalog-core/src/remote-catalog-bundle.ts", ); const module = await importToolingTypeScript(pathToFileURL(modulePath).href, import.meta.url); - if (typeof module.validateAndSanitizeRemoteModelCatalogBundle !== "function") { + const name = + version === 1 + ? "validateAndSanitizeRemoteModelCatalogBundle" + : "validateAndSanitizeRemoteModelCatalogBundleV2"; + if (typeof module[name] !== "function") { throw new Error("remote catalog bundle validator export is unavailable"); } - return module.validateAndSanitizeRemoteModelCatalogBundle; + return module[name]; } export async function assembleModelCatalogBundle(options: { @@ -160,7 +174,12 @@ export async function assembleModelCatalogBundle(options: { if (Object.hasOwn(providers, providerId)) { throw new Error(`provider ${providerId} is declared by more than one plugin manifest`); } - providers[providerId] = provider; + if (isRecord(provider)) { + const { recommendedModels: _recommendedModels, ...v1Provider } = provider; + providers[providerId] = v1Provider; + } else { + providers[providerId] = provider; + } } } @@ -727,6 +746,7 @@ export async function enrichModelCatalogPricing(options: { manifests: ModelCatalogManifestInput[]; fetchImpl?: typeof fetch; loadSource?: ModelCatalogSourceLoader; + pricingSelections?: WeakMap; }): Promise<{ modelsEnriched: number; pricingEntries: number }> { const policies = readPricingPolicies(options.manifests); const sources = await fetchPricingSources( @@ -758,10 +778,12 @@ export async function enrichModelCatalogPricing(options: { ); if (chosen?.pricing) { model.cost = chosen.pricing; + options.pricingSelections?.set(model, { status: "known", source: chosen.source.id }); enriched += 1; } else if (chosen) { // Keep the metadata row: removing it would revive the bundled seed's stale price. delete model.cost; + options.pricingSelections?.set(model, { status: "unavailable", source: chosen.source.id }); process.stderr.write( `[${SCRIPT_LABEL}] warning: ${chosen.source.label} pricing unavailable for ${providerId}/${model.id}; preserving metadata without cost\n`, ); @@ -840,6 +862,93 @@ export function serializeModelCatalogBundle(bundle: PublishedModelCatalogBundle) return `${JSON.stringify(sortCatalogValue({ ...bundle, providers }), null, 2)}\n`; } +export async function assembleModelCatalogBundleV2( + bundle: PublishedModelCatalogBundle, + pricingSelections: WeakMap, + manifests: ModelCatalogManifestInput[] = [], +): Promise { + const recommendations = new Map(); + for (const entry of manifests) { + const catalog = normalizeModelCatalog(entry.manifest.modelCatalog, { + ownedProviders: new Set(entry.manifest.providers ?? []), + }); + for (const [id, provider] of Object.entries(catalog?.providers ?? {})) { + if (provider.recommendedModels?.length) { + recommendations.set(id, provider.recommendedModels); + } + } + } + const providers: RemoteModelCatalogBundleV2["providers"] = {}; + const models: RemoteModelCatalogBundleV2["models"] = []; + for (const [providerId, provider] of Object.entries(bundle.providers)) { + const recommendedModels = recommendations.get(providerId); + providers[providerId] = { + api: provider.api, + defaultModel: provider.defaultModel, + defaultUtilityModel: provider.defaultUtilityModel, + ...(recommendedModels?.length ? { recommendedModels } : {}), + }; + for (const model of provider.models) { + const { cost, ...metadata } = model; + delete metadata.baseUrl; + delete metadata.headers; + delete metadata.upstreamModel; + const selection = pricingSelections.get(model); + const pricing: RemoteModelCatalogPricingV2 = + cost && (selection?.status === "known" || hasKnownPricing(cost)) + ? { + status: "known", + currency: "USD", + unit: "million_tokens", + ...cost, + ...(selection?.source ? { source: selection.source } : {}), + } + : { + status: selection?.status === "unavailable" ? "unavailable" : "unknown", + ...(selection?.source ? { source: selection.source } : {}), + }; + models.push({ ...metadata, provider: providerId, pricing }); + } + } + const validateBundle = await loadClientBundleValidator(2); + // The first supporting release is not assigned yet. schemaVersion gates v2; + // never copy v1's older client floor onto a new wire contract. + return validateBundle({ + schemaVersion: 2, + generatedAt: bundle.generatedAt, + sourceCommit: bundle.sourceCommit, + providers, + models, + }); +} + +export function serializeModelCatalogBundleV2(bundle: RemoteModelCatalogBundleV2): string { + const models = bundle.models + .toSorted( + (left, right) => + left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id), + ) + .map(({ id, provider, ...metadata }) => + Object.assign( + { id, provider }, + Object.fromEntries( + Object.entries(metadata) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, sortCatalogValue(value)]), + ), + ), + ); + return `${JSON.stringify( + Object.fromEntries( + Object.entries(bundle) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, key === "models" ? models : sortCatalogValue(value)]), + ), + null, + 2, + )}\n`; +} + function resolveSourceCommit(rootDir: string): string { return execFileSync("git", ["rev-parse", "HEAD"], { cwd: rootDir, @@ -848,7 +957,7 @@ function resolveSourceCommit(rootDir: string): string { }).trim(); } -async function runPublishModelCatalog( +export async function runPublishModelCatalog( options: { args?: string[]; fetchImpl?: typeof fetch; @@ -859,22 +968,44 @@ async function runPublishModelCatalog( ) { const rootDir = options.rootDir ?? defaultRootDir; const args = parsePublishModelCatalogArgs(options.args ?? process.argv.slice(2)); + if ( + args.out && + args.outV2 && + path.resolve(rootDir, args.out) === path.resolve(rootDir, args.outV2) + ) { + throw new Error("--out and --out-v2 must name different files"); + } const generatedAt = (options.now ?? Date.now)(); const sourceCommit = options.sourceCommit ?? resolveSourceCommit(rootDir); const manifests = readModelCatalogManifests({ rootDir }); let bundle = await assembleModelCatalogBundle({ manifests, generatedAt, sourceCommit }); + const pricingSelections = new WeakMap(); + // Capture seed ownership before hydration/enrichment can replace its cost. + for (const provider of Object.values(bundle.providers)) { + for (const model of provider.models) { + if (model.cost && hasKnownPricing(model.cost)) { + pricingSelections.set(model, { status: "known", source: "manifest" }); + } + } + } const loadSource = createModelCatalogSourceLoader(options.fetchImpl); const hydrationResult = await hydrateModelCatalogFromModelsDev({ bundle, manifests, loadSource }); const pricingResult = args.pricing - ? await enrichModelCatalogPricing({ bundle, manifests, loadSource }) + ? await enrichModelCatalogPricing({ bundle, manifests, loadSource, pricingSelections }) : { modelsEnriched: 0, pricingEntries: 0 }; // Validate after all enrichment so metadata-only and dry-run output obey the // same client contract as priced catalogs. const validateBundle = await loadClientBundleValidator(); + // Project while selection facts still refer to the assembled model objects. + const bundleV2 = args.outV2 + ? await assembleModelCatalogBundleV2(bundle, pricingSelections, manifests) + : undefined; bundle = validateBundle(bundle); const summary = summarizeModelCatalogBundle(bundle); const serialized = serializeModelCatalogBundle(bundle); const bundleBytes = Buffer.byteLength(serialized); + const serializedV2 = bundleV2 ? serializeModelCatalogBundleV2(bundleV2) : undefined; + const bundleV2Bytes = serializedV2 ? Buffer.byteLength(serializedV2) : 0; if (bundleBytes > BUNDLE_SIZE_WARNING_BYTES) { process.stderr.write( `[${SCRIPT_LABEL}] warning: bundle size ${bundleBytes} bytes exceeds ${BUNDLE_SIZE_WARNING_BYTES} bytes\n`, @@ -885,6 +1016,11 @@ async function runPublishModelCatalog( `catalog bundle ${bundleBytes} bytes exceeds client limit ${CLIENT_BUNDLE_LIMIT_BYTES} bytes`, ); } + if (bundleV2Bytes > CLIENT_BUNDLE_LIMIT_BYTES) { + throw new Error( + `catalog v2 bundle ${bundleV2Bytes} bytes exceeds client limit ${CLIENT_BUNDLE_LIMIT_BYTES} bytes`, + ); + } const hydrationSummary = Object.entries(hydrationResult) .toSorted(([left], [right]) => left.localeCompare(right)) .map( @@ -901,8 +1037,21 @@ async function runPublishModelCatalog( throw new Error("output path is required outside dry-run mode"); } const outputFile = path.resolve(rootDir, args.out); - fs.mkdirSync(path.dirname(outputFile), { recursive: true }); - fs.writeFileSync(outputFile, serialized); + if (args.outV2 && serializedV2) { + await publishModelCatalogPair( + [ + { file: outputFile, content: serialized }, + { file: path.resolve(rootDir, args.outV2), content: serializedV2 }, + ], + (message) => process.stderr.write(`[${SCRIPT_LABEL}] warning: ${message}\n`), + ); + process.stdout.write( + `[${SCRIPT_LABEL}] published schemaVersion=2 models=${summary.models} bundleBytes=${bundleV2Bytes} out=${args.outV2}\n`, + ); + } else { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + fs.writeFileSync(outputFile, serialized); + } process.stdout.write(`[${SCRIPT_LABEL}] published ${stats} out=${args.out}\n${hydrationSummary}`); return { bundle, summary, pricingEnriched: pricingResult.modelsEnriched, wrote: true }; } diff --git a/test/scripts/publish-model-catalog-v2.test.ts b/test/scripts/publish-model-catalog-v2.test.ts new file mode 100644 index 000000000000..69fd34745b15 --- /dev/null +++ b/test/scripts/publish-model-catalog-v2.test.ts @@ -0,0 +1,447 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseRemoteModelCatalogBundle, + parseRemoteModelCatalogBundleV2, + type RemoteModelCatalogBundle, +} from "../../packages/model-catalog-core/src/remote-catalog-bundle.js"; +import { + assembleModelCatalogBundleV2, + parsePublishModelCatalogArgs, + runPublishModelCatalog, + serializeModelCatalogBundle, + serializeModelCatalogBundleV2, +} from "../../scripts/publish-model-catalog.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +afterEach(() => { + vi.restoreAllMocks(); +}); + +function fixtureRoot() { + const root = tempDirs.make("openclaw-catalog-v2-"); + const dir = path.join(root, "extensions", "fixture"); + fs.mkdirSync(dir, { recursive: true }); + const seeds = Array.from({ length: 100 }, (_, index) => ({ id: `seed-${index}` })); + fs.writeFileSync( + path.join(dir, "openclaw.plugin.json"), + JSON.stringify({ + providers: ["anthropic", "openai", "fixture-native"], + modelCatalog: { + modelsDev: { "fixture-native": "upstream" }, + providers: { + anthropic: { + recommendedModels: ["missing"], + models: seeds.map((model, index) => + index === 0 ? { ...model, cost: { input: 0.5 } } : model, + ), + }, + openai: { + defaultModel: "seed-0", + recommendedModels: [" seed-2 ", "seed-0"], + models: seeds, + }, + "fixture-native": { + models: [ + { id: "free", cost: { input: 9, output: 9 } }, + { id: "paid" }, + { id: "withdrawn", cost: { input: 8, output: 8 } }, + ], + }, + }, + }, + modelPricing: { providers: { "fixture-native": { openCode: { provider: "upstream" } } } }, + }), + ); + return root; +} + +function fixtureFetch() { + return vi.fn(async (url) => { + if (url === "https://models.opencode.ai/api.json") { + return Response.json({ + upstream: { + id: "upstream", + models: { + free: { id: "free", cost: { input: 0, output: 0 } }, + paid: { id: "paid", cost: { input: 2, output: 3 } }, + extra: { id: "extra", cost: { input: 5, output: 6 } }, + }, + }, + }); + } + return Response.json({ data: [] }); + }); +} + +function pairFixture(existing = true) { + const rootDir = fixtureRoot(); + const outputs: [string, string] = [ + path.join(rootDir, "v1/catalog.json"), + path.join(rootDir, "v2/catalog.json"), + ]; + outputs.forEach((file, index) => { + fs.mkdirSync(path.dirname(file)); + if (existing) { + fs.writeFileSync(file, `previous v${index + 1}`); + } + }); + const run = () => + runPublishModelCatalog({ + rootDir, + sourceCommit: "fixture", + now: () => 42, + fetchImpl: fixtureFetch(), + args: ["--out", outputs[0], "--out-v2", outputs[1]], + }); + const recovery = (index: number) => { + const output = outputs[index]; + if (!output) { + throw new Error("fixture output index is invalid"); + } + const parent = path.dirname(output); + return fs + .readdirSync(parent) + .filter((name) => name.startsWith(".catalog-pair-")) + .map((name) => path.join(parent, name)); + }; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const warnings = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + return { outputs, run, recovery, warnings }; +} + +describe("publish model catalog v2", () => { + it("rejects a real second-parent failure before replacing v1", async () => { + const fixture = pairFixture(); + fs.unlinkSync(fixture.outputs[1]); + fs.rmdirSync(path.dirname(fixture.outputs[1])); + fs.writeFileSync(path.dirname(fixture.outputs[1]), "not a directory"); + await expect(fixture.run()).rejects.toThrow(/EEXIST|ENOTDIR/u); + expect(fs.readFileSync(fixture.outputs[0], "utf8")).toBe("previous v1"); + expect(fixture.recovery(0)).toEqual([]); + }); + + it("keeps both existing outputs when the second prepared write fails", async () => { + const fixture = pairFixture(); + const write = fs.writeFileSync; + vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { + if ( + typeof file === "number" && + typeof data === "string" && + data.includes('"schemaVersion": 2') + ) { + write(file, "partial"); + throw new Error("fixture second prepare ENOSPC"); + } + return write(file, data, options); + }); + await expect(fixture.run()).rejects.toThrow("fixture second prepare ENOSPC"); + fixture.outputs.forEach((file, index) => { + expect(fs.readFileSync(file, "utf8")).toBe(`previous v${index + 1}`); + expect(fixture.recovery(index)).toEqual([]); + }); + }); + + it.each([false, true])( + "retains old/new recovery on a second rename failure (existing=%s)", + async (existing) => { + const fixture = pairFixture(existing); + const rename = fs.promises.rename; + vi.spyOn(fs.promises, "rename").mockImplementation(async (source, destination) => { + if (destination === fixture.outputs[1]) { + throw new Error("fixture second rename EIO"); + } + return rename(source, destination); + }); + await expect(fixture.run()).rejects.toThrow("Recovery retained:"); + expect(JSON.parse(fs.readFileSync(fixture.outputs[0], "utf8")).schemaVersion).toBe(1); + if (existing) { + expect(fs.readFileSync(fixture.outputs[1], "utf8")).toBe("previous v2"); + } else { + expect(fs.existsSync(fixture.outputs[1])).toBe(false); + } + fixture.outputs.forEach((file, index) => { + const dirs = fixture.recovery(index); + expect(dirs).toHaveLength(1); + const [dir] = dirs; + if (!dir) { + throw new Error("fixture recovery is missing"); + } + expect(JSON.parse(fs.readFileSync(path.join(dir, "next.json"), "utf8")).schemaVersion).toBe( + index + 1, + ); + const note = fs.readFileSync(path.join(dir, "RECOVERY.txt"), "utf8"); + expect(note).toContain(file); + expect(note).toContain(fixture.outputs[1 - index]); + if (existing) { + expect(fs.readFileSync(path.join(dir, "previous.json"), "utf8")).toBe( + `previous v${index + 1}`, + ); + } else { + expect(note).toContain("was absent"); + expect(fs.existsSync(path.join(dir, "previous.json"))).toBe(false); + } + }); + }, + ); + + it("retains recovery after a partial second publication write", async () => { + const fixture = pairFixture(); + const write = fs.writeFileSync; + vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { + if ( + typeof file === "string" && + typeof data === "string" && + data.includes('"schemaVersion": 2') + ) { + write(file, "partial", options); + throw new Error("fixture publication write ENOSPC"); + } + return write(file, data, options); + }); + await expect(fixture.run()).rejects.toThrow("fixture publication write ENOSPC"); + expect(JSON.parse(fs.readFileSync(fixture.outputs[0], "utf8")).schemaVersion).toBe(1); + expect(fs.readFileSync(fixture.outputs[1], "utf8")).toBe("previous v2"); + expect(fixture.recovery(0)).toHaveLength(1); + expect(fixture.recovery(1)).toHaveLength(1); + }); + + it("preserves an observed destination replacement before the second publication", async () => { + const fixture = pairFixture(); + const rename = fs.promises.rename; + vi.spyOn(fs.promises, "rename").mockImplementation(async (source, destination) => { + await rename(source, destination); + if (destination === fixture.outputs[0]) { + fs.unlinkSync(fixture.outputs[1]); + fs.writeFileSync(fixture.outputs[1], "foreign replacement"); + } + }); + await expect(fixture.run()).rejects.toThrow("output changed during preparation"); + expect(fs.readFileSync(fixture.outputs[1], "utf8")).toBe("foreign replacement"); + expect(fixture.recovery(0)).toHaveLength(1); + expect(fixture.recovery(1)).toHaveLength(1); + }); + + it("does not roll back foreign replacements after an uncertain second rename", async () => { + const fixture = pairFixture(); + const rename = fs.promises.rename; + vi.spyOn(fs.promises, "rename").mockImplementation(async (source, destination) => { + await rename(source, destination); + if (destination === fixture.outputs[1]) { + for (const file of fixture.outputs) { + fs.unlinkSync(file); + fs.writeFileSync(file, "foreign replacement"); + } + throw new Error("fixture post-rename failure"); + } + }); + await expect(fixture.run()).rejects.toThrow("fixture post-rename failure"); + fixture.outputs.forEach((file, index) => { + expect(fs.readFileSync(file, "utf8")).toBe("foreign replacement"); + expect(fixture.recovery(index)).toHaveLength(1); + }); + }); + + it("reports cleanup failure without rejecting a successfully published pair", async () => { + const fixture = pairFixture(); + const unlink = fs.unlinkSync; + vi.spyOn(fs, "unlinkSync").mockImplementation((file) => { + if (String(file).includes(".catalog-pair-")) { + throw new Error("fixture cleanup EACCES"); + } + unlink(file); + }); + await expect(fixture.run()).resolves.toMatchObject({ wrote: true }); + fixture.outputs.forEach((file, index) => { + expect(JSON.parse(fs.readFileSync(file, "utf8")).schemaVersion).toBe(index + 1); + expect(fixture.recovery(index)).toHaveLength(1); + }); + expect(fixture.warnings.mock.calls.flat().join("")).toContain( + "pair published; recovery cleanup failed", + ); + }); + + it("replaces an existing pair and removes only its recovery artifacts", async () => { + const fixture = pairFixture(); + const parent = path.dirname(fixture.outputs[0]); + const parentMode = fs.statSync(parent).mode; + fs.writeFileSync(path.join(parent, "unrelated"), "preserve"); + await expect(fixture.run()).resolves.toMatchObject({ wrote: true }); + fixture.outputs.forEach((file, index) => { + expect(JSON.parse(fs.readFileSync(file, "utf8")).schemaVersion).toBe(index + 1); + expect(fixture.recovery(index)).toEqual([]); + }); + expect(fs.statSync(parent).mode).toBe(parentMode); + expect(fs.readFileSync(path.join(parent, "unrelated"), "utf8")).toBe("preserve"); + }); + + it("adds an explicit second output while keeping --out as v1", () => { + expect(parsePublishModelCatalogArgs(["--out", "v1.json", "--out-v2", "v2.json"])).toEqual({ + dryRun: false, + pricing: false, + out: "v1.json", + outV2: "v2.json", + }); + expect(() => parsePublishModelCatalogArgs(["--out-v2", "v2.json"])).toThrow("provide --out"); + expect(() => parsePublishModelCatalogArgs(["--out", "v1.json", "--out-v2"])).toThrow( + "requires a value", + ); + }); + + it("projects only metadata rows with partial costs, tiers, and native tuple identity", async () => { + const bundle: RemoteModelCatalogBundle = { + schemaVersion: 1, + generatedAt: 1, + sourceCommit: "fixture", + minVersion: "2026.7.0", + providers: { + alpha: { + models: [ + { id: "vendor/model", cost: { input: 2 } }, + { id: "zero", cost: { input: 0, output: 0 } }, + { + id: "tier", + cost: { + tieredPricing: [{ input: 3, output: 4, cacheRead: 0, cacheWrite: 0, range: [0] }], + }, + }, + ], + }, + beta: { models: [{ id: "vendor/model" }] }, + }, + pricing: { "alpha/extra": { input: 9, output: 9 } }, + }; + const before = serializeModelCatalogBundle(bundle); + const v2 = await assembleModelCatalogBundleV2(bundle, new WeakMap()); + expect(v2.models).toHaveLength(4); + expect(v2.models[0]).toMatchObject({ + id: "vendor/model", + provider: "alpha", + pricing: { status: "known", input: 2 }, + }); + expect(v2.models[0]?.pricing).not.toHaveProperty("source"); + expect(v2.models[0]?.pricing).not.toHaveProperty("output"); + expect(v2.models[1]?.pricing).toEqual({ status: "unknown" }); + expect(v2.models[2]?.pricing).toMatchObject({ + status: "known", + tieredPricing: bundle.providers.alpha?.models[2]?.cost?.tieredPricing, + }); + expect(v2.models[3]).toMatchObject({ + id: "vendor/model", + provider: "beta", + pricing: { status: "unknown" }, + }); + expect(v2).not.toHaveProperty("pricing"); + expect(v2).not.toHaveProperty("minVersion"); + expect(serializeModelCatalogBundle(bundle)).toBe(before); + const serialized = serializeModelCatalogBundleV2(v2); + const parsed = parseRemoteModelCatalogBundleV2(JSON.parse(serialized)); + expect(parsed.models).toHaveLength(4); + expect(Object.keys(JSON.parse(serialized).models[0]).slice(0, 2)).toEqual(["id", "provider"]); + expect(serializeModelCatalogBundleV2({ ...v2, models: v2.models.toReversed() })).toBe( + serialized, + ); + }); + + it("writes paired catalogs from one source snapshot, preserving authoritative zero and withdrawal", async () => { + const rootDir = fixtureRoot(); + const fetchImpl = fixtureFetch(); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await runPublishModelCatalog({ + rootDir, + fetchImpl, + now: () => 42, + sourceCommit: "fixture", + args: ["--pricing", "--out", "v1/catalog.json", "--out-v2", "v2/catalog.json"], + }); + const v1 = parseRemoteModelCatalogBundle( + JSON.parse(fs.readFileSync(path.join(rootDir, "v1/catalog.json"), "utf8")), + ); + const v2 = parseRemoteModelCatalogBundleV2( + JSON.parse(fs.readFileSync(path.join(rootDir, "v2/catalog.json"), "utf8")), + ); + expect(v1.generatedAt).toBe(v2.generatedAt); + expect(v1.sourceCommit).toBe(v2.sourceCommit); + expect(v1.minVersion).toBe("2026.7.0"); + expect(v2.models).toHaveLength(203); + expect(v2.providers.openai?.defaultModel).toBe("seed-0"); + expect(v2.providers.openai?.recommendedModels).toEqual(["seed-2", "seed-0"]); + expect(v1.providers.openai).not.toHaveProperty("recommendedModels"); + expect(v2.providers.anthropic).not.toHaveProperty("recommendedModels"); + expect( + v2.models.find((model) => model.provider === "anthropic" && model.id === "seed-0")?.pricing, + ).toEqual({ + status: "known", + currency: "USD", + unit: "million_tokens", + input: 0.5, + source: "manifest", + }); + expect(v2.models.find((model) => model.id === "free")?.pricing).toMatchObject({ + status: "known", + input: 0, + output: 0, + source: "openCode", + }); + expect(v2.models.find((model) => model.id === "paid")?.pricing).toMatchObject({ + status: "known", + input: 2, + output: 3, + source: "openCode", + }); + expect(v2.models.find((model) => model.id === "withdrawn")?.pricing).toEqual({ + status: "unavailable", + source: "openCode", + }); + expect(v1.pricing?.["fixture-native/extra"]).toBeDefined(); + expect(v2.models.some((model) => model.id === "extra")).toBe(false); + expect( + fetchImpl.mock.calls.filter(([url]) => url === "https://models.opencode.ai/api.json"), + ).toHaveLength(1); + await runPublishModelCatalog({ + rootDir, + fetchImpl: fixtureFetch(), + now: () => 42, + sourceCommit: "fixture", + args: ["--pricing", "--out", "v1-only.json"], + }); + expect(fs.readFileSync(path.join(rootDir, "v1-only.json"), "utf8")).toBe( + fs.readFileSync(path.join(rootDir, "v1/catalog.json"), "utf8"), + ); + }); + + it("keeps both prior files on source failure and never writes in dry-run mode", async () => { + const rootDir = fixtureRoot(); + const out = path.join(rootDir, "v1.json"); + const outV2 = path.join(rootDir, "v2.json"); + fs.writeFileSync(out, "previous v1"); + fs.writeFileSync(outV2, "previous v2"); + const args = ["--pricing", "--out", out, "--out-v2", outV2]; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await expect( + runPublishModelCatalog({ + rootDir, + sourceCommit: "fixture", + args, + fetchImpl: async () => { + throw new Error("fixture outage"); + }, + }), + ).rejects.toThrow("fixture outage"); + await runPublishModelCatalog({ + rootDir, + sourceCommit: "fixture", + args: [...args, "--dry-run"], + fetchImpl: fixtureFetch(), + }); + expect(fs.readFileSync(out, "utf8")).toBe("previous v1"); + expect(fs.readFileSync(outV2, "utf8")).toBe("previous v2"); + await expect( + runPublishModelCatalog({ rootDir, args: ["--out", "same.json", "--out-v2", "./same.json"] }), + ).rejects.toThrow("different files"); + }); +});