mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
177 lines
5.2 KiB
TypeScript
177 lines
5.2 KiB
TypeScript
|
|
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||
|
|
import {
|
||
|
|
asString,
|
||
|
|
runChildProcess,
|
||
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
||
|
|
|
||
|
|
const MODELS_CACHE_TTL_MS = 60_000;
|
||
|
|
|
||
|
|
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
|
||
|
|
|
||
|
|
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 discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
|
||
|
|
const envKey = Object.entries(env)
|
||
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
||
|
|
.map(([key, value]) => `${key}=${value}`)
|
||
|
|
.join("\n");
|
||
|
|
return `${command}\n${cwd}\n${envKey}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function discoverOpenCodeModels(input: {
|
||
|
|
command?: unknown;
|
||
|
|
cwd?: unknown;
|
||
|
|
env?: unknown;
|
||
|
|
} = {}): Promise<AdapterModel[]> {
|
||
|
|
const command = asString(
|
||
|
|
input.command,
|
||
|
|
(typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
|
||
|
|
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
|
||
|
|
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
|
||
|
|
: "opencode"),
|
||
|
|
);
|
||
|
|
const cwd = asString(input.cwd, process.cwd());
|
||
|
|
const env = normalizeEnv(input.env);
|
||
|
|
|
||
|
|
const result = await runChildProcess(
|
||
|
|
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||
|
|
command,
|
||
|
|
["models"],
|
||
|
|
{
|
||
|
|
cwd,
|
||
|
|
env,
|
||
|
|
timeoutSec: 20,
|
||
|
|
graceSec: 3,
|
||
|
|
onLog: async () => {},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
if (result.timedOut) {
|
||
|
|
throw new Error("`opencode models` timed out.");
|
||
|
|
}
|
||
|
|
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 = asString(
|
||
|
|
input.command,
|
||
|
|
(typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
|
||
|
|
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
|
||
|
|
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
|
||
|
|
: "opencode"),
|
||
|
|
);
|
||
|
|
const cwd = asString(input.cwd, process.cwd());
|
||
|
|
const env = normalizeEnv(input.env);
|
||
|
|
const key = discoveryCacheKey(command, cwd, env);
|
||
|
|
const now = Date.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();
|
||
|
|
}
|