116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
import { execFile } from 'child_process';
|
|
|
|
export type ExternalBackendName = 'claude_code' | 'opencode' | 'codex' | 'gemini' | 'pi_embedded';
|
|
|
|
export interface ExternalBackendRequest {
|
|
prompt: string;
|
|
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
|
}
|
|
|
|
export interface ExternalBackend {
|
|
name: ExternalBackendName;
|
|
process(input: ExternalBackendRequest): Promise<string>;
|
|
}
|
|
|
|
interface ExternalCliBackendOptions {
|
|
name: ExternalBackendName;
|
|
command: string;
|
|
args?: string[];
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
|
|
function buildPrompt(request: ExternalBackendRequest): string {
|
|
const lines: string[] = [];
|
|
for (const item of request.history) {
|
|
if (!item.content.trim()) {
|
|
continue;
|
|
}
|
|
lines.push(`${item.role.toUpperCase()}: ${item.content}`);
|
|
}
|
|
lines.push(`USER: ${request.prompt}`);
|
|
return lines.join('\n\n');
|
|
}
|
|
|
|
function inferArgs(name: ExternalBackendName, prompt: string): string[] {
|
|
if (name === 'claude_code') {
|
|
return ['--print', prompt];
|
|
}
|
|
if (name === 'opencode') {
|
|
return ['run', '--format', 'default', prompt];
|
|
}
|
|
return ['-p', prompt];
|
|
}
|
|
|
|
export class ExternalCliBackend implements ExternalBackend {
|
|
readonly name: ExternalBackendName;
|
|
private readonly command: string;
|
|
private readonly args: string[];
|
|
private readonly timeoutMs: number;
|
|
|
|
constructor(options: ExternalCliBackendOptions) {
|
|
this.name = options.name;
|
|
this.command = options.command;
|
|
this.args = options.args ?? [];
|
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
}
|
|
|
|
async process(input: ExternalBackendRequest): Promise<string> {
|
|
const prompt = buildPrompt(input);
|
|
const configuredArgs = this.args
|
|
.map((arg) => arg.includes('{prompt}') ? arg.replaceAll('{prompt}', prompt) : arg);
|
|
const hasPromptPlaceholder = this.args.some((arg) => arg.includes('{prompt}'));
|
|
const args = hasPromptPlaceholder
|
|
? configuredArgs
|
|
: [...configuredArgs, ...inferArgs(this.name, prompt)];
|
|
|
|
const output = await execFileAsync(this.command, args, this.timeoutMs);
|
|
const trimmed = output.trim();
|
|
if (!trimmed) {
|
|
throw new Error(`External backend "${this.name}" returned no output`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
export class ClaudeCodeBackend extends ExternalCliBackend {
|
|
constructor(path?: string, args?: string[], timeoutMs?: number) {
|
|
super({ name: 'claude_code', command: path ?? 'claude', args, timeoutMs });
|
|
}
|
|
}
|
|
|
|
export class OpenCodeBackend extends ExternalCliBackend {
|
|
constructor(path?: string, args?: string[], timeoutMs?: number) {
|
|
super({ name: 'opencode', command: path ?? 'opencode', args, timeoutMs });
|
|
}
|
|
}
|
|
|
|
export class CodexBackend extends ExternalCliBackend {
|
|
constructor(path?: string, args?: string[], timeoutMs?: number) {
|
|
super({ name: 'codex', command: path ?? 'codex', args, timeoutMs });
|
|
}
|
|
}
|
|
|
|
export class GeminiBackend extends ExternalCliBackend {
|
|
constructor(path?: string, args?: string[], timeoutMs?: number) {
|
|
super({ name: 'gemini', command: path ?? 'gemini', args, timeoutMs });
|
|
}
|
|
}
|
|
|
|
function execFileAsync(command: string, args: string[], timeoutMs: number): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(command, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
|
if (error) {
|
|
const details = [stderr, stdout]
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0)
|
|
.join('\n');
|
|
reject(new Error(details ? `${error.message}\n${details}` : error.message));
|
|
return;
|
|
}
|
|
resolve(stdout || '');
|
|
});
|
|
});
|
|
}
|