// Interactive model picker for opencode `provider.commandcode.models`. // // opencode builds `/models` from the configured model map; a custom `file://` provider is only // ever asked for `languageModel(id)`, never queried for discovery. So the catalog has to be baked // into opencode.json. This script fetches the live catalog, lets the user pick which models to // enable with a builtins-only checkbox UI, probes the selection for vision support, and writes // the result into the global opencode config. Deselecting a model removes it from the config. // // Run: node scripts/sync-models.mjs (build first: npm run build) import { readFileSync, writeFileSync, renameSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import readline from "node:readline"; import { createCommandCode } from "../dist/index.js"; import { redact } from "../dist/redact.js"; import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js"; const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json"); const CONCURRENCY = 6; // 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response. const PROBE_IMAGE = { type: "file", mediaType: "image/png", data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeUlEQVR4nO3PQQkAMAzAwCqpf1ETMxF7HINABFzm7H7dcEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFj13PLIEAOXyUUwAAAABJRU5ErkJggg==", }; const VISION_ERROR = /image|vision|multimodal|modality|unsupported|not support|invalid.*content|does not support/i; // Text-only models answer the image prompt by admitting they cannot see an image. Plan-gated or // unavailable models error before that, so only a clean reply is treated as a vision signal. const NO_IMAGE = /(?:don'?t|do not|can'?t|cannot|unable to)\s+(?:see|view|detect|find|access|receive|open|process)|no\s+(?:image|picture|attachment|photo)|not\s+(?:see|receive|detect|attach)|image\s+(?:was\s+)?not\s+(?:attach|provid|receiv|includ)/i; // Fallback when a probe is inconclusive. Ground truth is unavailable from the catalog, // so this is only a best guess for ids that look like known vision families. const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i; function readConfig(path) { const text = readFileSync(path, "utf8"); return { text, data: JSON.parse(text) }; } function detectIndent(text) { const match = /\n([ \t]+)\S/.exec(text); if (!match) return 2; const indent = match[1]; if (indent.startsWith("\t")) return "\t"; return indent.length; } function resolveKey(config, env) { const fromEnv = env.COMMANDCODE_API_KEY; if (fromEnv) return fromEnv.trim(); const options = config?.provider?.commandcode?.options ?? {}; const fromConfig = options.apiKey; if (typeof fromConfig === "string" && fromConfig.trim()) return fromConfig.trim(); const auth = options.headers?.Authorization ?? options.headers?.authorization; if (typeof auth === "string" && auth.trim()) return auth.replace(/^Bearer\s+/i, "").trim(); return undefined; } async function fetchCatalog(baseURL, apiKey, ccVersion) { const response = await fetch(`${baseURL}${MODELS_PATH}`, { headers: { Authorization: /^Bearer\s/i.test(apiKey) ? apiKey : `Bearer ${apiKey}`, "x-command-code-version": ccVersion, }, }); const body = await response.text(); if (!response.ok) { throw new Error(`catalog ${response.status}: ${redact(body.slice(0, 500)) || response.statusText}`); } const parsed = JSON.parse(body); const data = Array.isArray(parsed?.data) ? parsed.data : []; return data.filter((m) => m && typeof m.id === "string" && m.id.length > 0); } async function probeVision(provider, id) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error("probe timeout")), 30000); try { const result = await provider.languageModel(id).doGenerate({ prompt: [ { role: "user", content: [ { type: "text", text: "Look at the attached image and reply with exactly one word: its dominant color. " + "If no image is attached, or you cannot see images, reply with exactly: NO_IMAGE", }, PROBE_IMAGE, ], }, ], maxOutputTokens: 256, temperature: 0, abortSignal: controller.signal, }); const text = (result.content ?? []) .map((part) => (part.type === "text" ? part.text : "")) .join("") .trim(); if (!text) return { vision: undefined, reason: `empty (${result.finishReason?.unified ?? "?"})` }; if (/NO[_\s-]?IMAGE/i.test(text) || NO_IMAGE.test(text)) return { vision: false, reason: "reported no image" }; return { vision: true, reason: `answered: ${text.slice(0, 24)}` }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (VISION_ERROR.test(message)) return { vision: false, reason: "rejected image" }; return { vision: undefined, reason: redact(message).slice(0, 120) || "error" }; } finally { clearTimeout(timer); } } // Some models intermittently deny seeing an image on the first try. Retry an explicit "no image" // once so a flaky denial does not mislabel a vision model; inconclusive results skip the retry. async function probeVisionConfirmed(provider, id) { let outcome = await probeVision(provider, id); if (outcome.vision === false) outcome = await probeVision(provider, id); return outcome; } async function mapPool(items, concurrency, worker) { const results = new Array(items.length); let cursor = 0; const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (true) { const index = cursor++; if (index >= items.length) return; results[index] = await worker(items[index], index); } }); await Promise.all(runners); return results; } function modelEntry(catalogModel, vision) { const id = catalogModel.id; const context = Number(catalogModel.context_length) || 0; const entry = { name: typeof catalogModel.name === "string" && catalogModel.name ? catalogModel.name : id, limit: { context, output: DEFAULT_MAX_TOKENS }, reasoning: true, attachment: vision, modalities: { input: vision ? ["text", "image"] : ["text"], output: ["text"], }, }; return entry; } function writeAtomic(path, contents) { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, contents); renameSync(tmp, path); } // The catalog carries no pricing fields (id/name/context_length/supported_endpoints only), // so the picker shows context with any opportunistically present pricing keys omitted. function formatContext(value) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return "unknown"; if (n >= 1_000_000 && n % 1_000_000 === 0) return `${n / 1_000_000}M (${n})`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M (${n})`; if (n >= 1000 && n % 1000 === 0) return `${n / 1000}k (${n})`; if (n >= 1000) return `${(n / 1000).toFixed(0)}k (${n})`; return `${n}`; } // Builtins-only checkbox picker (no dependencies, so the package stays runtime-dependency-free). // Nothing is pre-checked; deselected ids are dropped by the caller. Resolves to the selected // catalog indices, or null when the user aborts (Esc on empty filter / Ctrl-C). function pickModels(catalog, existingIds) { return new Promise((resolve) => { const checked = new Set(); let filter = ""; let cursor = 0; let scroll = 0; let done = false; const columns = () => process.stdout.columns ?? 80; // Two terminal lines per model (header + context detail); keep at least one model visible. const pageSize = () => Math.max(1, Math.floor(Math.max(5, (process.stdout.rows ?? 24) - 7) / 2)); const filtered = () => { if (!filter) return catalog.map((_, index) => index); const needle = filter.toLowerCase(); const out = []; catalog.forEach((model, index) => { if (`${model.id} ${model.name ?? ""}`.toLowerCase().includes(needle)) out.push(index); }); return out; }; const clamp = (list) => { if (list.length === 0) { cursor = 0; scroll = 0; return; } cursor = Math.min(Math.max(0, cursor), list.length - 1); const size = pageSize(); if (cursor < scroll) scroll = cursor; if (cursor >= scroll + size) scroll = cursor - size + 1; }; const line = (text, code) => { // Slice visible text first so ANSI codes never count toward the terminal // width or get cut mid-sequence. NO_COLOR and dumb terminals get plain text. const visible = String(text).slice(0, columns()); if (code === undefined || process.env.NO_COLOR || process.env.TERM === "dumb") { process.stdout.write(`${visible}\n`); return; } process.stdout.write(`\x1b[${code}m${visible}\x1b[0m\n`); }; const render = () => { const list = filtered(); clamp(list); const size = pageSize(); const view = list.slice(scroll, scroll + size); process.stdout.write("\x1b[?25l\x1b[2J\x1b[H"); line("Select CommandCode models (type to filter · space toggle · enter confirm)"); line(`Catalog: ${catalog.length} models · Selected: ${checked.size} · Filter: ${filter}\u2588`); line(""); if (list.length === 0) { line(" (no matches)"); } view.forEach((original, i) => { const model = catalog[original]; const active = scroll + i === cursor; const selected = checked.has(original); const mark = selected ? "x" : " "; const current = existingIds.has(model.id) ? " · current" : ""; // Selected rows render green (both lines); the cursor row renders bold on top. const code = selected ? (active ? "1;32" : "32") : active ? "1" : undefined; line(`${active ? "\u203a" : " "} [${mark}] ${model.id} \u2014 ${model.name ?? model.id}${current}`, code); line(` ctx ${formatContext(model.context_length)}`, code); }); line(""); line("\u2191/\u2193 navigate · space toggle · ctrl-a all · ctrl-u none · esc back/abort · enter confirm"); }; const cleanup = () => { if (done) return; done = true; process.stdout.removeListener("resize", render); process.stdin.removeListener("keypress", onKey); try { process.stdin.setRawMode(false); } catch { // Non-TTY teardown; terminal state is already sane. } process.stdin.pause(); process.stdout.write("\x1b[?25h"); }; const finish = (value) => { cleanup(); resolve(value); }; const toggleAll = (list, value) => { for (const index of list) { if (value) checked.add(index); else checked.delete(index); } }; function onKey(str, key) { if (done) return; const list = filtered(); if (key.ctrl && (key.name === "c" || key.name === "d")) { finish(null); return; } if (key.ctrl && key.name === "a") { toggleAll(list, true); render(); return; } if (key.ctrl && key.name === "u") { checked.clear(); render(); return; } switch (key.name) { case "return": finish([...checked].sort((a, b) => a - b)); return; case "escape": // First Esc clears the filter; a second Esc on an empty filter aborts. if (filter) { filter = ""; cursor = 0; scroll = 0; render(); return; } finish(null); return; case "backspace": if (filter) { filter = filter.slice(0, -1); cursor = 0; scroll = 0; render(); } return; case "up": cursor = list.length === 0 ? 0 : (cursor - 1 + list.length) % list.length; render(); return; case "down": cursor = list.length === 0 ? 0 : (cursor + 1) % list.length; render(); return; case "space": { const current = list[cursor]; if (current !== undefined) { if (checked.has(current)) checked.delete(current); else checked.add(current); } render(); return; } default: break; } // All printable characters feed the filter — letter shortcuts would swallow // filter input (e.g. typing "qwen" would abort on the "q"). Bulk actions live // on Ctrl combos above; abort is Esc (empty filter) or Ctrl-C. if (!key.ctrl && !key.meta && typeof str === "string" && str.length === 1) { const printable = str >= " " && str <= "~"; if (printable && str !== " ") { filter += str; cursor = 0; scroll = 0; render(); } } } readline.emitKeypressEvents(process.stdin); try { process.stdin.setRawMode(true); } catch { resolve(null); return; } process.stdin.resume(); process.stdout.on("resize", render); process.stdin.on("keypress", onKey); render(); }); } async function main() { if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error("Interactive picker needs a TTY; nothing was written."); process.exit(1); } let config; let text; try { ({ text, data: config } = readConfig(CONFIG_PATH)); } catch (error) { console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } const key = resolveKey(config, process.env); if (!key) { console.error("No API key: set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey"); process.exit(1); } const baseURL = (config?.provider?.commandcode?.options?.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); const ccVersion = config?.provider?.commandcode?.options?.ccVersion ?? DEFAULT_CC_VERSION; console.log(`Fetching catalog: ${baseURL}${MODELS_PATH}`); let catalog; try { catalog = await fetchCatalog(baseURL, key, ccVersion); } catch (error) { console.error(`Failed to fetch catalog: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } if (catalog.length === 0) { console.error("Catalog is empty; config unchanged."); process.exit(1); } console.log(`Catalog: ${catalog.length} models`); const existing = config?.provider?.commandcode?.models ?? {}; const existingIds = new Set(Object.keys(existing)); const picked = await pickModels(catalog, existingIds); if (picked === null) { console.log("Aborted \u2014 config unchanged."); return; } if (picked.length === 0) { console.log("No models selected \u2014 config unchanged."); return; } const selected = picked.map((index) => catalog[index]).filter(Boolean); console.log(`Probing vision (${selected.length} models, concurrency ${CONCURRENCY})...`); const provider = createCommandCode({ name: "commandcode", apiKey: key, baseURL, ccVersion, maxRetries: 1 }); const probeResults = await mapPool(selected, CONCURRENCY, async (model, index) => { const outcome = await probeVisionConfirmed(provider, model.id); const tag = outcome.vision === true ? "vision" : outcome.vision === false ? "no-vision" : "unknown"; console.log(` [${index + 1}/${selected.length}] ${model.id}: ${tag} (${outcome.reason})`); return outcome; }); // Deselect = remove: the final map holds exactly the picked ids. Extra user fields on // picked ids are preserved; unpicked ids are dropped. const merged = {}; const stats = { probed: 0, vision: 0, noVision: 0, unknown: 0, heuristic: 0 }; selected.forEach((model, index) => { const outcome = probeResults[index]; let vision; if (outcome && outcome.vision !== undefined) { vision = outcome.vision; stats.probed++; if (vision) stats.vision++; else stats.noVision++; } else { vision = VISION_HEURISTIC.test(`${model.id} ${model.name ?? ""}`); stats.unknown++; if (vision) stats.heuristic++; } merged[model.id] = { ...(existing[model.id] ?? {}), ...modelEntry(model, vision) }; }); const removed = [...existingIds].filter((id) => !(id in merged)).length; config.provider ??= {}; config.provider.commandcode ??= {}; config.provider.commandcode.models = merged; const indent = detectIndent(text); const trailing = text.endsWith("\n") ? "\n" : ""; writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`); console.log( `Wrote ${Object.keys(merged).length} models to ${CONFIG_PATH}\n` + ` selected=${selected.length} removed=${removed}\n` + ` probed=${stats.probed} vision=${stats.vision} no-vision=${stats.noVision} ` + `unknown=${stats.unknown} heuristic-vision=${stats.heuristic}\n` + `Restart opencode, then run: opencode models commandcode`, ); } main().catch((error) => { console.error(error instanceof Error ? error.stack : String(error)); process.exit(1); });