Files
openclaw/scripts/publish-model-catalog.mjs
Peter Steinberger 5347285d6b improve(models): source pricing from hosted catalog (#114060)
* feat(model-catalog): serve hosted fallback pricing

* refactor(config): retire client pricing bootstrap settings

* refactor(gateway): delete client pricing refresh runtime

* docs(models): explain hosted catalog pricing

* fix(model-catalog): preserve pricing privacy and aliases

* fix(model-catalog): fingerprint pricing eligibility

* fix(model-catalog): harden pricing endpoint checks

* fix(model-catalog): materialize source-safe pricing aliases

* fix(model-catalog): keep unknown pricing fallbacks safe

* fix(model-catalog): reject zero-only hosted prices

* fix(model-catalog): fail closed without pricing policy metadata

* refactor(utils): extract usage pricing normalization

* fix(model-catalog): rebuild policy-owned pricing namespaces

* test(model-catalog): type publisher cost fixtures

* chore(config): regenerate schema baselines

* fix(utils): keep raw pricing tiers private
2026-07-26 03:48:25 -04:00

682 lines
22 KiB
JavaScript

import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const MODEL_CATALOG_MIN_VERSION = "2026.7.0";
export const MODEL_CATALOG_MIN_MODELS = 200;
export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
export const LITELLM_PRICING_URL =
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
const SCRIPT_LABEL = "publish-model-catalog";
const PRICING_FETCH_TIMEOUT_MS = 60_000;
const MAX_PRICING_CATALOG_BYTES = 5 * 1024 * 1024;
const BUNDLE_SIZE_WARNING_BYTES = 2 * 1024 * 1024;
const CLIENT_BUNDLE_LIMIT_BYTES = 4 * 1024 * 1024;
const defaultRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function requireOptionValue(args, index, flag) {
const value = args[index + 1]?.trim();
if (!value || value.startsWith("--")) {
throw new Error(`${flag} requires a value`);
}
return value;
}
export function parsePublishModelCatalogArgs(args) {
const options = { dryRun: false, pricing: false };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--dry-run") {
options.dryRun = true;
continue;
}
if (arg === "--pricing") {
options.pricing = true;
continue;
}
if (arg === "--out") {
options.out = requireOptionValue(args, index, arg);
index += 1;
continue;
}
throw new Error(`unknown argument: ${arg}`);
}
if (!options.dryRun && !options.out) {
throw new Error("provide --out <file> or --dry-run");
}
return options;
}
export function readModelCatalogManifests(options = {}) {
const rootDir = options.rootDir ?? defaultRootDir;
const extensionsDir = path.join(rootDir, "extensions");
return fs
.readdirSync(extensionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => ({
pluginId: entry.name,
manifestPath: path.join(extensionsDir, entry.name, "openclaw.plugin.json"),
}))
.filter((entry) => fs.existsSync(entry.manifestPath))
.map((entry) => ({
pluginId: entry.pluginId,
manifestPath: entry.manifestPath,
manifest: JSON.parse(fs.readFileSync(entry.manifestPath, "utf8")),
}))
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
}
async function loadClientBundleValidator() {
const { tsImport } = await import("tsx/esm/api");
const modulePath = path.join(
defaultRootDir,
"packages/model-catalog-core/src/remote-catalog-bundle.ts",
);
const module = await tsImport(pathToFileURL(modulePath).href, import.meta.url);
if (typeof module.validateAndSanitizeRemoteModelCatalogBundle !== "function") {
throw new Error("remote catalog bundle validator export is unavailable");
}
return module.validateAndSanitizeRemoteModelCatalogBundle;
}
export async function assembleModelCatalogBundle(options) {
const providers = {};
for (const entry of options.manifests) {
const declaredProviders = entry.manifest?.modelCatalog?.providers;
if (
!declaredProviders ||
typeof declaredProviders !== "object" ||
Array.isArray(declaredProviders)
) {
continue;
}
for (const [providerId, provider] of Object.entries(declaredProviders)) {
if (Object.hasOwn(providers, providerId)) {
throw new Error(`provider ${providerId} is declared by more than one plugin manifest`);
}
providers[providerId] = provider;
}
}
if (!Object.hasOwn(providers, "anthropic") || !Object.hasOwn(providers, "openai")) {
throw new Error("catalog must include anthropic and openai providers");
}
const bundle = {
schemaVersion: 1,
generatedAt: options.generatedAt,
minVersion: options.minVersion ?? MODEL_CATALOG_MIN_VERSION,
sourceCommit: options.sourceCommit,
providers,
};
const validateBundle = options.validateBundle ?? (await loadClientBundleValidator());
const validated = validateBundle(bundle);
const summary = summarizeModelCatalogBundle(validated);
if (summary.models < MODEL_CATALOG_MIN_MODELS) {
throw new Error(
`catalog model count ${summary.models} is below required floor ${MODEL_CATALOG_MIN_MODELS}`,
);
}
return validated;
}
export function summarizeModelCatalogBundle(bundle) {
const providerRows = Object.values(bundle.providers);
return {
providers: providerRows.length,
models: providerRows.reduce((total, provider) => total + provider.models.length, 0),
costModels: providerRows.reduce(
(total, provider) => total + provider.models.filter((model) => model.cost).length,
0,
),
pricingEntries: Object.keys(bundle.pricing ?? {}).length,
};
}
function parseNumberString(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value !== "string" || !value.trim()) {
return null;
}
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
function toPricePerMillion(value) {
return value === null || value < 0 || !Number.isFinite(value) ? 0 : value * 1_000_000;
}
function parseOpenRouterPricing(value) {
if (!value || typeof value !== "object") {
return null;
}
const prompt = parseNumberString(value.prompt);
const completion = parseNumberString(value.completion);
if (prompt === null || completion === null || prompt < 0 || completion < 0) {
return null;
}
return {
input: toPricePerMillion(prompt),
output: toPricePerMillion(completion),
cacheRead: toPricePerMillion(parseNumberString(value.input_cache_read)),
cacheWrite: toPricePerMillion(parseNumberString(value.input_cache_write)),
};
}
function parseLiteLLMTieredPricing(value) {
if (!Array.isArray(value)) {
return undefined;
}
const tiers = [];
for (const raw of value) {
if (!raw || typeof raw !== "object" || !Array.isArray(raw.range)) {
continue;
}
const input = parseNumberString(raw.input_cost_per_token);
const output = parseNumberString(raw.output_cost_per_token);
const start = parseNumberString(raw.range[0]);
if (input === null || output === null || start === null || input < 0 || output < 0) {
continue;
}
const rawEnd = raw.range.length >= 2 ? parseNumberString(raw.range[1]) : null;
tiers.push({
input: toPricePerMillion(input),
output: toPricePerMillion(output),
cacheRead: toPricePerMillion(parseNumberString(raw.cache_read_input_token_cost)),
cacheWrite: toPricePerMillion(parseNumberString(raw.cache_creation_input_token_cost)),
range: rawEnd === null || rawEnd <= start ? [start] : [start, rawEnd],
});
}
return tiers.length > 0
? tiers.toSorted((left, right) => left.range[0] - right.range[0])
: undefined;
}
function parseLiteLLMPricing(value) {
if (!value || typeof value !== "object") {
return null;
}
const input = parseNumberString(value.input_cost_per_token);
const output = parseNumberString(value.output_cost_per_token);
if (input === null || output === null || input < 0 || output < 0) {
return null;
}
const tieredPricing = parseLiteLLMTieredPricing(value.tiered_pricing);
return {
input: toPricePerMillion(input),
output: toPricePerMillion(output),
cacheRead: toPricePerMillion(parseNumberString(value.cache_read_input_token_cost)),
cacheWrite: toPricePerMillion(parseNumberString(value.cache_creation_input_token_cost)),
...(tieredPricing ? { tieredPricing } : {}),
};
}
function compactPricing(pricing) {
return {
input: pricing.input,
output: pricing.output,
...(pricing.cacheRead > 0 ? { cacheRead: pricing.cacheRead } : {}),
...(pricing.cacheWrite > 0 ? { cacheWrite: pricing.cacheWrite } : {}),
...(pricing.tieredPricing ? { tieredPricing: pricing.tieredPricing } : {}),
};
}
function mergePricing(primary, secondary) {
if (!primary || !hasKnownPricing(primary)) {
return secondary;
}
if (!secondary || !hasKnownPricing(secondary)) {
return primary;
}
if (!secondary?.tieredPricing) {
return primary;
}
return { ...primary, tieredPricing: secondary.tieredPricing };
}
function hasKnownPricing(pricing) {
return (
(pricing.input ?? 0) > 0 ||
(pricing.output ?? 0) > 0 ||
(pricing.cacheRead ?? 0) > 0 ||
(pricing.cacheWrite ?? 0) > 0 ||
Boolean(
pricing.tieredPricing?.some(
(tier) => tier.input > 0 || tier.output > 0 || tier.cacheRead > 0 || tier.cacheWrite > 0,
),
)
);
}
function applyModelIdTransforms(modelId, transforms) {
const variants = new Set([modelId]);
for (const transform of transforms ?? []) {
if (transform !== "version-dots") {
continue;
}
for (const variant of Array.from(variants)) {
variants.add(
variant
.replace(/^claude-(\d+)-(\d+)-/u, "claude-$1.$2-")
.replace(/^claude-([a-z]+)-(\d+)-(\d+)$/u, "claude-$1-$2.$3"),
);
}
}
return [...variants];
}
function buildPricingCandidates({ providerId, modelId, source, policies, seen = new Set() }) {
const ref = `${providerId}/${modelId}`;
if (seen.has(ref)) {
return [];
}
const nextSeen = new Set(seen).add(ref);
const policy = policies.get(providerId);
if (policy?.external === false) {
return [];
}
const sourcePolicy = policy?.[source];
if (policy && !sourcePolicy) {
return [];
}
const externalProvider = sourcePolicy?.provider ?? providerId;
const candidates = new Set(
applyModelIdTransforms(modelId, sourcePolicy?.modelIdTransforms).map(
(candidate) => `${externalProvider}/${candidate}`,
),
);
if (sourcePolicy?.passthroughProviderModel && modelId.includes("/")) {
const slash = modelId.indexOf("/");
for (const candidate of buildPricingCandidates({
providerId: modelId.slice(0, slash),
modelId: modelId.slice(slash + 1),
source,
policies,
seen: nextSeen,
})) {
candidates.add(candidate);
}
}
return [...candidates];
}
function reverseModelIdTransforms(modelId, transforms) {
const variants = new Set([modelId]);
for (const transform of transforms ?? []) {
if (transform !== "version-dots") {
continue;
}
for (const variant of Array.from(variants)) {
variants.add(
variant
.replace(/^claude-(\d+)\.(\d+)-/u, "claude-$1-$2-")
.replace(/^claude-([a-z]+)-(\d+)\.(\d+)$/u, "claude-$1-$2-$3"),
);
}
}
return [...variants];
}
function setRuntimePricingSource(entries, key, source, pricing) {
const entry = entries.get(key) ?? {};
entry[source] = pricing;
entries.set(key, entry);
}
function collectPolicyRuntimePricingAliases({ providerId, policy, source, catalog, entries }) {
const rawSourcePolicy = policy?.[source];
const sourceEnabled = Boolean(rawSourcePolicy);
const sourcePolicy = sourceEnabled ? rawSourcePolicy : {};
const sourceProvider = sourcePolicy.provider ?? providerId;
for (const [sourceKey, pricing] of catalog) {
const slash = sourceKey.indexOf("/");
if (slash <= 0 || slash === sourceKey.length - 1) {
continue;
}
const keyProvider = sourceKey.slice(0, slash);
const sourceModel = sourceKey.slice(slash + 1);
if (keyProvider === sourceProvider) {
for (const runtimeModel of reverseModelIdTransforms(
sourceModel,
sourcePolicy.modelIdTransforms,
)) {
setRuntimePricingSource(
entries,
`${providerId}/${runtimeModel}`,
source,
sourceEnabled ? pricing : undefined,
);
}
}
if (sourceEnabled && sourcePolicy.passthroughProviderModel) {
setRuntimePricingSource(entries, `${providerId}/${sourceKey}`, source, pricing);
}
}
}
function materializePolicyRuntimePricing({
hostedPricing,
policies,
openRouterCatalog,
liteLlmCatalog,
pricedProviderModelKeys,
}) {
for (const [providerId, policy] of policies) {
for (const key of hostedPricing.keys()) {
if (key.startsWith(`${providerId}/`)) {
hostedPricing.delete(key);
}
}
if (policy?.external === false) {
continue;
}
const entries = new Map();
collectPolicyRuntimePricingAliases({
providerId,
policy,
source: "openRouter",
catalog: openRouterCatalog,
entries,
});
collectPolicyRuntimePricingAliases({
providerId,
policy,
source: "liteLLM",
catalog: liteLlmCatalog,
entries,
});
for (const [key, entry] of entries) {
if (pricedProviderModelKeys.has(key)) {
hostedPricing.delete(key);
continue;
}
const pricing = mergePricing(entry.openRouter, entry.liteLLM);
if (pricing) {
hostedPricing.set(key, pricing);
} else {
hostedPricing.delete(key);
}
}
}
}
function readPricingPolicies(manifests) {
const policies = new Map();
for (const entry of manifests) {
for (const [providerId, policy] of Object.entries(
entry.manifest?.modelPricing?.providers ?? {},
)) {
policies.set(providerId, policy);
}
}
return policies;
}
async function readJsonResponse(response, source) {
if (!response.ok) {
throw new Error(`${source} request failed: HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > MAX_PRICING_CATALOG_BYTES) {
throw new Error(`${source} response exceeds ${MAX_PRICING_CATALOG_BYTES} bytes`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`${source} response has no body`);
}
const chunks = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > MAX_PRICING_CATALOG_BYTES) {
await reader.cancel();
throw new Error(`${source} response exceeds ${MAX_PRICING_CATALOG_BYTES} bytes`);
}
chunks.push(value);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
let payload;
try {
payload = JSON.parse(new TextDecoder().decode(bytes));
} catch {
throw new Error(`${source} response is malformed JSON`);
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error(`${source} response is not a JSON object`);
}
return payload;
}
async function fetchPricingSources(fetchImpl) {
const signal = AbortSignal.timeout(PRICING_FETCH_TIMEOUT_MS);
const results = await Promise.allSettled([
fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
signal,
}).then((response) => readJsonResponse(response, "OpenRouter")),
fetchImpl(LITELLM_PRICING_URL, {
headers: { Accept: "application/json" },
signal,
}).then((response) => readJsonResponse(response, "LiteLLM")),
]);
const [openRouterResult, liteLlmResult] = results;
for (const { label, result } of [
{ label: "OpenRouter", result: openRouterResult },
{ label: "LiteLLM", result: liteLlmResult },
]) {
if (result.status === "rejected") {
process.stderr.write(
`[${SCRIPT_LABEL}] warning: ${label} pricing unavailable: ${String(result.reason)}\n`,
);
}
}
return {
openRouter: openRouterResult.status === "fulfilled" ? openRouterResult.value : {},
liteLlm: liteLlmResult.status === "fulfilled" ? liteLlmResult.value : {},
};
}
export async function enrichModelCatalogPricing(options) {
const policies = readPricingPolicies(options.manifests);
const sources = await fetchPricingSources(options.fetchImpl ?? fetch);
const openRouterCatalog = new Map();
for (const row of Array.isArray(sources.openRouter.data) ? sources.openRouter.data : []) {
const pricing = parseOpenRouterPricing(row?.pricing);
if (typeof row?.id === "string" && pricing) {
openRouterCatalog.set(row.id, pricing);
}
}
const liteLlmCatalog = new Map();
const liteLlmAliasGroups = [];
for (const [id, row] of Object.entries(sources.liteLlm)) {
const pricing = parseLiteLLMPricing(row);
if (pricing) {
const aliases = [id];
if (typeof row?.litellm_provider === "string" && !id.includes("/")) {
aliases.push(`${row.litellm_provider}/${id}`);
}
for (const alias of aliases) {
liteLlmCatalog.set(alias, pricing);
}
liteLlmAliasGroups.push(aliases);
}
}
let enriched = 0;
const coveredPricingKeys = new Set();
const pricedProviderModelKeys = new Set();
for (const [providerId, provider] of Object.entries(options.bundle.providers)) {
for (const model of provider.models) {
const openRouterCandidates = buildPricingCandidates({
providerId,
modelId: model.id,
source: "openRouter",
policies,
});
const openRouterPricing = openRouterCandidates
.map((candidate) => openRouterCatalog.get(candidate))
.find(Boolean);
const liteLlmCandidates = buildPricingCandidates({
providerId,
modelId: model.id,
source: "liteLLM",
policies,
});
const liteLlmPricing = liteLlmCandidates
.map((candidate) => liteLlmCatalog.get(candidate))
.find(Boolean);
const cost = mergePricing(openRouterPricing, liteLlmPricing);
if (cost && hasKnownPricing(cost)) {
model.cost = cost;
enriched += 1;
}
if (model.cost && hasKnownPricing(model.cost)) {
const providerModelKey = `${providerId}/${model.id}`;
coveredPricingKeys.add(providerModelKey);
pricedProviderModelKeys.add(providerModelKey);
for (const candidate of [...openRouterCandidates, ...liteLlmCandidates]) {
coveredPricingKeys.add(candidate);
}
}
}
}
const hostedPricing = new Map();
for (const [key, pricing] of openRouterCatalog) {
hostedPricing.set(key, pricing);
}
for (const [key, pricing] of liteLlmCatalog) {
hostedPricing.set(key, mergePricing(hostedPricing.get(key), pricing));
}
for (const aliases of liteLlmAliasGroups) {
if (aliases.some((alias) => coveredPricingKeys.has(alias))) {
for (const alias of aliases) {
coveredPricingKeys.add(alias);
}
}
}
for (const key of coveredPricingKeys) {
hostedPricing.delete(key);
}
materializePolicyRuntimePricing({
hostedPricing,
policies,
openRouterCatalog,
liteLlmCatalog,
pricedProviderModelKeys,
});
options.bundle.pricing = Object.fromEntries(
[...hostedPricing.entries()]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, pricing]) => [key, compactPricing(pricing)]),
);
const validateBundle = options.validateBundle ?? (await loadClientBundleValidator());
const validated = validateBundle(options.bundle);
options.bundle.providers = validated.providers;
options.bundle.pricing = validated.pricing;
return { modelsEnriched: enriched, pricingEntries: hostedPricing.size };
}
function sortCatalogValue(value) {
if (Array.isArray(value)) {
return value.map(sortCatalogValue);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, sortCatalogValue(entry)]),
);
}
export function serializeModelCatalogBundle(bundle) {
const providers = Object.fromEntries(
Object.entries(bundle.providers)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([providerId, provider]) => [
providerId,
{
...provider,
models: provider.models.toSorted((left, right) => left.id.localeCompare(right.id)),
},
]),
);
return `${JSON.stringify(sortCatalogValue({ ...bundle, providers }), null, 2)}\n`;
}
function resolveSourceCommit(rootDir) {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
}).trim();
}
async function runPublishModelCatalog(options = {}) {
const rootDir = options.rootDir ?? defaultRootDir;
const args = parsePublishModelCatalogArgs(options.args ?? process.argv.slice(2));
const generatedAt = (options.now ?? Date.now)();
const sourceCommit = options.sourceCommit ?? resolveSourceCommit(rootDir);
const manifests = readModelCatalogManifests({ rootDir });
const bundle = await assembleModelCatalogBundle({
manifests,
generatedAt,
sourceCommit,
});
const pricingResult = args.pricing
? await enrichModelCatalogPricing({ bundle, manifests, fetchImpl: options.fetchImpl })
: { modelsEnriched: 0, pricingEntries: 0 };
const summary = summarizeModelCatalogBundle(bundle);
const serialized = serializeModelCatalogBundle(bundle);
const bundleBytes = Buffer.byteLength(serialized);
if (bundleBytes > BUNDLE_SIZE_WARNING_BYTES) {
process.stderr.write(
`[${SCRIPT_LABEL}] warning: bundle size ${bundleBytes} bytes exceeds ${BUNDLE_SIZE_WARNING_BYTES} bytes\n`,
);
}
if (bundleBytes > CLIENT_BUNDLE_LIMIT_BYTES) {
throw new Error(
`catalog bundle ${bundleBytes} bytes exceeds client limit ${CLIENT_BUNDLE_LIMIT_BYTES} bytes`,
);
}
const stats = `schemaVersion=1 providers=${summary.providers} models=${summary.models} costModels=${summary.costModels} pricingEnriched=${pricingResult.modelsEnriched} pricingEntries=${pricingResult.pricingEntries} bundleBytes=${bundleBytes} generatedAt=${bundle.generatedAt} minVersion=${bundle.minVersion} sourceCommit=${bundle.sourceCommit}`;
if (args.dryRun) {
process.stdout.write(`[${SCRIPT_LABEL}] dry-run ${stats}\n`);
return { bundle, summary, pricingEnriched: pricingResult.modelsEnriched, wrote: false };
}
const outputFile = path.resolve(rootDir, args.out);
if (args.out) {
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
fs.writeFileSync(outputFile, serialized);
}
process.stdout.write(`[${SCRIPT_LABEL}] published ${stats} out=${args.out}\n`);
return { bundle, summary, pricingEnriched: pricingResult.modelsEnriched, wrote: true };
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await runPublishModelCatalog();
} catch (error) {
const exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.stderr.write(`[${SCRIPT_LABEL}] FAILED (exit ${exitCode})\n`);
process.exitCode = exitCode;
}
}