diff --git a/README.md b/README.md index 0b073a2..8472fb0 100644 --- a/README.md +++ b/README.md @@ -65,21 +65,25 @@ opencode plugin file://$PWD ## Authenticate -Requires the plugin to be installed (see above). Uses the **Command Code Go plan ($1/mo)** — same browser login as `cmd login`. No Studio API key. +Requires the plugin to be installed (see above). Preferred: **Command Code Go plan ($1/mo)** browser login (same as `cmd login`). You can also paste an API key / session token. ```bash # Option A — sync from Command Code CLI (recommended) npm i -g command-code@latest -cmd login # browser → Studio CLI auth (Go plan). Laguna S 2.1 is $0 credits. +cmd login # browser → Studio CLI auth (Go plan) opencode auth login --provider command-code # pick "Use existing cmd login session" # Option B — browser OAuth (Go $1) opencode auth login --provider command-code # pick "Login with Command Code (Go $1)" + +# Option C — paste API key / session token +opencode auth login --provider command-code +# pick "Enter Command Code API key" and paste into the prompt field ``` -Then start OpenCode, pick provider **command-code**, and choose Laguna S 2.1 free: +Then start OpenCode, pick provider **command-code**, and choose any model from the live catalog (`cmd --list-models`). Laguna S 2.1 free is the default when available: ```bash opencode run "Summarise this repository in five bullets." --model command-code/laguna-s-2.1-free @@ -92,8 +96,9 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file | | | |---|---| | **Gateway proxy** | Talks to `api.commandcode.ai/alpha/generate` — same transport as the `cmd` CLI (`mode=agent`). | -| **Laguna S 2.1 free** | 256k context, reasoning, $0 while the deal lasts. Default aliases resolve here. | -| **Go-plan auth** | Browser OAuth like `cmd login` ($1/mo). Syncs `~/.commandcode/auth.json`. No Studio API key. | +| **Live model catalog** | Discovered from `cmd --list-models` (enriched by the installed CLI’s models.md) — not hardcoded. | +| **Laguna S 2.1 free** | Default when present — 256k context, $0 while the deal lasts. | +| **Auth options** | Go-plan browser OAuth, sync from `cmd login`, or paste an API key / session token. | | **Agent-grade tools** | OpenCode tool calls park and resume; MCP-prefixed tools are tracked separately. | | **Attachments** | Images, PDFs, text/binary files from OpenCode. Text-only models get image placeholders (Command Code behavior). | | **Auto-compact** | Context-fraction tips + tiered client compact before the 256k window overflows. | @@ -109,9 +114,9 @@ OpenCode └─ poolside/laguna-s-2.1-free (default) ``` -Model catalog: aliases `laguna-s-2.1-free` / `laguna` / `default` (and siblings) map to -`poolside/laguna-s-2.1-free`. Auth credentials are mirrored between OpenCode `auth.json` -and `~/.commandcode/auth.json`. +Model catalog: live from `cmd --list-models`. Aliases like `laguna` / `laguna-s-2.1-free` +resolve to `poolside/laguna-s-2.1-free` when that model is present. Auth credentials are +mirrored between OpenCode `auth.json` and `~/.commandcode/auth.json`. ## Requirements @@ -157,10 +162,10 @@ Local pin refresh after a release: ## FAQ **Do I need a Studio API key?** -No. Use Go-plan browser login (or sync from `cmd login`). +No. Prefer Go-plan browser login or sync from `cmd login`. You can also use **Enter Command Code API key** if you already have a session token. -**Is Laguna free?** -Yes — `poolside/laguna-s-2.1-free` is the default free model ($0 while the deal lasts). +**Are only Laguna models available?** +No. The plugin loads the full live catalog from `cmd --list-models`. Laguna is just the default free model used for testing. **Why isn’t Command Code listed without the plugin?** OpenCode doesn’t ship Command Code as a built-in provider; this plugin registers it. diff --git a/src/index.ts b/src/index.ts index 69bd15d..8211dab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ /** * OpenCode Command Code Auth Plugin * - * Enables Command Code (Laguna S 2.1 free and gateway models) inside OpenCode via: - * 1. Command Code Go-plan browser OAuth (`cmd login`) — no API key + * Enables Command Code models inside OpenCode via: + * 1. Go-plan browser OAuth, CLI session sync, or pasted API key * 2. Local OpenAI-compatible proxy → api.commandcode.ai /alpha/generate - * 3. Tools/MCP park-resume, attachments, compact, and usage accounting + * 3. Dynamic model catalog from `cmd --list-models` + * 4. Tools/MCP park-resume, attachments, compact, and usage accounting * * Register in opencode.json: * { "plugin": ["@otto-assistant/opencode-commandcode"] } @@ -16,6 +17,8 @@ import { resetPendingCommandLogin, startCommandBrowserLogin, syncCommandCodeCredentialsToOpenCode, + validateCommandApiKey, + writeCommandCodeAuth, } from "./auth-login.js"; import { DEFAULT_MODEL_ID, @@ -33,6 +36,7 @@ import { buildConfigVariants, buildEffortVariants, getCommandModels, + refreshCommandModels, type CommandModel, } from "./models.js"; import { @@ -265,7 +269,7 @@ async function loadRuntime( provider?: { models?: Record }, ): Promise<{ port: number; providerModels: Record } | undefined> { await resolveAccessToken(input, getAuth); - const models = getCommandModels(); + const models = refreshCommandModels(); await startProxy(async () => resolveAccessToken(input, getAuth)); const providerModels = buildProviderModels(models); if (provider) provider.models = providerModels; @@ -310,7 +314,8 @@ export const CommandCodePlugin: Plugin = async ( return synced?.access ?? null; }); - // Always seed Laguna catalog so the provider is discoverable pre-login. + // Always seed the live catalog so the provider is discoverable. + refreshCommandModels(); ensureProviderConfig(config as Record, getCommandModels()); }, @@ -413,7 +418,7 @@ export const CommandCodePlugin: Plugin = async ( return { url: current.url, instructions: - "Opens Command Code Studio CLI login (Go plan, $1/mo). Approve in the browser — session is posted back automatically. Laguna S 2.1 free costs $0 credits. No API key.", + "Opens Command Code Studio CLI login (Go plan, $1/mo). Approve in the browser — session is posted back automatically. Live models load from `cmd --list-models` after login.", method: "auto" as const, async callback() { try { @@ -443,7 +448,7 @@ export const CommandCodePlugin: Plugin = async ( return { url: "https://commandcode.ai/docs/quickstart", instructions: - "If you already ran `npm i -g command-code@latest && cmd login` (Go $1 plan), click Complete to sync ~/.commandcode/auth.json. No API key.", + "If you already ran `npm i -g command-code@latest && cmd login` (Go $1 plan), click Complete to sync ~/.commandcode/auth.json.", method: "auto" as const, async callback() { const again = syncCommandCodeCredentialsToOpenCode(); @@ -458,6 +463,43 @@ export const CommandCodePlugin: Plugin = async ( }; }, }, + { + type: "api", + label: "Enter Command Code API key", + prompts: [ + { + type: "text", + key: "apiKey", + message: "Paste your Command Code API key / session token", + placeholder: "user_… or Studio key", + validate: (value) => { + if (!value || !value.trim()) return "API key is required"; + if (value.trim().length < 16) return "Key looks too short"; + return undefined; + }, + }, + ], + async authorize(inputs) { + const key = inputs?.apiKey?.trim() || inputs?.key?.trim() || ""; + if (!key) return { type: "failed" as const }; + const check = await validateCommandApiKey(key); + if (!check.valid) { + log.warn( + "[opencode-commandcode] API key validation failed", + check.error, + ); + return { type: "failed" as const }; + } + writeCommandCodeAuth({ + key, + access: key, + refresh: "api-key", + expires: Date.now() + 1000 * 60 * 60 * 24 * 365, + }); + refreshCommandModels(); + return { type: "success" as const, key }; + }, + }, ], }, }; @@ -466,7 +508,12 @@ export const CommandCodePlugin: Plugin = async ( export default CommandCodePlugin; export { detectCommandCode } from "./detect.js"; -export { getCommandModels, COMMAND_CODE_MODELS } from "./models.js"; +export { + getCommandModels, + listCommandCodeModels, + refreshCommandModels, + invalidateCommandModelCache, +} from "./models.js"; export { startProxy, stopProxy, diff --git a/src/model-discover.ts b/src/model-discover.ts new file mode 100644 index 0000000..8e5326f --- /dev/null +++ b/src/model-discover.ts @@ -0,0 +1,277 @@ +/** + * Discover Command Code models dynamically from the installed CLI. + * No hardcoded catalog — prefer `cmd --list-models`, enrich from bundled models.md. + */ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { realpathSync } from "node:fs"; +import type { CommandEffort } from "./constants.js"; +import { isCommandEffort, LAGUNA_MODEL_ID } from "./constants.js"; +import { resolveCommandCodeExecutable } from "./executable-path.js"; +import { log } from "./log.js"; + +export type DiscoveredModelMeta = { + id: string; + name: string; + description?: string; + contextWindow: number; + maxTokens: number; + vision: boolean; + free: boolean; + efforts: CommandEffort[]; + reasoning: boolean; +}; + +const DEFAULT_CONTEXT = 256_000; +const DEFAULT_OUTPUT = 32_768; +const HEADER_WORDS = new Set([ + "open", + "source", + "anthropic", + "openai", + "google", + "sakana", + "meta", + "xai", + "available", + "pass", + "docs", + "the", +]); + +function parseContextWindow(raw: string): number | null { + const t = raw.trim(); + if (!t || t === "—") return null; + const m = t.match(/^([\d.]+)\s*([KMB])?$/i); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n)) return null; + const unit = (m[2] || "").toUpperCase(); + if (unit === "K") return Math.round(n * 1_000); + if (unit === "M") return Math.round(n * 1_000_000); + if (unit === "B") return Math.round(n * 1_000_000_000); + return Math.round(n); +} + +function parseEfforts(raw: string): CommandEffort[] { + if (!raw || raw.trim() === "—") return []; + const out: CommandEffort[] = []; + for (const part of raw.split(/[,\s]+/)) { + const p = part.trim().toLowerCase(); + if (isCommandEffort(p)) out.push(p); + } + return out; +} + +function looksLikeModelId(id: string): boolean { + if (!id || id.length < 3) return false; + if (HEADER_WORDS.has(id.toLowerCase())) return false; + if (/^https?:/i.test(id)) return false; + // provider/name or bare frontier ids + if (id.includes("/")) return /^[A-Za-z0-9._@+-]+\/[A-Za-z0-9._@+-]+$/.test(id); + return /^(claude-|gpt-|o\d)/i.test(id); +} + +/** Parse `cmd --list-models` text output into id + description. */ +export function parseListModelsOutput(text: string): Array<{ + id: string; + description: string; +}> { + const out: Array<{ id: string; description: string }> = []; + const seen = new Set(); + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^(\S+)\s{2,}(.+)$/); + if (!m) continue; + const id = m[1].trim(); + const description = m[2].trim(); + if (!looksLikeModelId(id)) continue; + if (seen.has(id)) continue; + seen.add(id); + out.push({ id, description }); + } + return out; +} + +/** Parse Command Code knowledge `models.md` table for richer metadata. */ +export function parseModelsMarkdown(md: string): Map { + const map = new Map(); + for (const line of md.split(/\r?\n/)) { + const m = line.match( + /^\| `([^`]+)` \| ([^|]+) \| ([^|]+) \| ([^|]+) \|/, + ); + if (!m) continue; + const id = m[1].trim(); + const name = m[2].trim(); + const contextRaw = m[3].trim(); + const effortsRaw = m[4].trim(); + if (!looksLikeModelId(id)) continue; + const efforts = parseEfforts(effortsRaw); + const contextWindow = parseContextWindow(contextRaw) ?? DEFAULT_CONTEXT; + const free = /free/i.test(id) || /\$0\/\$0/.test(line); + const vision = + /vision|multimodal|image/i.test(line) || + /kimi|minimax|gemini|inkling|step-/i.test(id); + map.set(id, { + id, + name, + contextWindow, + maxTokens: DEFAULT_OUTPUT, + vision, + free, + efforts, + reasoning: efforts.length > 0, + }); + // Case-insensitive alias for CLI lowercase ids + map.set(id.toLowerCase(), map.get(id)!); + } + return map; +} + +function findCommandCodePackageRoot(cmdPath: string | null): string | null { + if (!cmdPath) return null; + let resolved = cmdPath; + try { + resolved = realpathSync(cmdPath); + } catch { + // keep + } + // Typical npm layout: .../node_modules/command-code/bin/... or .../command-code/... + const parts = resolved.split(/[/\\]/); + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i] === "command-code") { + return parts.slice(0, i + 1).join("/"); + } + } + // Global shim: ~/.local/bin/cmd → ~/.local/lib/node_modules/command-code + const binDir = dirname(resolved); + const candidates = [ + join(binDir, "..", "lib", "node_modules", "command-code"), + join(binDir, "..", "node_modules", "command-code"), + ]; + for (const c of candidates) { + if (existsSync(join(c, "package.json"))) return c; + } + return null; +} + +function findModelsMarkdown(cmdPath: string | null): string | null { + const root = findCommandCodePackageRoot(cmdPath); + if (!root) return null; + const candidate = join( + root, + "dist", + "bundled", + "command-code-knowledge", + "reference", + "models.md", + ); + return existsSync(candidate) ? candidate : null; +} + +function displayNameFromId(id: string, description?: string): string { + const short = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id; + const pretty = short + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + if (description && /FREE/i.test(description)) return `${pretty} (free)`; + return pretty; +} + +function runListModels(cmdPath: string): string | null { + try { + const result = spawnSync(cmdPath, ["--list-models"], { + encoding: "utf8", + timeout: 20_000, + env: process.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const out = `${result.stdout || ""}${result.stderr || ""}`; + if (!out.trim()) return null; + return out; + } catch (err) { + log.warn( + "[opencode-commandcode] cmd --list-models failed", + err instanceof Error ? err.message : err, + ); + return null; + } +} + +/** + * Discover the live Command Code model catalog from the installed CLI. + */ +export function discoverCommandModels(): DiscoveredModelMeta[] { + const cmdPath = resolveCommandCodeExecutable(); + const listText = cmdPath ? runListModels(cmdPath) : null; + const listed = listText ? parseListModelsOutput(listText) : []; + + const mdPath = findModelsMarkdown(cmdPath); + let mdMeta = new Map(); + if (mdPath) { + try { + mdMeta = parseModelsMarkdown(readFileSync(mdPath, "utf8")); + } catch (err) { + log.warn( + "[opencode-commandcode] failed reading models.md", + err instanceof Error ? err.message : err, + ); + } + } + + const byId = new Map(); + + // Prefer live CLI listing order; enrich from models.md when present. + for (const row of listed) { + const enrich = + mdMeta.get(row.id) || + mdMeta.get(row.id.toLowerCase()) || + // CLI lowercases some ids; md keeps vendor casing + [...mdMeta.values()].find( + (m) => m.id.toLowerCase() === row.id.toLowerCase(), + ); + const free = + enrich?.free || + /free/i.test(row.id) || + /FREE/i.test(row.description || ""); + byId.set(row.id, { + id: enrich?.id || row.id, + name: enrich?.name || displayNameFromId(row.id, row.description), + description: row.description, + contextWindow: enrich?.contextWindow ?? DEFAULT_CONTEXT, + maxTokens: enrich?.maxTokens ?? DEFAULT_OUTPUT, + vision: enrich?.vision ?? false, + free, + efforts: enrich?.efforts ?? [], + reasoning: enrich ? enrich.reasoning : true, + }); + } + + // If CLI list failed, fall back to models.md alone. + if (byId.size === 0 && mdMeta.size > 0) { + for (const [key, meta] of mdMeta) { + if (key !== meta.id) continue; // skip lowercase duplicates + byId.set(meta.id, meta); + } + } + + // Absolute last resort so the provider stays selectable for live tests. + if (byId.size === 0) { + log.warn( + "[opencode-commandcode] no live model catalog; temporary Laguna-only fallback", + ); + byId.set(LAGUNA_MODEL_ID, { + id: LAGUNA_MODEL_ID, + name: "Laguna S 2.1 (free)", + contextWindow: DEFAULT_CONTEXT, + maxTokens: DEFAULT_OUTPUT, + vision: false, + free: true, + efforts: [], + reasoning: true, + }); + } + + return [...byId.values()]; +} diff --git a/src/models.ts b/src/models.ts index 75fecbf..cf79625 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,7 +1,19 @@ /** - * Command Code model catalog — Laguna S 2.1 free is the primary target. + * Command Code model catalog — discovered dynamically from the installed CLI. + * Laguna remains the preferred default when present (unlimited free tier for tests), + * but every model from `cmd --list-models` is exposed in OpenCode. */ -import { EFFORT_LEVELS, type CommandEffort, LAGUNA_MODEL_ID } from "./constants.js"; +import { + EFFORT_LEVELS, + type CommandEffort, + DEFAULT_MODEL_ID, + LAGUNA_MODEL_ID, +} from "./constants.js"; +import { + discoverCommandModels, + type DiscoveredModelMeta, +} from "./model-discover.js"; +import { log } from "./log.js"; export type CommandModel = { id: string; @@ -14,50 +26,24 @@ export type CommandModel = { vision: boolean; /** Free-tier badge. */ free?: boolean; + /** Supported effort levels from Command Code (empty → model decides). */ + efforts?: CommandEffort[]; }; -const LIMIT_256K = { context: 256_000, output: 32_768 } as const; +const CATALOG_TTL_MS = 30 * 60 * 1000; -function model( - id: string, - name: string, - resolvedId: string, - limit: { context: number; output: number }, - opts?: { vision?: boolean; free?: boolean; reasoning?: boolean }, -): CommandModel { - return { - id, - name, - resolvedId, - reasoning: opts?.reasoning !== false, - contextWindow: limit.context, - maxTokens: limit.output, - vision: opts?.vision === true, - free: opts?.free, - }; -} +let cachedModels: CommandModel[] | null = null; +let cachedAt = 0; -/** - * Primary catalog for this plugin. Laguna is the default and exclusive - * live-test target; aliases keep OpenCode menus short. - */ -export const COMMAND_CODE_MODELS: CommandModel[] = [ - model("laguna-s-2.1-free", "Laguna S 2.1 (free)", LAGUNA_MODEL_ID, LIMIT_256K, { - free: true, - vision: false, - }), - model("laguna", "Laguna S 2.1 (free)", LAGUNA_MODEL_ID, LIMIT_256K, { - free: true, - vision: false, - }), - model( - "poolside/laguna-s-2.1-free", - "Laguna S 2.1 (free)", - LAGUNA_MODEL_ID, - LIMIT_256K, - { free: true, vision: false }, - ), -]; +export const GENERATED_VARIANT_KEYS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; /** Placeholder so OpenCode keeps the provider visible while logged out. */ export const LOGIN_PLACEHOLDER_MODELS: CommandModel[] = [ @@ -72,64 +58,155 @@ export const LOGIN_PLACEHOLDER_MODELS: CommandModel[] = [ }, ]; -export const GENERATED_VARIANT_KEYS = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; - -export function isLoginPlaceholderModel(id: string): boolean { - return id === "login"; +function metaToModel(meta: DiscoveredModelMeta): CommandModel { + return { + id: meta.id, + name: meta.name, + resolvedId: meta.id, + reasoning: meta.reasoning || meta.efforts.length > 0, + contextWindow: meta.contextWindow, + maxTokens: meta.maxTokens, + vision: meta.vision, + free: meta.free || undefined, + efforts: meta.efforts.length ? meta.efforts : undefined, + }; } -export function getCommandModels(): CommandModel[] { - // Deduplicate by id while preferring shorter aliases first. - const seen = new Set(); +function withAliases(models: CommandModel[]): CommandModel[] { const out: CommandModel[] = []; - for (const m of COMMAND_CODE_MODELS) { - if (seen.has(m.id)) continue; + const seen = new Set(); + const add = (m: CommandModel) => { + if (seen.has(m.id)) return; seen.add(m.id); out.push(m); + }; + + for (const m of models) add(m); + + // Short name after last "/" — Command Code accepts these. + for (const m of models) { + if (!m.resolvedId.includes("/")) continue; + const short = m.resolvedId.slice(m.resolvedId.lastIndexOf("/") + 1); + if (!short || seen.has(short)) continue; + add({ ...m, id: short }); + } + + // Friendly Laguna aliases when that model is in the live catalog. + const laguna = models.find((m) => m.resolvedId === LAGUNA_MODEL_ID); + if (laguna) { + if (!seen.has(DEFAULT_MODEL_ID)) { + add({ ...laguna, id: DEFAULT_MODEL_ID, name: laguna.name }); + } + if (!seen.has("laguna")) { + add({ ...laguna, id: "laguna", name: laguna.name }); + } } + + // Put default / Laguna aliases first for nicer OpenCode menus. + out.sort((a, b) => { + const rank = (m: CommandModel) => { + if (m.id === DEFAULT_MODEL_ID) return 0; + if (m.id === "laguna") return 1; + if (m.resolvedId === LAGUNA_MODEL_ID) return 2; + if (m.free) return 3; + return 4; + }; + return rank(a) - rank(b) || a.name.localeCompare(b.name); + }); + return out; } +export function invalidateCommandModelCache(): void { + cachedModels = null; + cachedAt = 0; +} + +/** Force a fresh discovery from the Command Code CLI. */ +export function refreshCommandModels(): CommandModel[] { + const discovered = discoverCommandModels().map(metaToModel); + cachedModels = withAliases(discovered); + cachedAt = Date.now(); + log.info("[opencode-commandcode] model catalog refreshed", { + count: cachedModels.length, + }); + return cachedModels; +} + +/** + * Live catalog (cached). Discovers via `cmd --list-models` on first use / + * after TTL expiry — never a hardcoded product list. + */ +export function getCommandModels(): CommandModel[] { + if (cachedModels && Date.now() - cachedAt < CATALOG_TTL_MS) { + return cachedModels; + } + return refreshCommandModels(); +} + +/** Snapshot alias used by older call sites / tests. */ +export function listCommandCodeModels(): CommandModel[] { + return getCommandModels(); +} + +export function isLoginPlaceholderModel(id: string): boolean { + return id === "login"; +} + export function resolveCommandModelId(modelId: string): string { const cleaned = modelId.replace(/^command-code\//, "").trim(); - const match = COMMAND_CODE_MODELS.find( + if (!cleaned) return LAGUNA_MODEL_ID; + + const models = getCommandModels(); + const exact = models.find( (m) => m.id === cleaned || m.resolvedId === cleaned, ); - if (match) return match.resolvedId; - // Accept short name after last slash. + if (exact) return exact.resolvedId; + if (!cleaned.includes("/")) { - const bySuffix = COMMAND_CODE_MODELS.find((m) => - m.resolvedId.endsWith(`/${cleaned}`), + const bySuffix = models.find((m) => + m.resolvedId.toLowerCase().endsWith(`/${cleaned.toLowerCase()}`), ); if (bySuffix) return bySuffix.resolvedId; } - return cleaned || LAGUNA_MODEL_ID; + + // Case-insensitive full id match + const ci = models.find( + (m) => m.resolvedId.toLowerCase() === cleaned.toLowerCase(), + ); + if (ci) return ci.resolvedId; + + // Pass through unknown ids to the gateway (BYO / newly listed models). + return cleaned; } export function findCommandModel(modelId: string): CommandModel | undefined { - const resolved = resolveCommandModelId(modelId); + const cleaned = modelId.replace(/^command-code\//, "").trim(); + const models = getCommandModels(); + const resolved = resolveCommandModelId(cleaned); return ( - COMMAND_CODE_MODELS.find((m) => m.id === modelId || m.resolvedId === resolved) || - COMMAND_CODE_MODELS.find((m) => m.resolvedId === resolved) + models.find((m) => m.id === cleaned || m.resolvedId === cleaned) || + models.find((m) => m.resolvedId === resolved) || + models.find((m) => m.resolvedId.toLowerCase() === resolved.toLowerCase()) ); } export function buildEffortVariants( model: CommandModel, ): Record { - if (!model.reasoning || isLoginPlaceholderModel(model.id)) return {}; + if (isLoginPlaceholderModel(model.id)) return {}; + const supported = + model.efforts && model.efforts.length > 0 + ? model.efforts + : model.reasoning + ? [...EFFORT_LEVELS] + : []; + if (supported.length === 0) return {}; + const variants: Record< string, { effort: CommandEffort } | { disabled: true } - > = Object.fromEntries(EFFORT_LEVELS.map((effort) => [effort, { effort }])); + > = Object.fromEntries(supported.map((effort) => [effort, { effort }])); for (const key of GENERATED_VARIANT_KEYS) { if (!(key in variants)) variants[key] = { disabled: true }; } diff --git a/test/smoke.ts b/test/smoke.ts index e87b792..95a6699 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -13,13 +13,18 @@ async function main() { readCommandCodeApiKeyFromEnv, } = await import("../src/credentials.ts"); const { - COMMAND_CODE_MODELS, buildEffortVariants, getCommandModels, + refreshCommandModels, + invalidateCommandModelCache, isLoginPlaceholderModel, resolveCommandModelId, findCommandModel, } = await import("../src/models.ts"); + const { + parseListModelsOutput, + parseModelsMarkdown, + } = await import("../src/model-discover.ts"); const { encodeCommandModelSelection, decodeCommandModelSelection, @@ -81,30 +86,62 @@ async function main() { ); assert.equal(readCommandCodeApiKeyFromEnv({}), null); - // --- models / Laguna --- - const models = getCommandModels(); - assert.ok(models.some((m) => m.resolvedId === LAGUNA_MODEL_ID)); - assert.equal(resolveCommandModelId("laguna"), LAGUNA_MODEL_ID); - assert.equal(resolveCommandModelId("laguna-s-2.1-free"), LAGUNA_MODEL_ID); - assert.equal(resolveCommandModelId(DEFAULT_MODEL_ID), LAGUNA_MODEL_ID); + // --- models (dynamic catalog) --- + invalidateCommandModelCache(); + const models = refreshCommandModels(); + assert.ok(models.length >= 1, "expected at least one discovered model"); assert.equal(isLoginPlaceholderModel("login"), true); - const laguna = findCommandModel("laguna")!; - assert.equal(laguna.vision, false); - assert.equal(laguna.free, true); - assert.equal(laguna.contextWindow, 256_000); - - const variants = buildEffortVariants(laguna); - for (const level of EFFORT_LEVELS) { - assert.ok(variants[level]); - assert.equal(isCommandEffort(level), true); + if (models.some((m) => m.resolvedId === LAGUNA_MODEL_ID)) { + assert.equal(resolveCommandModelId("laguna"), LAGUNA_MODEL_ID); + assert.equal(resolveCommandModelId("laguna-s-2.1-free"), LAGUNA_MODEL_ID); + assert.equal(resolveCommandModelId(DEFAULT_MODEL_ID), LAGUNA_MODEL_ID); + const laguna = findCommandModel("laguna")!; + assert.equal(laguna.vision, false); + assert.equal(laguna.free, true); + assert.equal(laguna.contextWindow, 256_000); + const variants = buildEffortVariants(laguna); + assert.equal(typeof variants, "object"); } - assert.deepEqual(variants.none, { disabled: true }); + + const parsedList = parseListModelsOutput(` +Available models · 2 models + +Open Source + +deepseek/deepseek-v4-pro hybrid reasoning +poolside/laguna-s-2.1-free FREE open-weight + +Anthropic + +claude-sonnet-5 recommended +`); + assert.equal(parsedList.length, 3); + assert.equal(parsedList[0]?.id, "deepseek/deepseek-v4-pro"); + const md = parseModelsMarkdown(` +| Id | Name | Context | Efforts | Best for | +|---|---|---|---|---| +| \`poolside/laguna-s-2.1-free\` | Laguna S 2.1 | 256K | — | coding | +| \`claude-sonnet-5\` | Claude Sonnet 5 | 1M | low, medium, high, xhigh, max | agents | +`); + assert.equal(md.get("claude-sonnet-5")?.contextWindow, 1_000_000); + assert.deepEqual(md.get("claude-sonnet-5")?.efforts, [ + "low", + "medium", + "high", + "xhigh", + "max", + ]); const selection = resolveCommandModelSelection("laguna", "high"); const encoded = encodeCommandModelSelection(selection); const decoded = decodeCommandModelSelection(encoded); assert.equal(decoded?.modelId, LAGUNA_MODEL_ID); assert.equal(decoded?.effort, "high"); + assert.equal(PROVIDER_ID, "command-code"); + for (const level of EFFORT_LEVELS) { + assert.equal(isCommandEffort(level), true); + } + assert.ok(getCommandModels().length >= 1); // --- attachments (text-only Laguna strips images; files/pdfs inlined) --- const png =