489 lines
18 KiB
JavaScript
489 lines
18 KiB
JavaScript
// 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 { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, 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 CONSOLE_URL = "https://commandcode.ai/studio/provider";
|
|
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) {
|
|
try {
|
|
const text = readFileSync(path, "utf8");
|
|
return { text, data: JSON.parse(text), exists: true };
|
|
} catch (error) {
|
|
// A fresh install has no opencode.json yet; treat it as empty so the console
|
|
// guidance / env-key path can still run. Bad JSON or unreadable files still throw.
|
|
if (error.code === "ENOENT") return { text: "", data: {}, exists: false };
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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() {
|
|
let config;
|
|
let text;
|
|
let exists;
|
|
try {
|
|
({ text, data: config, exists } = readConfig(CONFIG_PATH));
|
|
} catch (error) {
|
|
console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Mandatory first step: without a CommandCode key there is nothing to fetch or
|
|
// probe, so point a fresh user at the Console before doing anything else.
|
|
const key = resolveKey(config, process.env);
|
|
if (!key) {
|
|
const where = exists
|
|
? `No CommandCode API key in ${CONFIG_PATH}.`
|
|
: `No opencode.json found at ${CONFIG_PATH}.`;
|
|
console.error(
|
|
`${where}\n` +
|
|
`Generate an API key in the CommandCode Console: ${CONSOLE_URL}\n` +
|
|
(exists
|
|
? `Add it as provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`
|
|
: `Create that file with provider.commandcode.options.apiKey, or export COMMANDCODE_API_KEY.`) +
|
|
`\nNothing was written.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
console.error("Interactive picker needs a TTY; nothing was written.");
|
|
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 === "" || text.endsWith("\n") ? "\n" : "";
|
|
// The config directory may not exist yet on a fresh install; create it before the atomic write.
|
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
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);
|
|
});
|