Files
flynn/src/backends/external.ts
T

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 || '');
});
});
}