Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da1b55d233 | |||
| 168161148c | |||
| 2daedda537 | |||
| e364e09113 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperclip-adapter-opencode-k8s",
|
"name": "paperclip-adapter-opencode-k8s",
|
||||||
"version": "0.1.31",
|
"version": "0.1.32",
|
||||||
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+17
-1
@@ -126,7 +126,11 @@ async function waitForPod(
|
|||||||
(c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable",
|
(c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable",
|
||||||
);
|
);
|
||||||
if (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) {
|
for (const cs of containerStatuses) {
|
||||||
@@ -137,6 +141,18 @@ async function waitForPod(
|
|||||||
if (waiting?.reason === "CrashLoopBackOff") {
|
if (waiting?.reason === "CrashLoopBackOff") {
|
||||||
throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`);
|
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));
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ describe("buildJobManifest", () => {
|
|||||||
expect(container?.image).toBe("paperclip/paperclip:latest");
|
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", () => {
|
it("sets fsGroupChangePolicy to OnRootMismatch", () => {
|
||||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
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 type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
const MODELS: AdapterModel[] = [
|
const execAsync = promisify(exec);
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function listK8sModels(): Promise<AdapterModel[]> {
|
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