Compare commits

..

7 Commits

Author SHA1 Message Date
Chris Farhood 7043e71ff6 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>
2026-04-26 02:59:05 +00:00
Chris Farhood da1b55d233 test: add coverage for listK8sModels CLI fetch and fallback
chore: bump version to 0.1.32

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 02:07:51 +00:00
Chris Farhood 168161148c feat: fetch model list from opencode CLI instead of hardcoded static list
Reads available models dynamically via 'opencode models' command
to populate the Paperclip UI model selector. Falls back to previous
static list on any error (missing CLI, timeout, parse failure).

Fixes FAR-94: 0 models shown in adapter UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:59:35 +00:00
Chris Farhood 2daedda537 Surface volume mount and PVC bind errors in waitForPod()
Handle MountVolumeFailed/ContainerCannotMount waiting reasons in pod
container status checks, throwing clear errors instead of silent failure.

Detect PVC/volume/bind/mount keywords in PodScheduled condition messages
and surface as 'PVC bind failed' error.

Fixes FAR-93: serviceAccountName missing error surfacing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:47:31 +00:00
Chris Farhood e364e09113 test: assert config.image overrides selfPod image in job manifest
Locks in the existing override behavior so a future regression that
reverts to a hardcoded image is caught immediately. Closes the
investigation on FAR-90.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:12:05 +00:00
Chris Farhood 4c956cc039 chore: bump version to 0.1.31 2026-04-26 00:25:06 +00:00
Chris Farhood 4fcd3b4547 fix test: stub PAPERCLIP_DEV_API_KEY before each cancel-poll test
The cancel-poll test sets PAPERCLIP_API_KEY='test-key' but the actual
PAPERCLIP_DEV_API_KEY was leaking through from the harness environment.
Since execute.ts prefers PAPERCLIP_DEV_API_KEY over PAPERCLIP_API_KEY,
the poll was sending the real dev key instead of 'test-key'.

Fix: add beforeEach to set PAPERCLIP_DEV_API_KEY='test-key', and afterEach
to clean both env vars.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 23:40:41 +00:00
8 changed files with 174 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.30",
"version": "0.1.33",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
+5
View File
@@ -879,11 +879,16 @@ describe("execute — log dedup (waitForPod status dedup)", () => {
describe("execute — external cancel polling", () => {
const KEEPALIVE_MS = 15_000;
beforeEach(() => {
process.env.PAPERCLIP_DEV_API_KEY = "test-key";
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_DEV_API_KEY;
});
it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => {
+17 -1
View File
@@ -126,7 +126,11 @@ async function waitForPod(
(c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable",
);
if (unschedulable) {
throw new Error(`Pod unschedulable: ${unschedulable.message ?? "insufficient resources"}`);
const msg = unschedulable.message ?? "insufficient resources";
if (/pvc|volume|bind|mount/i.test(msg)) {
throw new Error(`PVC bind failed: ${msg}`);
}
throw new Error(`Pod unschedulable: ${msg}`);
}
for (const cs of containerStatuses) {
@@ -137,6 +141,18 @@ async function waitForPod(
if (waiting?.reason === "CrashLoopBackOff") {
throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`);
}
if (waiting?.reason === "MountVolumeFailed" || waiting?.reason === "ContainerCannotMount") {
throw new Error(`Volume mount failed for "${cs.name}": ${waiting.message ?? waiting.reason}`);
}
}
for (const cs of containerStatuses) {
const terminated = cs.state?.terminated;
if (terminated?.exitCode !== undefined && terminated.exitCode !== 0) {
if (terminated.reason === "ContainerCannotMount" || terminated.reason === "MountVolumeFailed") {
throw new Error(`Volume mount failed for "${cs.name}": ${terminated.message ?? terminated.reason}`);
}
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
+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,
+11
View File
@@ -44,6 +44,17 @@ describe("buildJobManifest", () => {
expect(container?.image).toBe("paperclip/paperclip:latest");
});
it("uses config.image when provided, overriding selfPod image", () => {
const ctxWithImage = {
...mockCtx,
config: { image: "my-custom-image:v1.2.3" },
};
const result = buildJobManifest({ ctx: ctxWithImage, selfPod: mockSelfPod });
const container = result.job.spec?.template?.spec?.containers?.[0];
expect(container?.image).toBe("my-custom-image:v1.2.3");
});
it("sets fsGroupChangePolicy to OnRootMismatch", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execMock = 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);
},
}));
const { listK8sModels, STATIC_MODELS } = await import("./models.js");
function mockExecResult(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(null, { stdout, stderr: "" });
});
}
function mockExecError(err: Error) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(err, { stdout: "", stderr: "" });
});
}
describe("listK8sModels", () => {
beforeEach(() => {
execMock.mockReset();
});
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"),
);
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" });
});
it("ignores blank lines and trims whitespace", async () => {
mockExecResult("\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",
]);
});
it("invokes opencode models with a timeout", async () => {
mockExecResult("anthropic/claude-opus-4-7");
await listK8sModels();
expect(execMock).toHaveBeenCalledTimes(1);
const [cmd, opts] = execMock.mock.calls[0];
expect(cmd).toBe("opencode models");
expect(opts).toMatchObject({ timeout: 30_000 });
});
it("falls back to the static list when the CLI fails", async () => {
mockExecError(new Error("ENOENT: opencode not found"));
const models = 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);
});
});
+25 -2
View File
@@ -1,15 +1,38 @@
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { exec } from "child_process";
import { promisify } from "util";
const MODELS: AdapterModel[] = [
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[]> {
return MODELS;
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;
} catch {
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");
});
});