From 7fd564cbb78f35f3de14d5382fea692b87ec4026 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 23 Sep 2026 20:08:05 +0200 Subject: [PATCH] feat: share model catalog protocol with pi.dev (#9763) Move catalog layout, index validation, version selection, and request negotiation into scripts/model-catalog-protocol.ts. pi.dev keeps an identical copy, so the publisher and a test running the current client exercise the same selection pi.dev performs. Replaces the cross-repository compatibility dispatch. Related to #9099. --- .github/workflows/publish-model-catalog.yml | 1 + biome.json | 2 + package.json | 2 +- .../test/model-catalog-protocol.test.ts | 152 ++++++++++ scripts/model-catalog-protocol.test.ts | 120 ++++++++ scripts/model-catalog-protocol.ts | 260 ++++++++++++++++++ scripts/publish-model-catalog.mjs | 106 +++---- tsconfig.json | 2 +- 8 files changed, 571 insertions(+), 74 deletions(-) create mode 100644 packages/coding-agent/test/model-catalog-protocol.test.ts create mode 100644 scripts/model-catalog-protocol.test.ts create mode 100644 scripts/model-catalog-protocol.ts diff --git a/.github/workflows/publish-model-catalog.yml b/.github/workflows/publish-model-catalog.yml index 729defbe7..f241e1704 100644 --- a/.github/workflows/publish-model-catalog.yml +++ b/.github/workflows/publish-model-catalog.yml @@ -12,6 +12,7 @@ on: - '.gitignore' - 'package.json' - 'packages/ai/**' + - 'scripts/model-catalog-protocol.ts' - 'scripts/publish-model-catalog.mjs' # GitHub schedules use UTC. Run hourly candidates across the CET/CEST # boundaries; the publish job only uploads at 10:17, 12:17, and 14:17 diff --git a/biome.json b/biome.json index f1c3f837b..d3e200a7b 100644 --- a/biome.json +++ b/biome.json @@ -25,6 +25,8 @@ }, "files": { "includes": [ + "scripts/model-catalog-protocol.ts", + "scripts/model-catalog-protocol.test.ts", "packages/*/src/**/*.ts", "packages/*/test/**/*.ts", "packages/session-backends/*/src/**/*.ts", diff --git a/package.json b/package.json index 3139dcfe1..47c163512 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test:scripts && npm run test --workspaces --if-present", - "test:scripts": "node --test scripts/*.test.mjs", + "test:scripts": "node --test scripts/*.test.mjs scripts/*.test.ts", "version:patch": "npm version patch --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", "version:minor": "npm version minor --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", "version:major": "npm version major --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", diff --git a/packages/coding-agent/test/model-catalog-protocol.test.ts b/packages/coding-agent/test/model-catalog-protocol.test.ts new file mode 100644 index 000000000..9222af565 --- /dev/null +++ b/packages/coding-agent/test/model-catalog-protocol.test.ts @@ -0,0 +1,152 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + getModelCatalogProviderKey, + MODEL_CATALOG_INDEX_KEY, + type ModelCatalogIndex, + parseModelCatalogIndex, + parseModelCatalogRequest, + selectModelCatalog, +} from "../../../scripts/model-catalog-protocol.ts"; +import { VERSION } from "../src/config.ts"; +import { ModelRuntime } from "../src/core/model-runtime.ts"; +import { allowNetwork } from "./test-network-env.ts"; + +// Runs the current client against the catalog selection pi.dev performs, using +// the shared protocol in scripts/model-catalog-protocol.ts. Regression test for #9099. + +const legacyRevision = `sha256-${"a".repeat(64)}`; +const mixedApiRevision = `sha256-${"b".repeat(64)}`; +const modelId = "anthropic/claude-sonnet-5"; +// Newer than the bundled catalog, so the client applies the remote overlay. +const lastModified = new Date("2099-01-01T00:00:00Z").toUTCString(); +const commonModel = { + id: modelId, + name: "Claude Sonnet 5", + provider: "openrouter", + reasoning: true, + input: ["text"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200_000, + maxTokens: 64_000, +}; +const legacyModel = { ...commonModel, api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1" }; +const mixedApiModel = { ...commonModel, api: "anthropic-messages", baseUrl: "https://openrouter.ai/api" }; + +const index: ModelCatalogIndex = { + schemaVersion: 1, + defaultRevision: mixedApiRevision, + catalogs: [ + { minimumPiVersion: "0.80.7", revision: legacyRevision }, + { minimumPiVersion: "0.85.0", revision: mixedApiRevision }, + ], +}; + +// The legacy revision predates typed shards, so typed requests fall back to the chat-only shard. +const objects = new Map([ + [MODEL_CATALOG_INDEX_KEY, index], + [getModelCatalogProviderKey(legacyRevision, "openrouter", "legacy"), { [modelId]: legacyModel }], + [getModelCatalogProviderKey(mixedApiRevision, "openrouter", "legacy"), { [modelId]: mixedApiModel }], + [getModelCatalogProviderKey(mixedApiRevision, "openrouter", "typed"), [{ type: "chat", ...mixedApiModel }]], +]); + +/** Minimal stand-in for pi.dev's /api/models/providers/:provider route. */ +function startCatalogServer(requests: string[]): Promise { + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://${request.headers.host}`); + requests.push(`${url.pathname}${url.search}`); + const provider = /^\/api\/models\/providers\/([^/]+)$/.exec(url.pathname)?.[1]; + const catalogRequest = parseModelCatalogRequest(url, request.headers["user-agent"]); + if (!provider) { + response.writeHead(404).end(); + } else if (catalogRequest.kind === "redirect") { + response.writeHead(307, { location: catalogRequest.location, "cache-control": "no-store" }).end(); + } else if (catalogRequest.kind === "invalid") { + response.writeHead(400).end(catalogRequest.error); + } else { + const catalog = selectModelCatalog( + parseModelCatalogIndex(objects.get(MODEL_CATALOG_INDEX_KEY)), + catalogRequest.piVersion, + ); + const body = catalog + ? (objects.get(getModelCatalogProviderKey(catalog.revision, provider, catalogRequest.representation)) ?? + objects.get(getModelCatalogProviderKey(catalog.revision, provider, "legacy"))) + : undefined; + if (!catalog || body === undefined) { + response.writeHead(404).end(); + } else { + response + .writeHead(200, { + "content-type": "application/json", + "last-modified": lastModified, + "x-pi-model-catalog-revision": catalog.revision, + }) + .end(JSON.stringify(body)); + } + } + }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +describe("model catalog protocol with the current client", () => { + const requests: string[] = []; + let server: Server; + let catalogBaseUrl: string; + + beforeAll(async () => { + server = await startCatalogServer(requests); + catalogBaseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }); + + it("negotiates the catalog for its version and reaches the OpenRouter API", async () => { + allowNetwork(); + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + catalogBaseUrl, + refreshOnCreate: false, + }); + await runtime.setRuntimeApiKey("openrouter", "test-key"); + const refresh = await runtime.refresh({ allowNetwork: true, force: true, providers: ["openrouter"] }); + expect([...refresh.errors]).toEqual([]); + + const catalogUrl = "/api/models/providers/openrouter?types=chat%2Cimage%2Cclassifier"; + expect(requests).toEqual([catalogUrl, `${catalogUrl}&pi-version=${VERSION}`]); + + const expectedModel = + selectModelCatalog(index, VERSION)?.revision === legacyRevision ? legacyModel : mixedApiModel; + const model = runtime.getModel("openrouter", modelId); + expect(model).toMatchObject({ api: expectedModel.api, baseUrl: expectedModel.baseUrl }); + if (!model) throw new Error(`Missing model: openrouter/${modelId}`); + + let providerUrl: URL | undefined; + const nativeFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (input: Parameters[0], init?: RequestInit) => { + const request = new Request(input, init); + if (new URL(request.url).origin !== "https://openrouter.ai") return nativeFetch(request); + providerUrl = new URL(request.url); + throw new Error("Provider request captured"); + }); + try { + await runtime.completeSimple( + model, + { messages: [{ role: "user", content: "Hello", timestamp: 0 }] }, + { apiKey: "test-key", maxRetries: 0 }, + ); + } finally { + vi.unstubAllGlobals(); + } + expect(providerUrl?.pathname).toBe( + expectedModel.api === "anthropic-messages" ? "/api/v1/messages" : "/api/v1/chat/completions", + ); + }); +}); diff --git a/scripts/model-catalog-protocol.test.ts b/scripts/model-catalog-protocol.test.ts new file mode 100644 index 000000000..cdfcc0d50 --- /dev/null +++ b/scripts/model-catalog-protocol.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + compareModelCatalogPiVersions, + getModelCatalogArtifactKey, + getModelCatalogProviderKey, + type ModelCatalogIndex, + parseModelCatalogIndex, + parseModelCatalogRepresentation, + parseModelCatalogRequest, + selectModelCatalog, +} from "./model-catalog-protocol.ts"; + +const legacyRevision = `sha256-${"a".repeat(64)}`; +const mixedApiRevision = `sha256-${"b".repeat(64)}`; +const index: ModelCatalogIndex = { + schemaVersion: 1, + defaultRevision: mixedApiRevision, + catalogs: [ + { minimumPiVersion: "0.80.7", revision: legacyRevision }, + { minimumPiVersion: "0.85.0", revision: mixedApiRevision }, + ], +}; + +test("builds revision scoped storage keys", () => { + assert.equal( + getModelCatalogArtifactKey(legacyRevision, "models.json"), + `models/v1/revisions/${legacyRevision}/models.json`, + ); + assert.equal( + getModelCatalogProviderKey(legacyRevision, "openrouter", "legacy"), + `models/v1/revisions/${legacyRevision}/providers/openrouter.json`, + ); + assert.equal( + getModelCatalogProviderKey(legacyRevision, "openrouter", "typed"), + `models/v1/revisions/${legacyRevision}/providers/openrouter.all.json`, + ); +}); + +test("orders Pi versions with semver precedence", () => { + assert.ok(compareModelCatalogPiVersions("0.85.0", "0.84.4") > 0); + assert.ok(compareModelCatalogPiVersions("0.85.0-rc.1", "0.85.0") < 0); + assert.ok(compareModelCatalogPiVersions("0.85.0-rc.2", "0.85.0-rc.10") < 0); + assert.equal(compareModelCatalogPiVersions("v1.0.0", "1.0.0+build"), 0); + assert.throws(() => compareModelCatalogPiVersions("0.85", "0.85.0"), /Invalid Pi version/); +}); + +test("selects the newest catalog a Pi version supports", () => { + // Regression test for #9099: released clients before 0.85.0 cannot use the mixed-API catalog. + assert.equal(selectModelCatalog(index, "0.84.4")?.revision, legacyRevision); + assert.equal(selectModelCatalog(index, "0.85.0-rc.1")?.revision, legacyRevision); + assert.equal(selectModelCatalog(index, "0.85.0")?.revision, mixedApiRevision); + assert.equal(selectModelCatalog(index, "1.0.0")?.revision, mixedApiRevision); + assert.equal(selectModelCatalog(index, "0.80.6"), undefined); + assert.equal(selectModelCatalog(index, undefined)?.revision, mixedApiRevision); +}); + +test("redirects Pi user agents to an explicit catalog version", () => { + // Regression test for #9099: released clients identify themselves only by User-Agent. + assert.deepEqual( + parseModelCatalogRequest( + "https://pi.dev/api/models/providers/openrouter?types=chat%2Cimage", + "pi/0.84.4 (linux; node/v22.0.0; x64)", + ), + { + kind: "redirect", + location: "https://pi.dev/api/models/providers/openrouter?types=chat%2Cimage&pi-version=0.84.4", + }, + ); + assert.deepEqual(parseModelCatalogRequest("https://pi.dev/api/models", "pi/0.84.4"), { + kind: "redirect", + location: "https://pi.dev/api/models?pi-version=0.84.4", + }); +}); + +test("serves explicit and unversioned catalog requests without redirecting", () => { + assert.deepEqual( + parseModelCatalogRequest("https://pi.dev/api/models?pi-version=0.83.0", "pi/0.85.1 (linux; node/v22.0.0; x64)"), + { kind: "catalog", piVersion: "0.83.0", representation: "legacy" }, + ); + assert.deepEqual(parseModelCatalogRequest("https://pi.dev/api/models?types=chat", "curl/8.0.0"), { + kind: "catalog", + piVersion: undefined, + representation: "typed", + }); + assert.deepEqual(parseModelCatalogRequest("https://pi.dev/api/models", "pi/latest"), { + kind: "catalog", + piVersion: undefined, + representation: "legacy", + }); +}); + +test("rejects invalid catalog request parameters", () => { + assert.deepEqual(parseModelCatalogRequest("https://pi.dev/api/models?pi-version=latest", undefined), { + kind: "invalid", + error: "Invalid Pi version.", + }); + assert.deepEqual(parseModelCatalogRequest("https://pi.dev/api/models?types=", "pi/0.84.4"), { + kind: "invalid", + error: "Invalid model types.", + }); + assert.equal(parseModelCatalogRepresentation("chat,-image"), undefined); + assert.equal(parseModelCatalogRepresentation(null), "legacy"); +}); + +test("validates stored indexes and drops publication metadata", () => { + assert.deepEqual( + parseModelCatalogIndex({ + ...index, + catalogs: index.catalogs.map((catalog) => ({ ...catalog, sourceCommit: "abc", modelCount: 1 })), + }), + index, + ); + assert.throws(() => parseModelCatalogIndex({ ...index, schemaVersion: 2 }), /index is invalid/); + assert.throws(() => parseModelCatalogIndex({ ...index, defaultRevision: `sha256-${"c".repeat(64)}` }), /invalid/); + assert.throws( + () => parseModelCatalogIndex({ ...index, catalogs: [{ minimumPiVersion: "latest", revision: legacyRevision }] }), + /invalid/, + ); +}); diff --git a/scripts/model-catalog-protocol.ts b/scripts/model-catalog-protocol.ts new file mode 100644 index 000000000..d151d36a5 --- /dev/null +++ b/scripts/model-catalog-protocol.ts @@ -0,0 +1,260 @@ +/** + * Model catalog protocol shared by pi and pi.dev. + * + * This file is copied verbatim into two repositories. Change both copies in + * the same pair of pull requests and keep them byte for byte identical: + * + * earendil-works/pi scripts/model-catalog-protocol.ts + * earendil-works/pi.dev src/shared/models/protocol.ts + * + * pi uses it to publish catalog revisions and to test clients against the + * catalog selection pi.dev performs. pi.dev uses it to serve catalog requests. + * Keep it free of imports and runtime specific APIs so it runs unchanged in + * Node.js and Cloudflare Workers. + * + * Storage layout under `MODEL_CATALOG_PREFIX`: + * + * index.json revision index (`ModelCatalogIndex`) + * revisions//models.json chat models by provider and id + * revisions//models.all.json models of every type by provider + * revisions//providers.json sorted provider ids + * revisions//providers/.json chat models of one provider by id + * revisions//providers/.all.json models of every type of one provider + * + * Clients that send `?types=` receive the typed `.all.json` variant. Older + * clients receive the legacy chat-only variant. + */ + +export const MODEL_CATALOG_SCHEMA_VERSION = 1; +export const MODEL_CATALOG_PREFIX = `models/v${MODEL_CATALOG_SCHEMA_VERSION}`; +export const MODEL_CATALOG_INDEX_KEY = `${MODEL_CATALOG_PREFIX}/index.json`; +export const MODEL_CATALOG_REVISION_RE = /^sha256-[0-9a-f]{64}$/; + +const PI_VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; +const PI_USER_AGENT_RE = /^pi\/([^\s()]+)(?: \([^;()]+(?:;\s*[^;()]+(?:;\s*[^()]+)?)?\))?$/i; +const MODEL_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +type ParsedPiVersion = readonly [major: number, minor: number, patch: number, prerelease: string]; + +export type ModelCatalogArtifact = "models.json" | "models.all.json" | "providers.json"; + +export type ModelCatalogRepresentation = "legacy" | "typed"; + +export type ModelCatalogIndexEntry = { + minimumPiVersion: string; + revision: string; +}; + +export type ModelCatalogIndex = { + schemaVersion: number; + defaultRevision: string; + catalogs: ModelCatalogIndexEntry[]; +}; + +export type ModelCatalogRequest = + | { kind: "catalog"; piVersion: string | undefined; representation: ModelCatalogRepresentation } + | { kind: "redirect"; location: string } + | { kind: "invalid"; error: string }; + +export function getModelCatalogArtifactKey(revision: string, artifact: ModelCatalogArtifact): string { + return `${MODEL_CATALOG_PREFIX}/revisions/${revision}/${artifact}`; +} + +export function getModelCatalogProviderKey( + revision: string, + provider: string, + representation: ModelCatalogRepresentation, +): string { + const suffix = representation === "typed" ? ".all.json" : ".json"; + return `${MODEL_CATALOG_PREFIX}/revisions/${revision}/providers/${provider}${suffix}`; +} + +export function isValidModelCatalogPiVersion(version: string): boolean { + return parsePiVersion(version) !== undefined; +} + +/** Compare two Pi versions with semver precedence. Throws for invalid versions. */ +export function compareModelCatalogPiVersions(left: string, right: string): number { + return comparePiVersions(requirePiVersion(left), requirePiVersion(right)); +} + +/** + * Parse the `types` query parameter. Returns undefined when it is present but + * invalid. Clients that do not send it predate model types. + */ +export function parseModelCatalogRepresentation( + types: string | null | undefined, +): ModelCatalogRepresentation | undefined { + if (types === undefined || types === null) { + return "legacy"; + } + if (types === "" || types.split(",").some((type) => !MODEL_TYPE_RE.test(type))) { + return undefined; + } + return "typed"; +} + +/** + * Decide how to answer a catalog request from its URL and User-Agent. + * + * Released clients do not send `pi-version`. Responses are cached by URL, so + * instead of varying the response on the User-Agent, clients that identify + * as Pi are redirected to the equivalent URL with an explicit `pi-version`. + * Requests without a Pi User-Agent receive the default catalog. + */ +export function parseModelCatalogRequest(url: string | URL, userAgent: string | null | undefined): ModelCatalogRequest { + const requestUrl = new URL(url); + const representation = parseModelCatalogRepresentation(requestUrl.searchParams.get("types")); + if (representation === undefined) { + return { kind: "invalid", error: "Invalid model types." }; + } + + const piVersion = requestUrl.searchParams.get("pi-version"); + if (piVersion === null) { + const userAgentVersion = PI_USER_AGENT_RE.exec(userAgent ?? "")?.[1]; + if (userAgentVersion !== undefined && isValidModelCatalogPiVersion(userAgentVersion)) { + requestUrl.searchParams.set("pi-version", userAgentVersion); + return { kind: "redirect", location: requestUrl.toString() }; + } + return { kind: "catalog", piVersion: undefined, representation }; + } + if (!isValidModelCatalogPiVersion(piVersion)) { + return { kind: "invalid", error: "Invalid Pi version." }; + } + return { kind: "catalog", piVersion, representation }; +} + +/** + * Select the catalog revision for a Pi version: the entry with the highest + * minimum version that does not exceed it. Requests without a version receive + * the default revision. Returns undefined when no entry is compatible. + */ +export function selectModelCatalog( + index: ModelCatalogIndex, + piVersion: string | undefined, +): ModelCatalogIndexEntry | undefined { + if (piVersion === undefined) { + return index.catalogs.find((catalog) => catalog.revision === index.defaultRevision); + } + + const requestedVersion = parsePiVersion(piVersion); + if (!requestedVersion) { + return undefined; + } + + let selected: ModelCatalogIndexEntry | undefined; + let selectedVersion: ParsedPiVersion | undefined; + for (const catalog of index.catalogs) { + const minimumVersion = requirePiVersion(catalog.minimumPiVersion); + if ( + comparePiVersions(minimumVersion, requestedVersion) <= 0 && + (!selectedVersion || comparePiVersions(minimumVersion, selectedVersion) > 0) + ) { + selected = catalog; + selectedVersion = minimumVersion; + } + } + return selected; +} + +/** + * Validate an index read from storage. Returns only the protocol fields; + * publishers may store additional metadata on each entry. + */ +export function parseModelCatalogIndex(value: unknown): ModelCatalogIndex { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + !("schemaVersion" in value) || + value.schemaVersion !== MODEL_CATALOG_SCHEMA_VERSION || + !("defaultRevision" in value) || + typeof value.defaultRevision !== "string" || + !MODEL_CATALOG_REVISION_RE.test(value.defaultRevision) || + !("catalogs" in value) || + !Array.isArray(value.catalogs) || + value.catalogs.length === 0 || + value.catalogs.some( + (catalog) => + typeof catalog !== "object" || + catalog === null || + typeof catalog.minimumPiVersion !== "string" || + !parsePiVersion(catalog.minimumPiVersion) || + typeof catalog.revision !== "string" || + !MODEL_CATALOG_REVISION_RE.test(catalog.revision), + ) || + !value.catalogs.some((catalog) => catalog.revision === value.defaultRevision) + ) { + throw new Error(`Model catalog index is invalid: ${MODEL_CATALOG_INDEX_KEY}`); + } + + return { + schemaVersion: value.schemaVersion, + defaultRevision: value.defaultRevision, + catalogs: value.catalogs.map(({ minimumPiVersion, revision }) => ({ + minimumPiVersion, + revision, + })), + }; +} + +function requirePiVersion(version: string): ParsedPiVersion { + const parsed = parsePiVersion(version); + if (!parsed) { + throw new Error(`Invalid Pi version: ${version}`); + } + return parsed; +} + +function parsePiVersion(version: string): ParsedPiVersion | undefined { + const match = PI_VERSION_RE.exec(version.trim()); + if (!match) { + return undefined; + } + const parsed = [Number(match[1]), Number(match[2]), Number(match[3])] as const; + if (parsed.some((part) => !Number.isSafeInteger(part))) { + return undefined; + } + return [parsed[0], parsed[1], parsed[2], match[4] ?? ""]; +} + +function comparePiVersions(left: ParsedPiVersion, right: ParsedPiVersion): number { + for (const index of [0, 1, 2] as const) { + const difference = left[index] - right[index]; + if (difference !== 0) { + return difference; + } + } + + const leftPrerelease = left[3]; + const rightPrerelease = right[3]; + if (leftPrerelease === rightPrerelease) { + return 0; + } + if (!leftPrerelease) { + return 1; + } + if (!rightPrerelease) { + return -1; + } + + const leftParts = leftPrerelease.split("."); + const rightParts = rightPrerelease.split("."); + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const leftPart = leftParts[index]; + const rightPart = rightParts[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + + const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : undefined; + const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : undefined; + if (leftNumber !== undefined && rightNumber !== undefined) { + return leftNumber - rightNumber; + } + if (leftNumber !== undefined) return -1; + if (rightNumber !== undefined) return 1; + return leftPart.localeCompare(rightPart); + } + return 0; +} diff --git a/scripts/publish-model-catalog.mjs b/scripts/publish-model-catalog.mjs index c6d2cb727..984a7ea47 100644 --- a/scripts/publish-model-catalog.mjs +++ b/scripts/publish-model-catalog.mjs @@ -12,15 +12,18 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { isDeepStrictEqual } from "node:util"; +import { + compareModelCatalogPiVersions, + getModelCatalogArtifactKey, + getModelCatalogProviderKey, + MODEL_CATALOG_INDEX_KEY, + MODEL_CATALOG_PREFIX, + MODEL_CATALOG_SCHEMA_VERSION, + parseModelCatalogIndex, +} from "./model-catalog-protocol.ts"; -// Revision layout under `${CATALOG_PREFIX}/revisions//`: -// models.json, providers/.json chat-only catalog served to released clients -// models.all.json, providers/.all.json arrays containing every model type; -// served to clients that request typed models -// providers.json sorted provider ids shared by both variants -const CATALOG_SCHEMA_VERSION = 1; -const CATALOG_PREFIX = `models/v${CATALOG_SCHEMA_VERSION}`; -const CATALOG_INDEX_KEY = `${CATALOG_PREFIX}/index.json`; +// The storage layout, index format, and version ordering are defined in +// model-catalog-protocol.ts, which pi.dev shares to serve these artifacts. // Bump this only when generated model metadata requires behavior unavailable in older pi clients. const MINIMUM_PI_VERSION = "0.80.7"; const JSON_CONTENT_TYPE = "application/json; charset=utf-8"; @@ -199,7 +202,7 @@ function downloadIndex(bucket, endpoint, outputPath) { [ "s3", "cp", - `s3://${bucket}/${CATALOG_INDEX_KEY}`, + `s3://${bucket}/${MODEL_CATALOG_INDEX_KEY}`, outputPath, "--endpoint-url", endpoint, @@ -225,40 +228,13 @@ function uploadJson(bucket, endpoint, sourcePath, key, cacheControl) { ]); } +// Validate with the same parser pi.dev uses, but keep the stored entries so +// the publication metadata of existing revisions is preserved. function validateIndex(index) { - if ( - typeof index !== "object" || - index === null || - Array.isArray(index) || - index.schemaVersion !== CATALOG_SCHEMA_VERSION - ) { - throw new Error(`Existing ${CATALOG_INDEX_KEY} has an unsupported schema`); - } - if (!Array.isArray(index.catalogs)) throw new Error(`Existing ${CATALOG_INDEX_KEY} has no catalogs array`); - for (const catalog of index.catalogs) { - if ( - typeof catalog !== "object" || - catalog === null || - Array.isArray(catalog) || - typeof catalog.minimumPiVersion !== "string" || - typeof catalog.revision !== "string" - ) { - throw new Error(`Existing ${CATALOG_INDEX_KEY} contains an invalid catalog entry`); - } - } + parseModelCatalogIndex(index); return index; } -function comparePiVersions(left, right) { - const leftParts = left.split(".").map(Number); - const rightParts = right.split(".").map(Number); - for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index++) { - const difference = (leftParts[index] || 0) - (rightParts[index] || 0); - if (difference !== 0) return difference; - } - return left.localeCompare(right); -} - function buildIndex(existingIndex, publication) { const entry = { minimumPiVersion: MINIMUM_PI_VERSION, @@ -276,12 +252,12 @@ function buildIndex(existingIndex, publication) { const catalogs = (existingIndex?.catalogs || []) .filter((catalog) => catalog.minimumPiVersion !== MINIMUM_PI_VERSION) .concat(entry) - .sort((left, right) => comparePiVersions(left.minimumPiVersion, right.minimumPiVersion)); - return { - schemaVersion: CATALOG_SCHEMA_VERSION, + .sort((left, right) => compareModelCatalogPiVersions(left.minimumPiVersion, right.minimumPiVersion)); + return validateIndex({ + schemaVersion: MODEL_CATALOG_SCHEMA_VERSION, defaultRevision: publication.revision, catalogs, - }; + }); } async function main() { @@ -289,7 +265,7 @@ async function main() { const inputDir = resolve(options.input); const bundle = validateBundle(inputDir); const publication = { - schemaVersion: CATALOG_SCHEMA_VERSION, + schemaVersion: MODEL_CATALOG_SCHEMA_VERSION, minimumPiVersion: MINIMUM_PI_VERSION, revision: bundle.revision, sourceCommit: options.sourceCommit || gitSourceCommit(), @@ -324,39 +300,25 @@ async function main() { return; } - const revisionPrefix = `${CATALOG_PREFIX}/revisions/${bundle.revision}`; - uploadJson(options.bucket, options.endpoint, bundle.modelsPath, `${revisionPrefix}/models.json`, IMMUTABLE_CACHE_CONTROL); - uploadJson( - options.bucket, - options.endpoint, - bundle.allModelsPath, - `${revisionPrefix}/models.all.json`, - IMMUTABLE_CACHE_CONTROL, - ); - uploadJson( - options.bucket, - options.endpoint, - bundle.providerIndexPath, - `${revisionPrefix}/providers.json`, - IMMUTABLE_CACHE_CONTROL, - ); - for (const providerId of bundle.providerIds) { - for (const shard of [`${providerId}.json`, `${providerId}.all.json`]) { - uploadJson( - options.bucket, - options.endpoint, - join(bundle.providersDir, shard), - `${revisionPrefix}/providers/${shard}`, - IMMUTABLE_CACHE_CONTROL, - ); - } + const revision = bundle.revision; + const uploads = [ + [bundle.modelsPath, getModelCatalogArtifactKey(revision, "models.json")], + [bundle.allModelsPath, getModelCatalogArtifactKey(revision, "models.all.json")], + [bundle.providerIndexPath, getModelCatalogArtifactKey(revision, "providers.json")], + ...bundle.providerIds.flatMap((providerId) => [ + [join(bundle.providersDir, `${providerId}.json`), getModelCatalogProviderKey(revision, providerId, "legacy")], + [join(bundle.providersDir, `${providerId}.all.json`), getModelCatalogProviderKey(revision, providerId, "typed")], + ]), + ]; + for (const [sourcePath, key] of uploads) { + uploadJson(options.bucket, options.endpoint, sourcePath, key, IMMUTABLE_CACHE_CONTROL); } const nextIndex = buildIndex(currentIndex, publication); const nextIndexPath = join(temporaryDir, "index-next.json"); writeFileSync(nextIndexPath, `${JSON.stringify(nextIndex, null, 2)}\n`); - uploadJson(options.bucket, options.endpoint, nextIndexPath, CATALOG_INDEX_KEY, INDEX_CACHE_CONTROL); - console.log(`Published ${bundle.revision} to s3://${options.bucket}/${revisionPrefix}`); + uploadJson(options.bucket, options.endpoint, nextIndexPath, MODEL_CATALOG_INDEX_KEY, INDEX_CACHE_CONTROL); + console.log(`Published ${revision} to s3://${options.bucket}/${MODEL_CATALOG_PREFIX}/revisions/${revision}`); } finally { rmSync(temporaryDir, { recursive: true, force: true }); } diff --git a/tsconfig.json b/tsconfig.json index d86e580ce..da12bbfd7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,6 +54,6 @@ "@earendil-works/pi-agent-old/*": ["./packages/agent-old/src/*"] } }, - "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/session-backends/*/src/**/*", "packages/session-backends/*/test/**/*", "packages/coding-agent/examples/**/*"], + "include": ["scripts/model-catalog-protocol.ts", "scripts/model-catalog-protocol.test.ts", "packages/*/src/**/*", "packages/*/test/**/*", "packages/session-backends/*/src/**/*", "packages/session-backends/*/test/**/*", "packages/coding-agent/examples/**/*"], "exclude": ["**/dist/**", "packages/coding-agent/examples/extensions/gondolin/**"] }