mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
c70aee247e
* refactor(scripts): migrate JavaScript tools to TypeScript * fix(ci): keep changed-scope preflight zero-install * fix(ci): preserve zero-install script owners * fix(ci): complete script migration follow-through * fix(release): keep stable closeout zero-install * fix(scripts): preserve standalone execution boundaries * fix(scripts): repair standalone loader boundaries * fix(scripts): normalize gateway observation ids * fix(scripts): keep Docker packager standalone * test(scripts): preserve rebase cleanup helpers * test(sessions): use tracked temp directory
836 lines
27 KiB
TypeScript
836 lines
27 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import type {
|
|
RemoteModelCatalogBundle,
|
|
RemoteModelCatalogPricing,
|
|
} from "../packages/model-catalog-core/src/remote-catalog-bundle.js";
|
|
import type {
|
|
PluginManifestModelPricingProvider,
|
|
PluginManifestModelPricingSource,
|
|
} from "../src/plugins/manifest-types.js";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
|
|
type ModelCatalogManifestInput = {
|
|
pluginId: string;
|
|
manifestPath: string;
|
|
manifest: {
|
|
modelCatalog?: {
|
|
providers?: Record<string, unknown>;
|
|
};
|
|
modelPricing?: {
|
|
providers?: Record<string, unknown>;
|
|
};
|
|
};
|
|
};
|
|
|
|
type PublishedModelPricing = RemoteModelCatalogPricing;
|
|
type PublishedModelCatalogBundle = RemoteModelCatalogBundle;
|
|
|
|
type PricingSource = Exclude<keyof PluginManifestModelPricingProvider, "external">;
|
|
type PricingPolicies = Map<string, PluginManifestModelPricingProvider>;
|
|
type PricingCatalog = Map<string, PublishedModelPricing>;
|
|
type RuntimePricingEntries = Map<
|
|
string,
|
|
Partial<Record<PricingSource, PublishedModelPricing | undefined>>
|
|
>;
|
|
type BundleValidator = (bundle: unknown) => PublishedModelCatalogBundle;
|
|
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 = resolveRepoRoot(import.meta.url);
|
|
|
|
function requireOptionValue(args: string[], index: number, flag: string): string {
|
|
const value = args[index + 1]?.trim();
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${flag} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parsePublishModelCatalogArgs(args: string[]) {
|
|
let dryRun = false;
|
|
let pricing = false;
|
|
let out: string | undefined;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--dry-run") {
|
|
dryRun = true;
|
|
continue;
|
|
}
|
|
if (arg === "--pricing") {
|
|
pricing = true;
|
|
continue;
|
|
}
|
|
if (arg === "--out") {
|
|
out = requireOptionValue(args, index, arg);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
if (!dryRun && !out) {
|
|
throw new Error("provide --out <file> or --dry-run");
|
|
}
|
|
return { dryRun, pricing, ...(out ? { out } : {}) };
|
|
}
|
|
|
|
export function readModelCatalogManifests(
|
|
options: { rootDir?: string } = {},
|
|
): ModelCatalogManifestInput[] {
|
|
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: {
|
|
manifests: ModelCatalogManifestInput[];
|
|
generatedAt: number;
|
|
sourceCommit: string;
|
|
minVersion?: string;
|
|
validateBundle?: BundleValidator;
|
|
}): Promise<PublishedModelCatalogBundle> {
|
|
const providers: Record<string, unknown> = {};
|
|
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: PublishedModelCatalogBundle) {
|
|
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: unknown): number | null {
|
|
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: number | null): number {
|
|
return value === null || value < 0 || !Number.isFinite(value) ? 0 : value * 1_000_000;
|
|
}
|
|
|
|
function parseOpenRouterPricing(value: unknown): PublishedModelPricing | null {
|
|
if (!isRecord(value)) {
|
|
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: unknown,
|
|
): PublishedModelPricing["tieredPricing"] | undefined {
|
|
if (!Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const tiers: NonNullable<PublishedModelPricing["tieredPricing"]> = [];
|
|
for (const raw of value) {
|
|
if (!isRecord(raw)) {
|
|
continue;
|
|
}
|
|
if (!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;
|
|
const range: [number] | [number, number] =
|
|
rawEnd === null || rawEnd <= start ? [start] : [start, rawEnd];
|
|
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,
|
|
});
|
|
}
|
|
return tiers.length > 0
|
|
? tiers.toSorted((left, right) => left.range[0] - right.range[0])
|
|
: undefined;
|
|
}
|
|
|
|
function parseLiteLLMPricing(value: unknown): PublishedModelPricing | null {
|
|
if (!isRecord(value)) {
|
|
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: PublishedModelPricing): PublishedModelPricing {
|
|
return {
|
|
input: pricing.input,
|
|
output: pricing.output,
|
|
...((pricing.cacheRead ?? 0) > 0 ? { cacheRead: pricing.cacheRead } : {}),
|
|
...((pricing.cacheWrite ?? 0) > 0 ? { cacheWrite: pricing.cacheWrite } : {}),
|
|
...(pricing.tieredPricing ? { tieredPricing: pricing.tieredPricing } : {}),
|
|
};
|
|
}
|
|
|
|
function mergePricing(
|
|
primary: PublishedModelPricing | undefined,
|
|
secondary: PublishedModelPricing | undefined,
|
|
): PublishedModelPricing | undefined {
|
|
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: Partial<PublishedModelPricing>): boolean {
|
|
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: string, transforms?: string[]): string[] {
|
|
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<string>(),
|
|
}: {
|
|
providerId: string;
|
|
modelId: string;
|
|
source: PricingSource;
|
|
policies: PricingPolicies;
|
|
seen?: Set<string>;
|
|
}): string[] {
|
|
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 configuredSource = policy?.[source];
|
|
const sourcePolicy = configuredSource === false ? undefined : configuredSource;
|
|
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: string, transforms?: string[]): string[] {
|
|
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: RuntimePricingEntries,
|
|
key: string,
|
|
source: PricingSource,
|
|
pricing: PublishedModelPricing | undefined,
|
|
): void {
|
|
const entry = entries.get(key) ?? {};
|
|
entry[source] = pricing;
|
|
entries.set(key, entry);
|
|
}
|
|
|
|
function collectPolicyRuntimePricingAliases({
|
|
providerId,
|
|
policy,
|
|
source,
|
|
catalog,
|
|
entries,
|
|
}: {
|
|
providerId: string;
|
|
policy: PluginManifestModelPricingProvider | undefined;
|
|
source: PricingSource;
|
|
catalog: PricingCatalog;
|
|
entries: RuntimePricingEntries;
|
|
}): void {
|
|
const configuredSource = policy?.[source];
|
|
const sourcePolicy = configuredSource === false ? undefined : configuredSource;
|
|
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,
|
|
sourcePolicy ? pricing : undefined,
|
|
);
|
|
}
|
|
}
|
|
if (sourcePolicy?.passthroughProviderModel) {
|
|
setRuntimePricingSource(entries, `${providerId}/${sourceKey}`, source, pricing);
|
|
}
|
|
}
|
|
}
|
|
|
|
function materializePolicyRuntimePricing({
|
|
hostedPricing,
|
|
policies,
|
|
openRouterCatalog,
|
|
liteLlmCatalog,
|
|
pricedProviderModelKeys,
|
|
}: {
|
|
hostedPricing: PricingCatalog;
|
|
policies: PricingPolicies;
|
|
openRouterCatalog: PricingCatalog;
|
|
liteLlmCatalog: PricingCatalog;
|
|
pricedProviderModelKeys: Set<string>;
|
|
}): void {
|
|
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: RuntimePricingEntries = 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 normalizePricingSource(
|
|
value: unknown,
|
|
): PluginManifestModelPricingSource | false | undefined {
|
|
if (value === false) {
|
|
return false;
|
|
}
|
|
if (!isRecord(value)) {
|
|
return undefined;
|
|
}
|
|
const modelIdTransforms = Array.isArray(value.modelIdTransforms)
|
|
? value.modelIdTransforms.filter(
|
|
(transform): transform is "version-dots" => transform === "version-dots",
|
|
)
|
|
: undefined;
|
|
return {
|
|
...(typeof value.provider === "string" ? { provider: value.provider } : {}),
|
|
...(typeof value.passthroughProviderModel === "boolean"
|
|
? { passthroughProviderModel: value.passthroughProviderModel }
|
|
: {}),
|
|
...(modelIdTransforms && modelIdTransforms.length > 0 ? { modelIdTransforms } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizePricingPolicy(value: unknown): PluginManifestModelPricingProvider | undefined {
|
|
if (!isRecord(value)) {
|
|
return undefined;
|
|
}
|
|
const openRouter = normalizePricingSource(value.openRouter);
|
|
const liteLLM = normalizePricingSource(value.liteLLM);
|
|
return {
|
|
...(typeof value.external === "boolean" ? { external: value.external } : {}),
|
|
...(openRouter !== undefined ? { openRouter } : {}),
|
|
...(liteLLM !== undefined ? { liteLLM } : {}),
|
|
};
|
|
}
|
|
|
|
function readPricingPolicies(manifests: ModelCatalogManifestInput[]): PricingPolicies {
|
|
const policies: PricingPolicies = new Map();
|
|
for (const entry of manifests) {
|
|
for (const [providerId, rawPolicy] of Object.entries(
|
|
entry.manifest?.modelPricing?.providers ?? {},
|
|
)) {
|
|
const policy = normalizePricingPolicy(rawPolicy);
|
|
if (policy) {
|
|
policies.set(providerId, policy);
|
|
}
|
|
}
|
|
}
|
|
return policies;
|
|
}
|
|
|
|
async function readJsonResponse(response: Response, source: string) {
|
|
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: Uint8Array[] = [];
|
|
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: unknown;
|
|
try {
|
|
payload = JSON.parse(new TextDecoder().decode(bytes));
|
|
} catch {
|
|
throw new Error(`${source} response is malformed JSON`);
|
|
}
|
|
if (!isRecord(payload)) {
|
|
throw new Error(`${source} response is not a JSON object`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function fetchPricingSources(fetchImpl: typeof fetch) {
|
|
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: {
|
|
bundle: PublishedModelCatalogBundle;
|
|
manifests: ModelCatalogManifestInput[];
|
|
fetchImpl?: typeof fetch;
|
|
validateBundle?: BundleValidator;
|
|
}): Promise<{ modelsEnriched: number; pricingEntries: number }> {
|
|
const policies = readPricingPolicies(options.manifests);
|
|
const sources = await fetchPricingSources(options.fetchImpl ?? fetch);
|
|
const openRouterCatalog: PricingCatalog = new Map();
|
|
for (const row of Array.isArray(sources.openRouter.data) ? sources.openRouter.data : []) {
|
|
if (!isRecord(row)) {
|
|
continue;
|
|
}
|
|
const pricing = parseOpenRouterPricing(row.pricing);
|
|
if (typeof row.id === "string" && pricing) {
|
|
openRouterCatalog.set(row.id, pricing);
|
|
}
|
|
}
|
|
const liteLlmCatalog: PricingCatalog = new Map();
|
|
const liteLlmAliasGroups: string[][] = [];
|
|
for (const [id, row] of Object.entries(sources.liteLlm)) {
|
|
if (!isRecord(row)) {
|
|
continue;
|
|
}
|
|
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<string>();
|
|
const pricedProviderModelKeys = new Set<string>();
|
|
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: PricingCatalog = new Map();
|
|
for (const [key, pricing] of openRouterCatalog) {
|
|
hostedPricing.set(key, pricing);
|
|
}
|
|
for (const [key, pricing] of liteLlmCatalog) {
|
|
const merged = mergePricing(hostedPricing.get(key), pricing);
|
|
if (merged) {
|
|
hostedPricing.set(key, merged);
|
|
}
|
|
}
|
|
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: unknown): unknown {
|
|
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: PublishedModelCatalogBundle): string {
|
|
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: string): string {
|
|
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "inherit"],
|
|
}).trim();
|
|
}
|
|
|
|
async function runPublishModelCatalog(
|
|
options: {
|
|
args?: string[];
|
|
fetchImpl?: typeof fetch;
|
|
now?: () => number;
|
|
rootDir?: string;
|
|
sourceCommit?: string;
|
|
} = {},
|
|
) {
|
|
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 };
|
|
}
|
|
|
|
if (!args.out) {
|
|
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);
|
|
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 errorExitCode =
|
|
error && typeof error === "object" && "exitCode" in error ? error.exitCode : undefined;
|
|
const exitCode =
|
|
typeof errorExitCode === "number" && Number.isInteger(errorExitCode) ? errorExitCode : 1;
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.stderr.write(`[${SCRIPT_LABEL}] FAILED (exit ${exitCode})\n`);
|
|
process.exitCode = exitCode;
|
|
}
|
|
}
|