feat(models): port model discovery to match opencode_local adapter

Replace static hardcoded fallback list with the same robust approach used
by the opencode_local adapter: runChildProcess + ensurePathInEnv, HOME fix
via os.userInfo(), 60s TTL cache, returns [] on failure instead of a stale
list. Also updates CLAUDE.md and README.md with missing fields/features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:51:26 -04:00
parent 7043e71ff6
commit 5f75c2b81b
8 changed files with 498 additions and 134 deletions
+1 -2
View File
@@ -1,7 +1,7 @@
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import { type, agentConfigurationDoc } from "../index.js";
import { listK8sModels, STATIC_MODELS } from "./models.js";
import { listK8sModels } from "./models.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
@@ -14,7 +14,6 @@ export function createServerAdapter(): ServerAdapterModule {
execute,
testEnvironment,
sessionCodec,
models: STATIC_MODELS,
listModels: listK8sModels,
listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills,
+122 -53
View File
@@ -1,85 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const execMock = vi.fn();
const runChildProcessMock = vi.fn();
vi.mock("child_process", () => ({
exec: (cmd: string, opts: unknown, cb: (err: Error | null, result: { stdout: string; stderr: string }) => void) => {
execMock(cmd, opts, cb);
},
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@paperclipai/adapter-utils/server-utils")>();
return { ...actual, runChildProcess: runChildProcessMock };
});
const { listK8sModels, STATIC_MODELS } = await import("./models.js");
const { listK8sModels, discoverK8sModels, resetK8sModelsCacheForTests } = await import("./models.js");
function mockExecResult(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(null, { stdout, stderr: "" });
});
type MockResult = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean };
function mockSuccess(stdout: string): void {
runChildProcessMock.mockResolvedValue({ exitCode: 0, stdout, stderr: "", timedOut: false } satisfies MockResult);
}
function mockExecError(err: Error) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(err, { stdout: "", stderr: "" });
});
function mockFailure(stderr = "ENOENT: opencode not found"): void {
runChildProcessMock.mockResolvedValue({ exitCode: 1, stdout: "", stderr, timedOut: false } satisfies MockResult);
}
function mockTimeout(): void {
runChildProcessMock.mockResolvedValue({ exitCode: null, stdout: "", stderr: "", timedOut: true } satisfies MockResult);
}
describe("listK8sModels", () => {
beforeEach(() => {
execMock.mockReset();
afterEach(() => {
runChildProcessMock.mockReset();
resetK8sModelsCacheForTests();
});
it("parses opencode models output into AdapterModel entries", async () => {
mockExecResult(
[
"anthropic/claude-opus-4-7",
"openai/gpt-4o",
"google/gemini-2.5-pro",
].join("\n"),
);
it("parses provider/model lines into AdapterModel entries", async () => {
mockSuccess(["anthropic/claude-opus-4-7", "openai/gpt-4o", "google/gemini-2.5-pro"].join("\n"));
const models = await listK8sModels();
expect(models).toHaveLength(3);
expect(models[0]).toEqual({ id: "anthropic/claude-opus-4-7", label: "claude opus 4 7" });
expect(models[1]).toEqual({ id: "openai/gpt-4o", label: "gpt 4o" });
expect(models[2]).toEqual({ id: "google/gemini-2.5-pro", label: "gemini 2.5 pro" });
expect(models.find((m) => m.id === "anthropic/claude-opus-4-7")).toEqual({
id: "anthropic/claude-opus-4-7",
label: "anthropic/claude-opus-4-7",
});
});
it("ignores blank lines and trims whitespace", async () => {
mockExecResult("\nanthropic/claude-opus-4-7\n\n openai/gpt-4o \n\n");
mockSuccess("\nanthropic/claude-opus-4-7\n\n openai/gpt-4o \n\n");
const models = await listK8sModels();
expect(models.map((m) => m.id)).toEqual([
"anthropic/claude-opus-4-7",
"openai/gpt-4o",
]);
expect(models.map((m) => m.id)).toEqual(
["anthropic/claude-opus-4-7", "openai/gpt-4o"].sort(),
);
});
it("invokes opencode models with a timeout", async () => {
mockExecResult("anthropic/claude-opus-4-7");
it("skips lines without a provider/model slash", async () => {
mockSuccess("anthropic/claude-opus-4-7\nnot-a-model-line\nopenai/gpt-4o");
const models = await listK8sModels();
expect(models.map((m) => m.id)).not.toContain("not-a-model-line");
expect(models).toHaveLength(2);
});
it("deduplicates repeated model IDs", async () => {
mockSuccess("anthropic/claude-opus-4-7\nanthropic/claude-opus-4-7\nopenai/gpt-4o");
const models = await listK8sModels();
expect(models.filter((m) => m.id === "anthropic/claude-opus-4-7")).toHaveLength(1);
});
it("returns models sorted alphabetically by ID", async () => {
mockSuccess("openai/gpt-4o\nanthropic/claude-opus-4-7");
const models = await listK8sModels();
expect(models[0].id).toBe("anthropic/claude-opus-4-7");
expect(models[1].id).toBe("openai/gpt-4o");
});
it("returns empty array when the CLI fails", async () => {
mockFailure("ENOENT: opencode not found");
expect(await listK8sModels()).toEqual([]);
});
it("returns empty array when the CLI times out", async () => {
mockTimeout();
expect(await listK8sModels()).toEqual([]);
});
it("returns empty array when the CLI returns empty stdout", async () => {
mockSuccess("");
expect(await listK8sModels()).toEqual([]);
});
it("caches results and only calls the CLI once within the TTL", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await listK8sModels();
await listK8sModels();
expect(execMock).toHaveBeenCalledTimes(1);
const [cmd, opts] = execMock.mock.calls[0];
expect(cmd).toBe("opencode models");
expect(opts).toMatchObject({ timeout: 30_000 });
expect(runChildProcessMock).toHaveBeenCalledTimes(1);
});
it("falls back to the static list when the CLI fails", async () => {
mockExecError(new Error("ENOENT: opencode not found"));
it("re-fetches after the cache is reset", async () => {
mockSuccess("anthropic/claude-opus-4-7");
const models = await listK8sModels();
await listK8sModels();
resetK8sModelsCacheForTests();
await listK8sModels();
expect(models).toBe(STATIC_MODELS);
expect(models.length).toBeGreaterThan(0);
});
it("falls back to the static list when the CLI returns empty stdout", async () => {
mockExecResult("");
const models = await listK8sModels();
expect(models).toBe(STATIC_MODELS);
expect(runChildProcessMock).toHaveBeenCalledTimes(2);
});
});
describe("discoverK8sModels", () => {
afterEach(() => {
runChildProcessMock.mockReset();
resetK8sModelsCacheForTests();
});
it("passes OPENCODE_DISABLE_PROJECT_CONFIG=true to the subprocess", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await discoverK8sModels();
const [, , , opts] = runChildProcessMock.mock.calls[0] as [unknown, unknown, unknown, { env: Record<string, string> }];
expect(opts.env).toMatchObject({ OPENCODE_DISABLE_PROJECT_CONFIG: "true" });
});
it("invokes opencode with the models subcommand", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await discoverK8sModels();
const [, command, args] = runChildProcessMock.mock.calls[0] as [unknown, string, string[]];
expect(command).toBe("opencode");
expect(args).toEqual(["models"]);
});
it("throws when the CLI exits non-zero", async () => {
mockFailure("provider not configured");
await expect(discoverK8sModels()).rejects.toThrow("opencode models` failed");
});
it("throws when the CLI times out", async () => {
mockTimeout();
await expect(discoverK8sModels()).rejects.toThrow("timed out");
});
});
+160 -29
View File
@@ -1,38 +1,169 @@
import { createHash } from "node:crypto";
import os from "node:os";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { exec } from "child_process";
import { promisify } from "util";
import { asString, ensurePathInEnv, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
const execAsync = promisify(exec);
const MODELS_CACHE_TTL_MS = 60_000;
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
export const STATIC_MODELS: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "anthropic/claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5" },
{ id: "openai/gpt-4o", label: "GPT-4o" },
{ id: "openai/gpt-4o-mini", label: "GPT-4o mini" },
{ id: "openai/gpt-5", label: "GPT-5" },
{ id: "openai/gpt-5-mini", label: "GPT-5 mini" },
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ id: "xai/grok-4", label: "Grok 4" },
{ id: "deepseek/deepseek-chat", label: "DeepSeek Chat" },
];
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 discoverK8sModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(input.command, "opencode");
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
let resolvedHome: string | undefined;
try {
resolvedHome = os.userInfo().homedir || undefined;
} catch {
// os.userInfo() throws when the UID has no /etc/passwd entry (e.g. distroless images)
}
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 discoverK8sModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(input.command, "opencode");
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 discoverK8sModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function listK8sModels(): Promise<AdapterModel[]> {
try {
const result = await execAsync("opencode models", { timeout: 30_000 });
const lines = result.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return STATIC_MODELS;
const models: AdapterModel[] = [];
for (const line of lines) {
const parts = line.split("/");
const label = parts[parts.length - 1].replace(/-/g, " ").replace(/_/g, " ");
models.push({ id: line, label });
}
return models;
return await discoverK8sModelsCached();
} catch {
return STATIC_MODELS;
return [];
}
}
export function resetK8sModelsCacheForTests() {
discoveryCache.clear();
}
+1 -15
View File
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { createServerAdapter } from "./index.js";
import { STATIC_MODELS } from "./models.js";
describe("createServerAdapter", () => {
it("declares the opencode_k8s type", () => {
@@ -8,20 +7,7 @@ describe("createServerAdapter", () => {
expect(adapter.type).toBe("opencode_k8s");
});
it("exposes a non-empty static models list so the UI never renders zero models", () => {
const adapter = createServerAdapter();
expect(Array.isArray(adapter.models)).toBe(true);
expect(adapter.models!.length).toBeGreaterThan(0);
expect(adapter.models).toBe(STATIC_MODELS);
for (const m of adapter.models!) {
expect(typeof m.id).toBe("string");
expect(m.id.length).toBeGreaterThan(0);
expect(typeof m.label).toBe("string");
expect(m.label.length).toBeGreaterThan(0);
}
});
it("also exposes listModels for dynamic refresh", () => {
it("exposes listModels for dynamic model discovery", () => {
const adapter = createServerAdapter();
expect(typeof adapter.listModels).toBe("function");
});