Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da1b55d233 | |||
| 168161148c | |||
| 2daedda537 | |||
| e364e09113 | |||
| 4c956cc039 | |||
| 4fcd3b4547 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.32",
|
||||
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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 } = 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.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");
|
||||
});
|
||||
});
|
||||
+29
-11
@@ -1,15 +1,33 @@
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const MODELS: 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" },
|
||||
];
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export async function listK8sModels(): Promise<AdapterModel[]> {
|
||||
return MODELS;
|
||||
}
|
||||
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 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 });
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user