Add OpenCode provider integration and strict model selection

This commit is contained in:
Konan69
2026-03-05 15:24:20 +01:00
parent c7c96feef7
commit 6a101e0da1
55 changed files with 2225 additions and 104 deletions

View File

@@ -1,12 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
describe("adapter model listing", () => {
beforeEach(() => {
delete process.env.OPENAI_API_KEY;
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
resetCodexModelsCacheForTests();
resetOpenCodeModelsCacheForTests();
vi.restoreAllMocks();
});
@@ -55,4 +58,11 @@ describe("adapter model listing", () => {
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
});
it("returns no opencode models when opencode command is unavailable", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const models = await listAdapterModels("opencode_local");
expect(models).toEqual([]);
});
});

View File

@@ -19,6 +19,16 @@ import {
agentConfigurationDoc as openclawAgentConfigurationDoc,
models as openclawModels,
} from "@paperclipai/adapter-openclaw";
import {
execute as openCodeExecute,
testEnvironment as openCodeTestEnvironment,
sessionCodec as openCodeSessionCodec,
listOpenCodeModels,
} from "@paperclipai/adapter-opencode-local/server";
import {
agentConfigurationDoc as openCodeAgentConfigurationDoc,
models as openCodeModels,
} from "@paperclipai/adapter-opencode-local";
import { listCodexModels } from "./codex-models.js";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
@@ -53,8 +63,21 @@ const openclawAdapter: ServerAdapterModule = {
agentConfigurationDoc: openclawAgentConfigurationDoc,
};
const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
sessionCodec: openCodeSessionCodec,
models: openCodeModels,
listModels: listOpenCodeModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: openCodeAgentConfigurationDoc,
};
const adaptersByType = new Map<string, ServerAdapterModule>(
[claudeLocalAdapter, codexLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map(
(a) => [a.type, a],
),
);
export function getServerAdapter(type: string): ServerAdapterModule {

View File

@@ -36,11 +36,13 @@ import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath",
opencode_local: "instructionsFilePath",
};
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
@@ -193,6 +195,27 @@ export function agentRoutes(db: Db) {
return next;
}
async function assertAdapterConfigConstraints(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: runtimeConfig.model,
command: runtimeConfig.command,
cwd: runtimeConfig.cwd,
env: runtimeEnv,
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
}
}
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
const trimmed = candidatePath.trim();
if (path.isAbsolute(trimmed)) return trimmed;
@@ -324,7 +347,9 @@ export function agentRoutes(db: Db) {
}
});
router.get("/adapters/:type/models", async (req, res) => {
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const type = req.params.type as string;
const models = await listAdapterModels(type);
res.json(models);
@@ -578,6 +603,11 @@ export function agentRoutes(db: Db) {
requestedAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
hireInput.adapterType,
normalizedAdapterConfig,
);
const normalizedHireInput = {
...hireInput,
adapterConfig: normalizedAdapterConfig,
@@ -713,6 +743,11 @@ export function agentRoutes(db: Db) {
requestedAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
req.body.adapterType,
normalizedAdapterConfig,
);
const agent = await svc.create(companyId, {
...req.body,
@@ -892,6 +927,22 @@ export function agentRoutes(db: Db) {
);
}
const requestedAdapterType =
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
const touchesAdapterConfiguration =
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {});
await assertAdapterConfigConstraints(
existing.companyId,
requestedAdapterType,
effectiveAdapterConfig,
);
}
const actor = getActorInfo(req);
const agent = await svc.update(id, patchData, {
recordRevision: {