fix(models): expose static models list so UI renders entries before listModels resolves

The UI was showing 0 models because the adapter only set listModels (async)
without a static models array. The static array is what the UI reads
synchronously to populate the selector — listModels is for refresh.

- Move the fallback list out as STATIC_MODELS (expanded to cover Opus/Sonnet/Haiku 4.x, GPT-4o/5, Gemini 2.5, Grok 4, DeepSeek)
- Set models: STATIC_MODELS on the adapter module
- Keep listK8sModels for runtime refresh from `opencode models` (with STATIC_MODELS fallback on error or empty stdout)
- Add server-adapter.test.ts asserting models is non-empty
- Add models.test.ts coverage for the empty-stdout fallback path

chore: bump version to 0.1.33

Fixes FAR-94.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-26 02:59:05 +00:00
committed by Hugh Commit [agent]
parent da1b55d233
commit 7043e71ff6
5 changed files with 62 additions and 21 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.32",
"version": "0.1.33",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
+2 -1
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 } from "./models.js";
import { listK8sModels, STATIC_MODELS } from "./models.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
@@ -14,6 +14,7 @@ export function createServerAdapter(): ServerAdapterModule {
execute,
testEnvironment,
sessionCodec,
models: STATIC_MODELS,
listModels: listK8sModels,
listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills,
+10 -3
View File
@@ -8,7 +8,7 @@ vi.mock("child_process", () => ({
},
}));
const { listK8sModels } = await import("./models.js");
const { listK8sModels, STATIC_MODELS } = await import("./models.js");
function mockExecResult(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => {
@@ -71,8 +71,15 @@ describe("listK8sModels", () => {
const models = await listK8sModels();
expect(models).toBe(STATIC_MODELS);
expect(models.length).toBeGreaterThan(0);
expect(models.map((m) => m.id)).toContain("anthropic/claude-opus-4-7");
expect(models.map((m) => m.id)).toContain("openai/gpt-4o");
});
it("falls back to the static list when the CLI returns empty stdout", async () => {
mockExecResult("");
const models = await listK8sModels();
expect(models).toBe(STATIC_MODELS);
});
});
+21 -16
View File
@@ -4,30 +4,35 @@ import { promisify } from "util";
const execAsync = promisify(exec);
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" },
];
export async function listK8sModels(): Promise<AdapterModel[]> {
try {
const result = await execAsync("opencode models", { timeout: 30_000 });
const output = result.stdout;
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
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) {
if (!line) continue;
const parts = line.split("/");
const id = line;
const label = parts[parts.length - 1].replace(/-/g, " ").replace(/_/g, " ");
models.push({ id, label });
models.push({ id: line, label });
}
return models;
} catch {
const fallback: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ 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: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
];
return fallback;
return STATIC_MODELS;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
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", () => {
const adapter = 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", () => {
const adapter = createServerAdapter();
expect(typeof adapter.listModels).toBe("function");
});
});