#!/usr/bin/env bun import { mkdir } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { inferKimiFamily } from "../src/family.js"; // Friendli API endpoint const API_ENDPOINT = "https://api.friendli.ai/serverless/v1/models"; // Zod schemas for API response validation const Functionality = z.object({ tool_call: z.boolean(), parallel_tool_call: z.boolean(), structured_output: z.boolean(), }); const Pricing = z.object({ input: z.number(), output: z.number(), response_time: z.number(), unit_type: z.enum(["TOKEN", "SECOND"]), }); const FriendliModel = z .object({ id: z.string(), name: z.string(), max_completion_tokens: z.number(), context_length: z.number(), functionality: Functionality, pricing: Pricing, hugging_face_url: z.string().optional(), description: z.string().optional(), license: z.string().optional(), policy: z.string().optional().nullable(), created: z.number(), // Unix timestamp }) .passthrough(); const FriendliResponse = z.object({ data: z.array(FriendliModel), }); // Family inference patterns const familyPatterns: [RegExp, string][] = [ [/qwen3/i, "qwen3"], [/deepseek-r1/i, "deepseek-r1"], [/glm-4/i, "glm-4"], [/glm-5/i, "glm"], ]; function inferFamily(modelId: string, modelName: string): string | undefined { const kimiFamily = inferKimiFamily(modelId, modelName); if (kimiFamily !== undefined) return kimiFamily; for (const [pattern, family] of familyPatterns) { if (pattern.test(modelId) || pattern.test(modelName)) { return family; } } return undefined; } function extractModelName(fullName: string): string { // "meta-llama/Llama-3.3-70B-Instruct" -> "Llama 3.3 70B Instruct" const parts = fullName.split("/"); const modelName = parts.at(-1) ?? fullName; return modelName .replace(/-/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()); } // TODO: Replace with functionality.parse_reasoning from API when available function isReasoningModel(modelId: string): boolean { const nonReasoningPatterns = [ /qwen3.*instruct/i, ]; for (const pattern of nonReasoningPatterns) { if (pattern.test(modelId)) { return false; } } // Everything else is reasoning or hybrid reasoning return true; } function formatNumber(n: number): string { if (n >= 1000) { // Format with underscores for readability (e.g., 131_072) return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_"); } return n.toString(); } function timestampToDate(timestamp: number): string { const date = new Date(timestamp * 1000); return date.toISOString().slice(0, 10); } function getTodayDate(): string { return new Date().toISOString().slice(0, 10); } interface ExistingModel { 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; reasoning?: number; cache_read?: number; cache_write?: number; }; limit?: { context?: number; input?: number; output?: number; }; modalities?: { input?: string[]; output?: string[]; }; provider?: { npm?: string; api?: string; }; } async function loadExistingModel( filePath: string, ): Promise { 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 (e) { console.warn(`Warning: Failed to parse existing file ${filePath}:`, e); return null; } } interface MergedModel { 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; }; limit: { context: number; output: number; }; modalities: { input: string[]; output: string[]; }; } function mergeModel( apiModel: z.infer, existing: ExistingModel | null, ): MergedModel { const contextTokens = apiModel.context_length; const outputTokens = apiModel.max_completion_tokens; const openWeights = Boolean(apiModel.hugging_face_url); const merged: MergedModel = { // Always from API name: extractModelName(apiModel.name), attachment: false, // All Friendli models are text-only currently reasoning: isReasoningModel(apiModel.id), tool_call: apiModel.functionality.tool_call, temperature: true, release_date: timestampToDate(apiModel.created), last_updated: getTodayDate(), open_weights: openWeights, limit: { context: contextTokens, output: outputTokens, }, modalities: { input: ["text"], output: ["text"], }, }; // structured_output only if true if (apiModel.functionality.structured_output === true) { merged.structured_output = true; } // Cost from API - ONLY include if unit_type is TOKEN if (apiModel.pricing.unit_type === "TOKEN") { merged.cost = { input: apiModel.pricing.input, output: apiModel.pricing.output, }; } else { console.log( ` Note: ${apiModel.id} uses ${apiModel.pricing.unit_type} pricing - cost section omitted`, ); } // Preserve from existing OR infer if (existing?.family) { merged.family = existing.family; } else { const inferred = inferFamily(apiModel.id, apiModel.name); if (inferred) { merged.family = inferred; } } // Preserve manual fields from existing if (existing?.knowledge) { merged.knowledge = existing.knowledge; } if (existing?.interleaved !== undefined) { merged.interleaved = existing.interleaved; } if (existing?.status !== undefined) { merged.status = existing.status; } return merged; } function formatToml(model: MergedModel): string { const lines: string[] = []; // Basic fields lines.push(`name = "${model.name.replace(/"/g, '\\"')}"`); if (model.family) { lines.push(`family = "${model.family}"`); } lines.push(`attachment = ${model.attachment}`); lines.push(`reasoning = ${model.reasoning}`); lines.push(`tool_call = ${model.tool_call}`); if (model.structured_output !== undefined) { lines.push(`structured_output = ${model.structured_output}`); } lines.push(`temperature = ${model.temperature}`); if (model.knowledge) { lines.push(`knowledge = "${model.knowledge}"`); } lines.push(`release_date = "${model.release_date}"`); lines.push(`last_updated = "${model.last_updated}"`); lines.push(`open_weights = ${model.open_weights}`); if (model.status) { lines.push(`status = "${model.status}"`); } // Interleaved section (if present) if (model.interleaved !== undefined) { lines.push(""); if (model.interleaved === true) { lines.push(`interleaved = true`); } else if (typeof model.interleaved === "object") { lines.push(`[interleaved]`); lines.push(`field = "${model.interleaved.field}"`); } } // Cost section (only if present) if (model.cost) { lines.push(""); lines.push(`[cost]`); lines.push(`input = ${model.cost.input}`); lines.push(`output = ${model.cost.output}`); } // Limit section lines.push(""); lines.push(`[limit]`); lines.push(`context = ${formatNumber(model.limit.context)}`); lines.push(`output = ${formatNumber(model.limit.output)}`); // Modalities section 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"; } interface Changes { field: string; oldValue: string; newValue: string; } function detectChanges( existing: ExistingModel | null, merged: MergedModel, ): Changes[] { if (!existing) return []; const changes: Changes[] = []; const compare = (field: string, oldVal: unknown, newVal: unknown) => { const oldStr = JSON.stringify(oldVal); const newStr = JSON.stringify(newVal); if (oldStr !== newStr) { changes.push({ field, oldValue: formatValue(oldVal), newValue: formatValue(newVal), }); } }; const formatValue = (val: unknown): string => { if (typeof val === "number") return formatNumber(val); if (Array.isArray(val)) return `[${val.join(", ")}]`; if (val === undefined) return "(none)"; return String(val); }; compare("name", existing.name, merged.name); compare("family", existing.family, merged.family); compare("attachment", existing.attachment, merged.attachment); compare("reasoning", existing.reasoning, merged.reasoning); compare("tool_call", existing.tool_call, merged.tool_call); compare( "structured_output", existing.structured_output, merged.structured_output, ); compare("open_weights", existing.open_weights, merged.open_weights); compare("release_date", existing.release_date, merged.release_date); compare("cost.input", existing.cost?.input, merged.cost?.input); compare("cost.output", existing.cost?.output, merged.cost?.output); 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); return changes; } async function main() { const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const modelsDir = path.join( import.meta.dirname, "..", "..", "..", "providers", "friendli", "models", ); if (dryRun) { console.log(`[DRY RUN] Fetching Friendli models from API...`); } else { console.log(`Fetching Friendli models from API...`); } // Fetch API data 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 = FriendliResponse.safeParse(json); if (!parsed.success) { console.error("Invalid API response:", parsed.error.errors); process.exit(1); } const apiModels = parsed.data.data; // Get existing files (recursively) const existingFiles = new Set(); try { for await (const file of new Bun.Glob("**/*.toml").scan({ cwd: modelsDir, absolute: false, })) { existingFiles.add(file); } } catch { // Directory might not exist yet } console.log( `Found ${apiModels.length} models in API, ${existingFiles.size} existing files\n`, ); // Track API model IDs for orphan detection const apiModelIds = new Set(); 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}" (inferred)`); } console.log(""); } else { await mkdir(dirPath, { recursive: true }); await Bun.write(filePath, tomlContent); console.log(`Created: ${relativePath}`); } } else { const changes = detectChanges(existing, merged); if (changes.length > 0) { updated++; if (dryRun) { console.log(`[DRY RUN] Would update: ${relativePath}`); } else { await Bun.write(filePath, tomlContent); console.log(`Updated: ${relativePath}`); } for (const change of changes) { console.log(` ${change.field}: ${change.oldValue} → ${change.newValue}`); } console.log(""); } else { unchanged++; } } } // Check for orphaned files const orphaned: string[] = []; for (const file of existingFiles) { if (!apiModelIds.has(file)) { orphaned.push(file); console.log(`Warning: Orphaned file (not in API): ${file}`); } } // Summary console.log(""); if (dryRun) { console.log( `Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} orphaned`, ); } else { console.log( `Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned`, ); } } await main();