| import { createHash } from "node:crypto"; |
| import os from "node:os"; |
| import type { AdapterModel } from "@paperclipai/adapter-utils"; |
| import { |
| asString, |
| ensurePathInEnv, |
| runChildProcess, |
| } from "@paperclipai/adapter-utils/server-utils"; |
|
|
| const MODELS_CACHE_TTL_MS = 60_000; |
| const MODELS_DISCOVERY_TIMEOUT_MS = 20_000; |
|
|
| function resolveOpenCodeCommand(input: unknown): string { |
| const envOverride = |
| typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" && |
| process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0 |
| ? process.env.PAPERCLIP_OPENCODE_COMMAND.trim() |
| : "opencode"; |
| return asString(input, envOverride); |
| } |
|
|
| const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>(); |
| const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const; |
| const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]); |
|
|
| function dedupeModels(models: AdapterModel[]): AdapterModel[] { |
| const seen = new Set<string>(); |
| const deduped: AdapterModel[] = []; |
| for (const model of models) { |
| const id = model.id.trim(); |
| if (!id || seen.has(id)) continue; |
| seen.add(id); |
| deduped.push({ id, label: model.label.trim() || id }); |
| } |
| return deduped; |
| } |
|
|
| function sortModels(models: AdapterModel[]): AdapterModel[] { |
| return [...models].sort((a, b) => |
| a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }), |
| ); |
| } |
|
|
| function firstNonEmptyLine(text: string): string { |
| return ( |
| text |
| .split(/\r?\n/) |
| .map((line) => line.trim()) |
| .find(Boolean) ?? "" |
| ); |
| } |
|
|
| function parseModelsOutput(stdout: string): AdapterModel[] { |
| const parsed: AdapterModel[] = []; |
| for (const raw of stdout.split(/\r?\n/)) { |
| const line = raw.trim(); |
| if (!line) continue; |
| const firstToken = line.split(/\s+/)[0]?.trim() ?? ""; |
| if (!firstToken.includes("/")) continue; |
| const provider = firstToken.slice(0, firstToken.indexOf("/")).trim(); |
| const model = firstToken.slice(firstToken.indexOf("/") + 1).trim(); |
| if (!provider || !model) continue; |
| parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` }); |
| } |
| return dedupeModels(parsed); |
| } |
|
|
| function normalizeEnv(input: unknown): Record<string, string> { |
| const envInput = typeof input === "object" && input !== null && !Array.isArray(input) |
| ? (input as Record<string, unknown>) |
| : {}; |
| const env: Record<string, string> = {}; |
| for (const [key, value] of Object.entries(envInput)) { |
| if (typeof value === "string") env[key] = value; |
| } |
| return env; |
| } |
|
|
| function isVolatileEnvKey(key: string): boolean { |
| if (VOLATILE_ENV_KEY_EXACT.has(key)) return true; |
| return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); |
| } |
|
|
| function hashValue(value: string): string { |
| return createHash("sha256").update(value).digest("hex"); |
| } |
|
|
| function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) { |
| const envKey = Object.entries(env) |
| .filter(([key]) => !isVolatileEnvKey(key)) |
| .sort(([a], [b]) => a.localeCompare(b)) |
| .map(([key, value]) => `${key}=${hashValue(value)}`) |
| .join("\n"); |
| return `${command}\n${cwd}\n${envKey}`; |
| } |
|
|
| function pruneExpiredDiscoveryCache(now: number) { |
| for (const [key, value] of discoveryCache.entries()) { |
| if (value.expiresAt <= now) discoveryCache.delete(key); |
| } |
| } |
|
|
| export async function discoverOpenCodeModels(input: { |
| command?: unknown; |
| cwd?: unknown; |
| env?: unknown; |
| } = {}): Promise<AdapterModel[]> { |
| const command = resolveOpenCodeCommand(input.command); |
| const cwd = asString(input.cwd, process.cwd()); |
| const env = normalizeEnv(input.env); |
| |
| |
| |
| |
| let resolvedHome: string | undefined; |
| try { |
| resolvedHome = os.userInfo().homedir || undefined; |
| } catch { |
| |
| |
| |
| } |
| |
| const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env, ...(resolvedHome ? { HOME: resolvedHome } : {}), OPENCODE_DISABLE_PROJECT_CONFIG: "true" })); |
|
|
| const result = await runChildProcess( |
| `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, |
| command, |
| ["models"], |
| { |
| cwd, |
| env: runtimeEnv, |
| timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000, |
| graceSec: 3, |
| onLog: async () => {}, |
| }, |
| ); |
|
|
| if (result.timedOut) { |
| throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`); |
| } |
| if ((result.exitCode ?? 1) !== 0) { |
| const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); |
| throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed."); |
| } |
|
|
| return sortModels(parseModelsOutput(result.stdout)); |
| } |
|
|
| export async function discoverOpenCodeModelsCached(input: { |
| command?: unknown; |
| cwd?: unknown; |
| env?: unknown; |
| } = {}): Promise<AdapterModel[]> { |
| const command = resolveOpenCodeCommand(input.command); |
| const cwd = asString(input.cwd, process.cwd()); |
| const env = normalizeEnv(input.env); |
| const key = discoveryCacheKey(command, cwd, env); |
| const now = Date.now(); |
| pruneExpiredDiscoveryCache(now); |
| const cached = discoveryCache.get(key); |
| if (cached && cached.expiresAt > now) return cached.models; |
|
|
| const models = await discoverOpenCodeModels({ command, cwd, env }); |
| discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models }); |
| return models; |
| } |
|
|
| export async function ensureOpenCodeModelConfiguredAndAvailable(input: { |
| model?: unknown; |
| command?: unknown; |
| cwd?: unknown; |
| env?: unknown; |
| }): Promise<AdapterModel[]> { |
| const model = asString(input.model, "").trim(); |
| if (!model) { |
| throw new Error("OpenCode requires `adapterConfig.model` in provider/model format."); |
| } |
|
|
| const models = await discoverOpenCodeModelsCached({ |
| command: input.command, |
| cwd: input.cwd, |
| env: input.env, |
| }); |
|
|
| if (models.length === 0) { |
| throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth."); |
| } |
|
|
| if (!models.some((entry) => entry.id === model)) { |
| const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); |
| throw new Error( |
| `Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, |
| ); |
| } |
|
|
| return models; |
| } |
|
|
| export async function listOpenCodeModels(): Promise<AdapterModel[]> { |
| try { |
| return await discoverOpenCodeModelsCached(); |
| } catch { |
| return []; |
| } |
| } |
|
|
| export function resetOpenCodeModelsCacheForTests() { |
| discoveryCache.clear(); |
| } |
|
|