Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7043e71ff6 | |||
| da1b55d233 | |||
| 168161148c | |||
| 2daedda537 | |||
| e364e09113 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.33",
|
||||
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+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));
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user