Skip to content

Commit eb5129b

Browse files
authored
Implement OpenCode agent provider (#395)
1 parent 7cacdfb commit eb5129b

6 files changed

Lines changed: 324 additions & 14 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createOpencodeProvider, type CommandRunner } from "../provider";
3+
4+
describe("opencode provider", () => {
5+
it("runs opencode with prompt context and configured flags", async () => {
6+
const calls: Array<{ command: string; args: string[]; cwd?: string; timeoutMs: number }> = [];
7+
const runner: CommandRunner = vi.fn(async (command, args, options) => {
8+
calls.push({ command, args, cwd: options.cwd, timeoutMs: options.timeoutMs });
9+
return { stdout: "done\n", stderr: "", exitCode: 0 };
10+
});
11+
12+
const provider = createOpencodeProvider({
13+
runner,
14+
env: {
15+
OPENCODE_BIN: "opencode",
16+
OPENCODE_MODEL: "anthropic/claude-sonnet-4",
17+
OPENCODE_AGENT: "build",
18+
OPENCODE_ATTACH: "http://127.0.0.1:4096",
19+
OPENCODE_DIR: "/tmp/project",
20+
OPENCODE_TIMEOUT_MS: "45000",
21+
},
22+
});
23+
24+
await expect(provider.chat({
25+
messages: [
26+
{ role: "system", content: "Be concise." },
27+
{ role: "user", content: "Explain the deploy plan." },
28+
],
29+
})).resolves.toEqual({ content: "done" });
30+
31+
expect(calls).toEqual([{
32+
command: "opencode",
33+
args: [
34+
"run",
35+
"--model",
36+
"anthropic/claude-sonnet-4",
37+
"--agent",
38+
"build",
39+
"--attach",
40+
"http://127.0.0.1:4096",
41+
"--dir",
42+
"/tmp/project",
43+
"SYSTEM:\nBe concise.\n\nUSER:\nExplain the deploy plan.",
44+
],
45+
cwd: "/tmp/project",
46+
timeoutMs: 45000,
47+
}]);
48+
});
49+
50+
it("rejects empty chat requests before running the CLI", async () => {
51+
const runner: CommandRunner = vi.fn();
52+
const provider = createOpencodeProvider({ runner, env: {} });
53+
54+
await expect(provider.chat({ messages: [] })).rejects.toThrow("requires at least one message");
55+
expect(runner).not.toHaveBeenCalled();
56+
});
57+
58+
it("lists models from opencode output", async () => {
59+
const runner: CommandRunner = vi.fn(async () => ({
60+
stdout: "anthropic/claude-sonnet-4\nopenai/gpt-4.1\nopenai/gpt-4.1\n",
61+
stderr: "",
62+
exitCode: 0,
63+
}));
64+
const provider = createOpencodeProvider({
65+
runner,
66+
env: { OPENCODE_MODELS_PROVIDER: "anthropic" },
67+
});
68+
69+
await expect(provider.listModels()).resolves.toEqual([
70+
"anthropic/claude-sonnet-4",
71+
"openai/gpt-4.1",
72+
]);
73+
expect(runner).toHaveBeenCalledWith("opencode", ["models", "anthropic"], expect.any(Object));
74+
});
75+
76+
it("reports CLI availability through healthcheck", async () => {
77+
const runner: CommandRunner = vi.fn(async () => ({
78+
stdout: "opencode 0.7.1\n",
79+
stderr: "",
80+
exitCode: 0,
81+
}));
82+
const provider = createOpencodeProvider({ runner, env: {} });
83+
84+
await expect(provider.healthcheck()).resolves.toEqual({
85+
ok: true,
86+
message: "opencode 0.7.1",
87+
});
88+
expect(runner).toHaveBeenCalledWith("opencode", ["--version"], expect.any(Object));
89+
});
90+
91+
it("redacts sensitive values from CLI failures", async () => {
92+
const runner: CommandRunner = vi.fn(async () => ({
93+
stdout: "",
94+
stderr: "auth failed for secret-pass",
95+
exitCode: 1,
96+
}));
97+
const provider = createOpencodeProvider({
98+
runner,
99+
env: { OPENCODE_SERVER_PASSWORD: "secret-pass" },
100+
});
101+
102+
await expect(provider.chat({
103+
messages: [{ role: "user", content: "hello" }],
104+
})).rejects.toThrow("auth failed for [redacted]");
105+
});
106+
107+
it("validates timeout configuration", () => {
108+
const provider = createOpencodeProvider();
109+
110+
expect(() => provider.validateEnv({ OPENCODE_TIMEOUT_MS: "0" })).toThrow("positive integer");
111+
expect(() => provider.validateEnv({ OPENCODE_TIMEOUT_MS: "15000" })).not.toThrow();
112+
});
113+
});
Lines changed: 207 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,227 @@
11
import {
2-
AgentProviderAdapter,
3-
AgentProviderNotImplementedError,
2+
AgentProviderConfigError,
3+
AgentProviderRequestError,
44
} from "@profullstack/sh1pt-agent-provider-shared";
5+
import type { AgentProviderAdapter } from "@profullstack/sh1pt-agent-provider-shared";
6+
import { execFile } from "node:child_process";
7+
import { promisify } from "node:util";
58

6-
export const opencodeProvider: AgentProviderAdapter = {
9+
const execFileAsync = promisify(execFile);
10+
11+
export interface CommandResult {
12+
stdout: string;
13+
stderr: string;
14+
exitCode: number;
15+
}
16+
17+
export interface CommandRunnerOptions {
18+
cwd?: string;
19+
env: Record<string, string | undefined>;
20+
timeoutMs: number;
21+
}
22+
23+
export type CommandRunner = (
24+
command: string,
25+
args: string[],
26+
options: CommandRunnerOptions,
27+
) => Promise<CommandResult>;
28+
29+
export interface OpencodeProviderOptions {
30+
env?: Record<string, string | undefined>;
31+
runner?: CommandRunner;
32+
}
33+
34+
interface OpencodeConfig {
35+
bin: string;
36+
model?: string;
37+
agent?: string;
38+
attach?: string;
39+
dir?: string;
40+
modelsProvider?: string;
41+
timeoutMs: number;
42+
}
43+
44+
export function createOpencodeProvider(options: OpencodeProviderOptions = {}): AgentProviderAdapter {
45+
const env = options.env ?? process.env;
46+
const runner = options.runner ?? runCommand;
47+
48+
return {
749
id: "opencode",
850
displayName: "OpenCode",
951
capabilities: { chat: true },
1052

1153
getRequiredEnv() {
12-
return [];
54+
return [
55+
{ key: "OPENCODE_BIN", required: false },
56+
{ key: "OPENCODE_MODEL", required: false },
57+
{ key: "OPENCODE_AGENT", required: false },
58+
{ key: "OPENCODE_ATTACH", required: false },
59+
{ key: "OPENCODE_DIR", required: false },
60+
{ key: "OPENCODE_MODELS_PROVIDER", required: false },
61+
{ key: "OPENCODE_TIMEOUT_MS", required: false },
62+
];
1363
},
1464

15-
validateEnv() {
16-
// no-op for now
65+
validateEnv(candidateEnv) {
66+
resolveConfig(candidateEnv);
1767
},
1868

1969
async listModels() {
20-
throw new AgentProviderNotImplementedError("opencode.listModels");
70+
const config = resolveConfig(env);
71+
const args = ["models"];
72+
if (config.modelsProvider) args.push(config.modelsProvider);
73+
const result = await runOpencode(config, runner, args, env);
74+
return parseModels(result.stdout);
2175
},
2276

23-
async chat() {
24-
throw new AgentProviderNotImplementedError("opencode.chat");
77+
async chat(req) {
78+
const config = resolveConfig(env);
79+
const prompt = renderPrompt(req.messages);
80+
if (!prompt) {
81+
throw new AgentProviderConfigError("opencode.chat requires at least one message with content");
82+
}
83+
84+
const args = ["run"];
85+
if (config.model) args.push("--model", config.model);
86+
if (config.agent) args.push("--agent", config.agent);
87+
if (config.attach) args.push("--attach", config.attach);
88+
if (config.dir) args.push("--dir", config.dir);
89+
args.push(prompt);
90+
91+
const result = await runOpencode(config, runner, args, env);
92+
const content = result.stdout.trim();
93+
if (!content) throw new AgentProviderRequestError("OpenCode CLI returned an empty response");
94+
return { content };
2595
},
2696

2797
async healthcheck() {
28-
throw new AgentProviderNotImplementedError("opencode.healthcheck");
98+
const config = resolveConfig(env);
99+
const result = await runOpencode(config, runner, ["--version"], env);
100+
return { ok: true, message: result.stdout.trim() || "opencode available" };
29101
},
30102
};
103+
}
104+
105+
export const opencodeProvider = createOpencodeProvider();
106+
107+
function resolveConfig(env: Record<string, string | undefined>): OpencodeConfig {
108+
const timeoutMs = parseTimeout(env.OPENCODE_TIMEOUT_MS);
109+
return {
110+
bin: env.OPENCODE_BIN?.trim() || "opencode",
111+
model: nonEmpty(env.OPENCODE_MODEL),
112+
agent: nonEmpty(env.OPENCODE_AGENT),
113+
attach: nonEmpty(env.OPENCODE_ATTACH),
114+
dir: nonEmpty(env.OPENCODE_DIR),
115+
modelsProvider: nonEmpty(env.OPENCODE_MODELS_PROVIDER),
116+
timeoutMs,
117+
};
118+
}
119+
120+
function parseTimeout(value: string | undefined): number {
121+
if (!value) return 120_000;
122+
const parsed = Number.parseInt(value, 10);
123+
if (!Number.isFinite(parsed) || parsed <= 0) {
124+
throw new AgentProviderConfigError("OPENCODE_TIMEOUT_MS must be a positive integer");
125+
}
126+
return parsed;
127+
}
128+
129+
function nonEmpty(value: string | undefined): string | undefined {
130+
const trimmed = value?.trim();
131+
return trimmed ? trimmed : undefined;
132+
}
133+
134+
function renderPrompt(messages: { role: string; content: string }[]): string {
135+
return messages
136+
.map((message) => {
137+
const content = message.content.trim();
138+
if (!content) return "";
139+
return `${message.role.toUpperCase()}:\n${content}`;
140+
})
141+
.filter(Boolean)
142+
.join("\n\n");
143+
}
144+
145+
async function runOpencode(
146+
config: OpencodeConfig,
147+
runner: CommandRunner,
148+
args: string[],
149+
env: Record<string, string | undefined>,
150+
): Promise<CommandResult> {
151+
try {
152+
const result = await runner(config.bin, args, {
153+
cwd: config.dir,
154+
env: { ...process.env, ...env },
155+
timeoutMs: config.timeoutMs,
156+
});
157+
if (result.exitCode !== 0) {
158+
const detail = formatFailure(result, env);
159+
throw new AgentProviderRequestError(`OpenCode CLI exited ${result.exitCode}${detail}`);
160+
}
161+
return result;
162+
} catch (error) {
163+
if (error instanceof AgentProviderRequestError) throw error;
164+
const result = commandErrorResult(error);
165+
const detail = formatFailure(result, env);
166+
throw new AgentProviderRequestError(`OpenCode CLI failed${detail}`);
167+
}
168+
}
169+
170+
async function runCommand(
171+
command: string,
172+
args: string[],
173+
options: CommandRunnerOptions,
174+
): Promise<CommandResult> {
175+
const result = await execFileAsync(resolveExecutable(command), args, {
176+
cwd: options.cwd,
177+
env: options.env,
178+
timeout: options.timeoutMs,
179+
maxBuffer: 1024 * 1024,
180+
windowsHide: true,
181+
});
182+
return {
183+
stdout: String(result.stdout ?? ""),
184+
stderr: String(result.stderr ?? ""),
185+
exitCode: 0,
186+
};
187+
}
188+
189+
function resolveExecutable(command: string): string {
190+
if (process.platform !== "win32") return command;
191+
if (/[\\/]/.test(command) || /\.(?:bat|cmd|exe)$/i.test(command)) return command;
192+
return `${command}.cmd`;
193+
}
194+
195+
function commandErrorResult(error: unknown): CommandResult {
196+
const candidate = error as { stdout?: unknown; stderr?: unknown; code?: unknown };
197+
return {
198+
stdout: String(candidate.stdout ?? ""),
199+
stderr: String(candidate.stderr ?? ""),
200+
exitCode: typeof candidate.code === "number" ? candidate.code : 1,
201+
};
202+
}
203+
204+
function formatFailure(result: CommandResult, env: Record<string, string | undefined>): string {
205+
const text = sanitizeCliOutput([result.stderr, result.stdout].filter(Boolean).join("\n"), env);
206+
return text ? `: ${text}` : "";
207+
}
208+
209+
function sanitizeCliOutput(text: string, env: Record<string, string | undefined>): string {
210+
let sanitized = text.replace(/(Bearer\s+)[^\s]+/gi, "$1[redacted]");
211+
for (const key of ["OPENCODE_SERVER_PASSWORD", "OPENCODE_API_KEY"]) {
212+
const value = env[key];
213+
if (value && value.length >= 4) {
214+
sanitized = sanitized.split(value).join("[redacted]");
215+
}
216+
}
217+
return sanitized.trim().slice(0, 1000);
218+
}
219+
220+
function parseModels(stdout: string): string[] {
221+
const models = new Set<string>();
222+
for (const line of stdout.split(/\r?\n/)) {
223+
const matches = line.matchAll(/\b[a-z0-9][a-z0-9_.-]*\/[a-zA-Z0-9][a-zA-Z0-9_.:/+-]*/g);
224+
for (const match of matches) models.add(match[0]);
225+
}
226+
return [...models].sort();
227+
}

packages/agent-providers/opencode/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "../../../tsconfig.json",
2+
"extends": "../../../tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "dist"
55
},

packages/agent-providers/openrouter/src/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
2-
AgentProviderAdapter,
32
AgentProviderNotImplementedError,
43
AgentProviderConfigError,
54
} from "@profullstack/sh1pt-agent-provider-shared";
5+
import type { AgentProviderAdapter } from "@profullstack/sh1pt-agent-provider-shared";
66

77
export const openrouterProvider: AgentProviderAdapter = {
88
id: "openrouter",

packages/agent-providers/shared/src/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AgentProviderAdapter } from "./types";
1+
import type { AgentProviderAdapter } from "./types";
22

33
const registry = new Map<string, AgentProviderAdapter>();
44

packages/agent-providers/shared/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface AgentProviderAdapter {
3737
getRequiredEnv(): AgentProviderEnvRequirement[];
3838
validateEnv(env: Record<string, string | undefined>): void;
3939

40-
listModels(): Promise<never>;
40+
listModels(): Promise<string[]>;
4141
chat(req: AgentProviderChatRequest): Promise<AgentProviderChatResponse>;
4242
healthcheck(): Promise<AgentProviderHealthcheckResult>;
4343
}

0 commit comments

Comments
 (0)