/** * Sync the pi model catalog into Bloby. * * Reads upstream pi's `packages/ai/src/models.generated.ts` (vendored as a * sibling checkout at ../pi-main) and emits a filtered, alphabetised TS file * the wizard imports. Run on demand when you want to pull in newer model IDs. * * npm run sync:pi-models * * The OUTPUT (`supervisor/harnesses/pi/models-catalog.generated.ts`) is the * file that ships with bloby. The pi-main checkout is dev-only and is not * required at runtime. */ import fs from 'fs'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); // Default sibling checkout; override with PI_MAIN_DIR=/path/to/pi-main when it lives elsewhere. const PI_MAIN_DIR = process.env.PI_MAIN_DIR || path.join(REPO_ROOT, 'pi-main'); const PI_MODELS_PATH = path.join(PI_MAIN_DIR, 'packages', 'ai', 'src', 'models.generated.ts'); const OUTPUT_PATH = path.join(REPO_ROOT, 'supervisor', 'harnesses', 'pi', 'models-catalog.generated.ts'); if (!fs.existsSync(PI_MODELS_PATH)) { console.error(`✗ pi catalog not found at ${PI_MODELS_PATH}`); console.error(' Clone or download earendil-works/pi into ../pi-main first.'); process.exit(1); } // Bloby sub-provider id → pi MODELS top-level key. // Sub-providers without a pi mapping (ollama, lm-studio, custom) stay dynamic. const PROVIDER_MAP: Record = { google: 'google', deepseek: 'deepseek', groq: 'groq', xai: 'xai', cerebras: 'cerebras', mistral: 'mistral', 'openai-api': 'openai', 'anthropic-api': 'anthropic', // openrouter intentionally skipped — 270+ entries is wizard-hostile. Stays dynamic. }; // Drop noisy variants: date-suffixed previews, custom-tool forks, live-audio, // model-snapshot aliases. We keep the canonical id (e.g. "gemini-3.1-pro-preview") // and the rolling "*-latest" handles, which is what users actually want to pick. const HIDDEN_PATTERNS: RegExp[] = [ /-\d{2}-\d{2}$/, // ...-04-17 /-\d{2}-\d{4}$/, // ...-09-2025 /-\d{4}-\d{2}-\d{2}$/, // ...-2025-08-07 /-\d{8}$/, // ...-20250805 /-customtools$/, /-live-/, /-search-preview/, /-realtime/, /-audio/, /-tts$/, /-transcribe$/, /^text-embedding-/, /^omni-moderation/, /^dall-e/, /^whisper/, /^gpt-3\.5/, // legacy /^gpt-4-/, // legacy variants of plain gpt-4 /^o1-/, // dated o1 variants /^gemma-/, // separate open-weight family — better served via Ollama ]; function isHidden(id: string): boolean { return HIDDEN_PATTERNS.some((re) => re.test(id)); } async function loadPiModels(): Promise>> { // pi's file has `import type { Model } from "./types.js"` — strip that line // so the module loads without needing pi's full types graph at sync time. const raw = fs.readFileSync(PI_MODELS_PATH, 'utf-8'); const sanitised = raw .replace(/^import type[^;]+;\s*$/m, '') .replace(/ satisfies Model<[^>]+>/g, ''); // Drop into a temp file next to the original so any relative paths in errors // still make sense, then dynamic-import via file:// URL. const tmpPath = `${PI_MODELS_PATH}.bloby-sync.tmp.ts`; fs.writeFileSync(tmpPath, sanitised); try { const mod = await import(pathToFileURL(tmpPath).href); return (mod as any).MODELS; } finally { fs.rmSync(tmpPath, { force: true }); } } function versionScore(id: string): number { // Cheap "newer first" ordering: parse the first major.minor pair we find. const m = id.match(/(\d+)(?:\.(\d+))?/); if (!m) return 0; const major = parseInt(m[1], 10); const minor = m[2] ? parseInt(m[2], 10) : 0; return major * 1000 + minor; } async function main() { const MODELS = await loadPiModels(); interface OutModel { id: string; label: string; contextWindow?: number; maxOutputTokens?: number; input?: string[]; } const out: Record = {}; let total = 0; for (const [blobyId, piKey] of Object.entries(PROVIDER_MAP)) { const provider = MODELS[piKey]; if (!provider) { console.warn(`! no pi provider "${piKey}" (mapped from bloby "${blobyId}")`); continue; } const entries: OutModel[] = []; for (const [id, m] of Object.entries(provider)) { if (isHidden(id)) continue; const entry: OutModel = { id, label: m?.name || id }; // Capacity + modality metadata used by the harness: contextWindow feeds the // supervisor's session recycler, maxOutputTokens caps tool-call/output size, // input gates image attachments on non-vision models. if (typeof m?.contextWindow === 'number' && m.contextWindow > 0) entry.contextWindow = m.contextWindow; if (typeof m?.maxTokens === 'number' && m.maxTokens > 0) entry.maxOutputTokens = m.maxTokens; if (Array.isArray(m?.input) && m.input.length > 0) entry.input = m.input; entries.push(entry); } // Newest version first; alphabetical inside the same version. entries.sort((a, b) => { const dv = versionScore(b.id) - versionScore(a.id); return dv !== 0 ? dv : a.id.localeCompare(b.id); }); out[blobyId] = entries; total += entries.length; } const banner = `// Auto-generated by scripts/sync-pi-models.ts — DO NOT EDIT MANUALLY.\n` + `// Source: earendil-works/pi @ packages/ai/src/models.generated.ts\n` + `// Last sync: ${new Date().toISOString()}\n` + `\n`; const body = `export interface PiCatalogModel {\n` + ` id: string;\n` + ` label: string;\n` + ` /** Model context window in tokens — feeds the supervisor's proactive session recycling. */\n` + ` contextWindow?: number;\n` + ` /** Max output tokens per request — caps tool-call/output size per provider round. */\n` + ` maxOutputTokens?: number;\n` + ` /** Input modalities (e.g. ["text","image"]) — gates image attachments on non-vision models. */\n` + ` input?: string[];\n` + `}\n` + `export const PI_MODELS_CATALOG: Record = ${JSON.stringify(out, null, 2)};\n`; fs.writeFileSync(OUTPUT_PATH, banner + body); console.log(`✓ wrote ${OUTPUT_PATH}`); console.log(` ${Object.keys(out).length} providers, ${total} models`); for (const [k, v] of Object.entries(out)) { console.log(` · ${k}: ${v.length}`); } } main().catch((err) => { console.error('✗ sync failed:', err); process.exit(1); });