sync update
This commit is contained in:
+3
-2
@@ -26,10 +26,11 @@
|
||||
"huggingface:sync": "bun ./packages/core/script/sync-models.ts huggingface",
|
||||
"venice:sync": "bun ./packages/core/script/sync-models.ts venice",
|
||||
"vercel:generate": "bun ./packages/core/script/sync-models.ts vercel",
|
||||
"wandb:generate": "bun ./packages/core/script/generate-wandb.ts",
|
||||
"wandb:generate": "bun ./packages/core/script/sync-models.ts wandb",
|
||||
"digitalocean:generate": "bun ./packages/core/script/generate-digitalocean.ts",
|
||||
"ambient:generate": "bun ./packages/core/script/generate-ambient.ts",
|
||||
"models:sync": "bun ./packages/core/script/sync-models.ts"
|
||||
"models:sync": "bun ./packages/core/script/sync-models.ts",
|
||||
"sync:models": "bun ./packages/core/script/sync-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260424.1",
|
||||
|
||||
@@ -1,627 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { inferKimiFamily, ModelFamily, ModelFamilyValues } from "../src/family.js";
|
||||
import { main } from "../src/sync/index.js";
|
||||
|
||||
// This endpoint already returns data in the models.dev schema, so most fields
|
||||
// map straight through. Only fields the catalog can't provide (family) are
|
||||
// inferred, and manually-curated fields in existing TOMLs are preserved.
|
||||
const API_ENDPOINT = "https://trace.wandb.ai/inference/modelsdev/models";
|
||||
|
||||
const ApiCost = z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number().optional(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
input_audio: z.number().optional(),
|
||||
output_audio: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ApiLimit = z
|
||||
.object({
|
||||
context: z.number(),
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ApiModalities = z
|
||||
.object({
|
||||
input: z.array(z.string()),
|
||||
output: z.array(z.string()),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ApiModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
tool_call: z.boolean(),
|
||||
structured_output: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
knowledge: z.string().optional(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
open_weights: z.boolean(),
|
||||
status: z.string().optional(),
|
||||
interleaved: z
|
||||
.union([z.boolean(), z.object({ field: z.string() })])
|
||||
.optional(),
|
||||
cost: ApiCost.optional(),
|
||||
limit: ApiLimit.optional(),
|
||||
modalities: ApiModalities.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ApiProvider = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
npm: z.string(),
|
||||
env: z.array(z.string()),
|
||||
doc: z.string(),
|
||||
api: z.string().optional(),
|
||||
models: z.record(z.string(), ApiModel),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// The models.dev `api.json` shape: a mapping of provider id -> provider.
|
||||
const ApiResponse = z.record(z.string(), ApiProvider);
|
||||
|
||||
interface ExistingModel {
|
||||
base_model?: string;
|
||||
base_model_omit?: string[];
|
||||
name?: string;
|
||||
family?: string;
|
||||
attachment?: boolean;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature?: boolean;
|
||||
knowledge?: string;
|
||||
release_date?: string;
|
||||
last_updated?: string;
|
||||
open_weights?: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
};
|
||||
limit?: {
|
||||
context?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
};
|
||||
modalities?: {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface MergedModel {
|
||||
base_model?: string;
|
||||
base_model_omit?: string[];
|
||||
name: string;
|
||||
family?: string;
|
||||
attachment: boolean;
|
||||
reasoning: boolean;
|
||||
tool_call: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature: boolean;
|
||||
knowledge?: string;
|
||||
release_date: string;
|
||||
last_updated: string;
|
||||
open_weights: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
};
|
||||
limit: {
|
||||
context: number;
|
||||
output: number;
|
||||
};
|
||||
modalities: {
|
||||
input: SupportedModality[];
|
||||
output: SupportedModality[];
|
||||
};
|
||||
}
|
||||
|
||||
interface Changes {
|
||||
field: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
type SupportedModality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
const modalityMap: Record<string, SupportedModality | undefined> = {
|
||||
text: "text",
|
||||
image: "image",
|
||||
audio: "audio",
|
||||
video: "video",
|
||||
pdf: "pdf",
|
||||
file: "pdf",
|
||||
files: "pdf",
|
||||
};
|
||||
|
||||
function getTodayDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1000) {
|
||||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_");
|
||||
}
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function formatDecimal(n: number): string {
|
||||
return Number(n.toFixed(6)).toString();
|
||||
}
|
||||
|
||||
function isSubstring(target: string, family: string): boolean {
|
||||
return target.toLowerCase().includes(family.toLowerCase());
|
||||
}
|
||||
|
||||
function matchesFamily(target: string, family: string): boolean {
|
||||
const targetLower = target.toLowerCase();
|
||||
const familyLower = family.toLowerCase();
|
||||
let familyIdx = 0;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < targetLower.length && familyIdx < familyLower.length;
|
||||
i++
|
||||
) {
|
||||
if (targetLower[i] === familyLower[familyIdx]) {
|
||||
familyIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return familyIdx === familyLower.length;
|
||||
}
|
||||
|
||||
function inferFamily(modelId: string, modelName: string): string | undefined {
|
||||
const kimiFamily = inferKimiFamily(modelId, modelName);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const sortedFamilies = [...ModelFamilyValues].sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
|
||||
for (const family of sortedFamilies) {
|
||||
if (isSubstring(modelId, family) || isSubstring(modelName, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
for (const family of sortedFamilies) {
|
||||
if (matchesFamily(modelId, family) || matchesFamily(modelName, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isValidFamily(family: string | undefined): family is ModelFamily {
|
||||
return family !== undefined && ModelFamily.safeParse(family).success;
|
||||
}
|
||||
|
||||
function resolveFamily(
|
||||
existing: ExistingModel | null,
|
||||
apiModel: z.infer<typeof ApiModel>,
|
||||
): ModelFamily | undefined {
|
||||
// Preserve a manually-curated family only when it's a recognized enum value;
|
||||
// otherwise fall back to inference so we never emit an invalid family.
|
||||
if (isValidFamily(existing?.family)) {
|
||||
return existing.family;
|
||||
}
|
||||
const inferred = inferFamily(apiModel.id, apiModel.name);
|
||||
return isValidFamily(inferred) ? inferred : undefined;
|
||||
}
|
||||
|
||||
function normalizeName(apiModel: z.infer<typeof ApiModel>): string {
|
||||
const stripped = apiModel.name.replace(/^[^:]+:\s*/, "").trim();
|
||||
return stripped || path.basename(apiModel.id);
|
||||
}
|
||||
|
||||
function normalizeModalities(values: string[]): SupportedModality[] {
|
||||
const normalized = values
|
||||
.map((value) => modalityMap[value.toLowerCase()])
|
||||
.filter((value): value is SupportedModality => value !== undefined);
|
||||
|
||||
return [...new Set(normalized)];
|
||||
}
|
||||
|
||||
async function loadExistingModel(
|
||||
filePath: string,
|
||||
): Promise<ExistingModel | null> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toml = await import(filePath, { with: { type: "toml" } }).then(
|
||||
(mod) => mod.default,
|
||||
);
|
||||
return toml as ExistingModel;
|
||||
} catch (cause) {
|
||||
console.warn(`Warning: Failed to parse existing file ${filePath}:`, cause);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeModel(
|
||||
apiModel: z.infer<typeof ApiModel>,
|
||||
existing: ExistingModel | null,
|
||||
): MergedModel {
|
||||
const inputModalities = normalizeModalities(apiModel.modalities?.input ?? []);
|
||||
const outputModalities = normalizeModalities(
|
||||
apiModel.modalities?.output ?? [],
|
||||
);
|
||||
|
||||
const merged: MergedModel = {
|
||||
...(existing?.base_model ? { base_model: existing.base_model } : {}),
|
||||
...(existing?.base_model_omit
|
||||
? { base_model_omit: existing.base_model_omit }
|
||||
: {}),
|
||||
name: existing?.name ?? normalizeName(apiModel),
|
||||
family: resolveFamily(existing, apiModel),
|
||||
attachment: existing?.attachment ?? apiModel.attachment,
|
||||
reasoning: existing?.reasoning ?? apiModel.reasoning,
|
||||
tool_call: existing?.tool_call ?? apiModel.tool_call,
|
||||
temperature: existing?.temperature ?? apiModel.temperature ?? true,
|
||||
release_date: existing?.release_date ?? apiModel.release_date,
|
||||
last_updated: getTodayDate(),
|
||||
open_weights: existing?.open_weights ?? apiModel.open_weights,
|
||||
...(existing?.structured_output !== undefined
|
||||
? { structured_output: existing.structured_output }
|
||||
: apiModel.structured_output
|
||||
? { structured_output: true }
|
||||
: {}),
|
||||
...((existing?.knowledge ?? apiModel.knowledge)
|
||||
? { knowledge: existing?.knowledge ?? apiModel.knowledge }
|
||||
: {}),
|
||||
...((existing?.interleaved ?? apiModel.interleaved)
|
||||
? { interleaved: existing?.interleaved ?? apiModel.interleaved }
|
||||
: {}),
|
||||
...((existing?.status ?? apiModel.status)
|
||||
? { status: existing?.status ?? apiModel.status }
|
||||
: {}),
|
||||
limit: {
|
||||
context: apiModel.limit?.context ?? existing?.limit?.context ?? 0,
|
||||
output: apiModel.limit?.output ?? existing?.limit?.output ?? 0,
|
||||
},
|
||||
modalities: {
|
||||
input:
|
||||
inputModalities.length > 0
|
||||
? inputModalities
|
||||
: ((existing?.modalities?.input as
|
||||
| SupportedModality[]
|
||||
| undefined) ?? ["text"]),
|
||||
output:
|
||||
outputModalities.length > 0
|
||||
? outputModalities
|
||||
: ((existing?.modalities?.output as
|
||||
| SupportedModality[]
|
||||
| undefined) ?? ["text"]),
|
||||
},
|
||||
};
|
||||
|
||||
if (apiModel.cost) {
|
||||
merged.cost = {
|
||||
input: apiModel.cost.input,
|
||||
output: apiModel.cost.output,
|
||||
...(apiModel.cost.cache_read && apiModel.cost.cache_read > 0
|
||||
? { cache_read: apiModel.cost.cache_read }
|
||||
: {}),
|
||||
...(apiModel.cost.cache_write && apiModel.cost.cache_write > 0
|
||||
? { cache_write: apiModel.cost.cache_write }
|
||||
: {}),
|
||||
};
|
||||
} else if (
|
||||
existing?.cost?.input !== undefined &&
|
||||
existing.cost.output !== undefined
|
||||
) {
|
||||
merged.cost = {
|
||||
input: existing.cost.input,
|
||||
output: existing.cost.output,
|
||||
...(existing.cost.cache_read !== undefined
|
||||
? { cache_read: existing.cost.cache_read }
|
||||
: {}),
|
||||
...(existing.cost.cache_write !== undefined
|
||||
? { cache_write: existing.cost.cache_write }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function formatToml(model: MergedModel): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (model.base_model !== undefined) {
|
||||
lines.push(`base_model = "${model.base_model}"`);
|
||||
}
|
||||
if (model.base_model_omit !== undefined) {
|
||||
lines.push(
|
||||
`base_model_omit = [${model.base_model_omit.map((item) => `"${item}"`).join(", ")}]`,
|
||||
);
|
||||
}
|
||||
lines.push(`name = "${model.name.replace(/"/g, '\\"')}"`);
|
||||
if (model.family) {
|
||||
lines.push(`family = "${model.family}"`);
|
||||
}
|
||||
lines.push(`release_date = "${model.release_date}"`);
|
||||
lines.push(`last_updated = "${model.last_updated}"`);
|
||||
lines.push(`attachment = ${model.attachment}`);
|
||||
lines.push(`reasoning = ${model.reasoning}`);
|
||||
if (model.structured_output !== undefined) {
|
||||
lines.push(`structured_output = ${model.structured_output}`);
|
||||
}
|
||||
lines.push(`temperature = ${model.temperature}`);
|
||||
lines.push(`tool_call = ${model.tool_call}`);
|
||||
if (model.knowledge) {
|
||||
lines.push(`knowledge = "${model.knowledge}"`);
|
||||
}
|
||||
lines.push(`open_weights = ${model.open_weights}`);
|
||||
if (model.status) {
|
||||
lines.push(`status = "${model.status}"`);
|
||||
}
|
||||
|
||||
if (model.interleaved !== undefined) {
|
||||
lines.push("");
|
||||
if (model.interleaved === true) {
|
||||
lines.push("interleaved = true");
|
||||
} else if (model.interleaved !== false) {
|
||||
lines.push("[interleaved]");
|
||||
lines.push(`field = "${model.interleaved.field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.cost) {
|
||||
lines.push("");
|
||||
lines.push("[cost]");
|
||||
lines.push(`input = ${formatDecimal(model.cost.input)}`);
|
||||
lines.push(`output = ${formatDecimal(model.cost.output)}`);
|
||||
if (model.cost.cache_read !== undefined) {
|
||||
lines.push(`cache_read = ${formatDecimal(model.cost.cache_read)}`);
|
||||
}
|
||||
if (model.cost.cache_write !== undefined) {
|
||||
lines.push(`cache_write = ${formatDecimal(model.cost.cache_write)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("[limit]");
|
||||
lines.push(`context = ${formatNumber(model.limit.context)}`);
|
||||
lines.push(`output = ${formatNumber(model.limit.output)}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push("[modalities]");
|
||||
lines.push(
|
||||
`input = [${model.modalities.input.map((m) => `"${m}"`).join(", ")}]`,
|
||||
);
|
||||
lines.push(
|
||||
`output = [${model.modalities.output.map((m) => `"${m}"`).join(", ")}]`,
|
||||
);
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function detectChanges(
|
||||
existing: ExistingModel | null,
|
||||
merged: MergedModel,
|
||||
): Changes[] {
|
||||
if (!existing) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const changes: Changes[] = [];
|
||||
const epsilon = 0.001;
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === "number") return formatNumber(value);
|
||||
if (Array.isArray(value)) return `[${value.join(", ")}]`;
|
||||
if (value === undefined) return "(none)";
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const compare = (field: string, oldValue: unknown, newValue: unknown) => {
|
||||
const changed = field.startsWith("cost.")
|
||||
? oldValue === undefined && newValue === undefined
|
||||
? false
|
||||
: oldValue === undefined || newValue === undefined
|
||||
? true
|
||||
: Math.abs((oldValue as number) - (newValue as number)) > epsilon
|
||||
: JSON.stringify(oldValue) !== JSON.stringify(newValue);
|
||||
|
||||
if (changed) {
|
||||
changes.push({
|
||||
field,
|
||||
oldValue: formatValue(oldValue),
|
||||
newValue: formatValue(newValue),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
compare("name", existing.name, merged.name);
|
||||
compare("family", existing.family, merged.family);
|
||||
compare("release_date", existing.release_date, merged.release_date);
|
||||
compare("attachment", existing.attachment, merged.attachment);
|
||||
compare("reasoning", existing.reasoning, merged.reasoning);
|
||||
compare(
|
||||
"structured_output",
|
||||
existing.structured_output,
|
||||
merged.structured_output,
|
||||
);
|
||||
compare("temperature", existing.temperature, merged.temperature);
|
||||
compare("tool_call", existing.tool_call, merged.tool_call);
|
||||
compare("open_weights", existing.open_weights, merged.open_weights);
|
||||
compare("cost.input", existing.cost?.input, merged.cost?.input);
|
||||
compare("cost.output", existing.cost?.output, merged.cost?.output);
|
||||
compare(
|
||||
"cost.cache_read",
|
||||
existing.cost?.cache_read,
|
||||
merged.cost?.cache_read,
|
||||
);
|
||||
compare(
|
||||
"cost.cache_write",
|
||||
existing.cost?.cache_write,
|
||||
merged.cost?.cache_write,
|
||||
);
|
||||
compare("limit.context", existing.limit?.context, merged.limit.context);
|
||||
compare("limit.output", existing.limit?.output, merged.limit.output);
|
||||
compare(
|
||||
"modalities.input",
|
||||
existing.modalities?.input,
|
||||
merged.modalities.input,
|
||||
);
|
||||
compare(
|
||||
"modalities.output",
|
||||
existing.modalities?.output,
|
||||
merged.modalities.output,
|
||||
);
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const newOnly = args.includes("--new-only");
|
||||
|
||||
const modelsDir = path.join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"providers",
|
||||
"wandb",
|
||||
"models",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`${dryRun ? "[DRY RUN] " : ""}${newOnly ? "[NEW ONLY] " : ""}Fetching WandB models from API...`,
|
||||
);
|
||||
|
||||
const res = await fetch(API_ENDPOINT);
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch API: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ApiResponse.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid API response:", parsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The response groups models by provider; flatten to a single list.
|
||||
const apiModels = Object.values(parsed.data).flatMap((provider) =>
|
||||
Object.values(provider.models),
|
||||
);
|
||||
const existingFiles = new Set<string>();
|
||||
|
||||
for await (const file of new Bun.Glob("**/*.toml").scan({
|
||||
cwd: modelsDir,
|
||||
absolute: false,
|
||||
})) {
|
||||
existingFiles.add(file);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${apiModels.length} models in API, ${existingFiles.size} existing files\n`,
|
||||
);
|
||||
|
||||
const apiModelIds = new Set<string>();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const apiModel of apiModels) {
|
||||
const relativePath = `${apiModel.id}.toml`;
|
||||
const filePath = path.join(modelsDir, relativePath);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
apiModelIds.add(relativePath);
|
||||
|
||||
const existing = await loadExistingModel(filePath);
|
||||
const merged = mergeModel(apiModel, existing);
|
||||
const tomlContent = formatToml(merged);
|
||||
|
||||
if (existing === null) {
|
||||
created++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would create: ${relativePath}`);
|
||||
console.log(` name = "${merged.name}"`);
|
||||
if (merged.family) {
|
||||
console.log(` family = "${merged.family}"`);
|
||||
}
|
||||
console.log("");
|
||||
} else {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await Bun.write(filePath, tomlContent);
|
||||
console.log(`Created: ${relativePath}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newOnly) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const changes = detectChanges(existing, merged);
|
||||
if (changes.length === 0) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
updated++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would update: ${relativePath}`);
|
||||
} else {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await Bun.write(filePath, tomlContent);
|
||||
console.log(`Updated: ${relativePath}`);
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
console.log(` ${change.field}: ${change.oldValue} → ${change.newValue}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const orphaned = [...existingFiles].filter((file) => !apiModelIds.has(file));
|
||||
for (const file of orphaned) {
|
||||
console.log(`Warning: Orphaned file (not in API): ${file}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
dryRun
|
||||
? `Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} orphaned`
|
||||
: `Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
await main(["wandb", ...process.argv.slice(2)]);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { openrouter } from "./providers/openrouter.js";
|
||||
import { ovhcloud } from "./providers/ovhcloud.js";
|
||||
import { vercel } from "./providers/vercel.js";
|
||||
import { venice } from "./providers/venice.js";
|
||||
import { wandb } from "./providers/wandb.js";
|
||||
import { xai } from "./providers/xai.js";
|
||||
|
||||
const ExistingModelType = AuthoredModelShape.partial()
|
||||
@@ -86,6 +87,7 @@ export const providers: {
|
||||
ovhcloud: SyncProvider<any>;
|
||||
vercel: SyncProvider<any>;
|
||||
venice: SyncProvider<any>;
|
||||
wandb: SyncProvider<any>;
|
||||
xai: SyncProvider<any>;
|
||||
} = {
|
||||
baseten,
|
||||
@@ -96,11 +98,12 @@ export const providers: {
|
||||
ovhcloud,
|
||||
vercel,
|
||||
venice,
|
||||
wandb,
|
||||
xai,
|
||||
};
|
||||
|
||||
export const groups = {
|
||||
aggregators: ["huggingface", "openrouter", "vercel"],
|
||||
aggregators: ["huggingface", "openrouter", "vercel", "wandb"],
|
||||
cloudflare: ["cloudflare-workers-ai"],
|
||||
direct: ["baseten", "google", "ovhcloud", "venice", "xai"],
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { inferKimiFamily, ModelFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
|
||||
const API_ENDPOINT = "https://trace.wandb.ai/inference/modelsdev/models";
|
||||
|
||||
const WandbCost = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number().optional(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
input_audio: z.number().optional(),
|
||||
output_audio: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const WandbLimit = z.object({
|
||||
context: z.number(),
|
||||
input: z.number().optional(),
|
||||
output: z.number(),
|
||||
}).passthrough();
|
||||
|
||||
const WandbModalities = z.object({
|
||||
input: z.array(z.string()),
|
||||
output: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
export const WandbModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
tool_call: z.boolean(),
|
||||
structured_output: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
knowledge: z.string().optional(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
open_weights: z.boolean(),
|
||||
status: z.string().optional(),
|
||||
interleaved: z.union([z.boolean(), z.object({ field: z.string() }).passthrough()]).optional(),
|
||||
cost: WandbCost.optional(),
|
||||
limit: WandbLimit.optional(),
|
||||
modalities: WandbModalities.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const WandbProvider = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
npm: z.string(),
|
||||
env: z.array(z.string()),
|
||||
doc: z.string(),
|
||||
api: z.string().optional(),
|
||||
models: z.record(z.string(), WandbModel),
|
||||
}).passthrough();
|
||||
|
||||
const WandbResponse = z.record(z.string(), WandbProvider);
|
||||
|
||||
export type WandbModel = z.infer<typeof WandbModel>;
|
||||
|
||||
type SupportedModality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
type InterleavedObject = Exclude<SyncedFullModel["interleaved"], true | undefined>;
|
||||
|
||||
const modalityMap: Record<string, SupportedModality | undefined> = {
|
||||
text: "text",
|
||||
image: "image",
|
||||
audio: "audio",
|
||||
video: "video",
|
||||
pdf: "pdf",
|
||||
file: "pdf",
|
||||
files: "pdf",
|
||||
};
|
||||
|
||||
export const wandb = {
|
||||
id: "wandb",
|
||||
name: "Weights & Biases",
|
||||
modelsDir: "providers/wandb/models",
|
||||
deleteMissing: false,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
missingNotice(paths) {
|
||||
if (paths.length === 0) return [];
|
||||
return [
|
||||
`${paths.length} local W&B models were absent from the W&B Inference catalog and were retained for manual lifecycle review.`,
|
||||
`Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`W&B Inference request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return Object.values(WandbResponse.parse(raw)).flatMap((provider) => Object.values(provider.models));
|
||||
},
|
||||
translateModel(model, context) {
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildWandbModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
sameModel(current, desired) {
|
||||
return sameWandbModel(current, desired);
|
||||
},
|
||||
} satisfies SyncProvider<WandbModel>;
|
||||
|
||||
export function buildWandbModel(
|
||||
model: WandbModel,
|
||||
existing: ExistingModel | undefined,
|
||||
today = new Date().toISOString().slice(0, 10),
|
||||
): SyncedFullModel {
|
||||
const inputModalities = normalizeModalities(model.modalities?.input ?? []);
|
||||
const outputModalities = normalizeModalities(model.modalities?.output ?? []);
|
||||
|
||||
return {
|
||||
name: existing?.name ?? normalizeName(model),
|
||||
family: resolveFamily(existing, model),
|
||||
attachment: existing?.attachment ?? model.attachment,
|
||||
reasoning: existing?.reasoning ?? model.reasoning,
|
||||
reasoning_options: existing?.reasoning_options,
|
||||
temperature: existing?.temperature ?? model.temperature ?? true,
|
||||
tool_call: existing?.tool_call ?? model.tool_call,
|
||||
structured_output: existing?.structured_output !== undefined
|
||||
? existing.structured_output
|
||||
: model.structured_output === true
|
||||
? true
|
||||
: undefined,
|
||||
knowledge: existing?.knowledge ?? model.knowledge,
|
||||
release_date: existing?.release_date ?? model.release_date,
|
||||
last_updated: today,
|
||||
open_weights: existing?.open_weights ?? model.open_weights,
|
||||
status: resolveStatus(existing, model.status),
|
||||
interleaved: existing?.interleaved ?? normalizeInterleaved(model.interleaved),
|
||||
cost: buildCost(model.cost, existing?.cost),
|
||||
limit: {
|
||||
context: model.limit?.context ?? existing?.limit?.context ?? 0,
|
||||
output: model.limit?.output ?? existing?.limit?.output ?? 0,
|
||||
input: existing?.limit?.input,
|
||||
},
|
||||
modalities: {
|
||||
input: inputModalities.length > 0
|
||||
? inputModalities
|
||||
: existing?.modalities?.input ?? ["text"],
|
||||
output: outputModalities.length > 0
|
||||
? outputModalities
|
||||
: existing?.modalities?.output ?? ["text"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCost(
|
||||
cost: WandbModel["cost"],
|
||||
existing: ExistingModel["cost"] | undefined,
|
||||
): SyncedFullModel["cost"] | undefined {
|
||||
if (cost !== undefined) {
|
||||
return {
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache_read: cost.cache_read !== undefined && cost.cache_read > 0
|
||||
? cost.cache_read
|
||||
: undefined,
|
||||
cache_write: cost.cache_write !== undefined && cost.cache_write > 0
|
||||
? cost.cache_write
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (existing?.input === undefined || existing.output === undefined) return undefined;
|
||||
return {
|
||||
input: existing.input,
|
||||
output: existing.output,
|
||||
cache_read: existing.cache_read,
|
||||
cache_write: existing.cache_write,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeName(model: WandbModel): string {
|
||||
const stripped = model.name.replace(/^[^:]+:\s*/, "").trim();
|
||||
return stripped || path.basename(model.id);
|
||||
}
|
||||
|
||||
function normalizeModalities(values: string[]): SupportedModality[] {
|
||||
const normalized = values
|
||||
.map((value) => modalityMap[value.toLowerCase()])
|
||||
.filter((value): value is SupportedModality => value !== undefined);
|
||||
return [...new Set(normalized)];
|
||||
}
|
||||
|
||||
function normalizeInterleaved(
|
||||
value: WandbModel["interleaved"],
|
||||
): SyncedFullModel["interleaved"] | undefined {
|
||||
if (value === true) return true;
|
||||
if (value !== undefined && value !== false) {
|
||||
return { field: value.field as InterleavedObject["field"] };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveStatus(
|
||||
existing: ExistingModel | undefined,
|
||||
status: string | undefined,
|
||||
): SyncedFullModel["status"] | undefined {
|
||||
return existing?.status ?? (status as SyncedFullModel["status"] | undefined);
|
||||
}
|
||||
|
||||
function resolveFamily(
|
||||
existing: ExistingModel | undefined,
|
||||
model: WandbModel,
|
||||
): SyncedFullModel["family"] | undefined {
|
||||
if (existing?.family !== undefined) return existing.family;
|
||||
const inferred = inferFamily(model.id, model.name);
|
||||
return isValidFamily(inferred) ? inferred : undefined;
|
||||
}
|
||||
|
||||
function isValidFamily(family: string | undefined): family is ModelFamily {
|
||||
return family !== undefined && ModelFamily.safeParse(family).success;
|
||||
}
|
||||
|
||||
function inferFamily(modelID: string, modelName: string): string | undefined {
|
||||
const kimiFamily = inferKimiFamily(modelID, modelName);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const sortedFamilies = [...ModelFamilyValues].sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const family of sortedFamilies) {
|
||||
if (includesIgnoreCase(modelID, family) || includesIgnoreCase(modelName, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
for (const family of sortedFamilies) {
|
||||
if (isSubsequence(modelID, family) || isSubsequence(modelName, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function includesIgnoreCase(target: string, value: string) {
|
||||
return target.toLowerCase().includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function isSubsequence(target: string, value: string) {
|
||||
const targetLower = target.toLowerCase();
|
||||
const valueLower = value.toLowerCase();
|
||||
let valueIndex = 0;
|
||||
|
||||
for (const character of targetLower) {
|
||||
if (character === valueLower[valueIndex]) valueIndex++;
|
||||
}
|
||||
|
||||
return valueIndex === valueLower.length;
|
||||
}
|
||||
|
||||
function sameWandbModel(current: ExistingModel, desired: SyncedModel) {
|
||||
const desiredModel = desired as SyncedFullModel;
|
||||
const fields: Array<[unknown, unknown, boolean?]> = [
|
||||
[current.name, desiredModel.name],
|
||||
[current.family, desiredModel.family],
|
||||
[current.release_date, desiredModel.release_date],
|
||||
[current.attachment, desiredModel.attachment],
|
||||
[current.reasoning, desiredModel.reasoning],
|
||||
[current.structured_output, desiredModel.structured_output],
|
||||
[current.temperature, desiredModel.temperature],
|
||||
[current.tool_call, desiredModel.tool_call],
|
||||
[current.open_weights, desiredModel.open_weights],
|
||||
[current.cost?.input, desiredModel.cost?.input, true],
|
||||
[current.cost?.output, desiredModel.cost?.output, true],
|
||||
[current.cost?.cache_read, desiredModel.cost?.cache_read, true],
|
||||
[current.cost?.cache_write, desiredModel.cost?.cache_write, true],
|
||||
[current.limit?.context, desiredModel.limit?.context],
|
||||
[current.limit?.output, desiredModel.limit?.output],
|
||||
[current.modalities?.input, desiredModel.modalities?.input],
|
||||
[current.modalities?.output, desiredModel.modalities?.output],
|
||||
];
|
||||
|
||||
return fields.every(([currentValue, desiredValue, cost]) => {
|
||||
if (cost && currentValue === undefined && desiredValue === undefined) return true;
|
||||
if (cost && (currentValue === undefined || desiredValue === undefined)) return false;
|
||||
if (cost && typeof currentValue === "number" && typeof desiredValue === "number") {
|
||||
return Math.abs(currentValue - desiredValue) <= 0.001;
|
||||
}
|
||||
return JSON.stringify(currentValue) === JSON.stringify(desiredValue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user