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:
+1
-2
@@ -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
@@ -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
@@ -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,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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user