RSS Git Download  Clone
Raw Blame History 3kB 79 lines
import { spawn } from 'node:child_process';
import { zodToJsonSchema } from './schema.mjs';

// Maps Anthropic model IDs (used in CLAUDE.md / .env) to claude CLI --model values.
// claude CLI accepts aliases ('opus', 'sonnet', 'haiku') or full IDs.
function resolveModel(model) {
    if (!model) return 'opus';
    if (/^(opus|sonnet|haiku)$/i.test(model)) return model.toLowerCase();
    if (model.startsWith('claude-opus')) return 'opus';
    if (model.startsWith('claude-sonnet')) return 'sonnet';
    if (model.startsWith('claude-haiku')) return 'haiku';
    return model;
}

function spawnAndCollect(cmd, args, { input } = {}) {
    return new Promise((resolve, reject) => {
        const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
        let stdout = '';
        let stderr = '';
        child.stdout.on('data', (d) => { stdout += d.toString(); });
        child.stderr.on('data', (d) => { stderr += d.toString(); });
        child.on('error', reject);
        child.on('close', (code) => {
            if (code !== 0) {
                const msg = stderr.trim() || stdout.trim() || `${cmd} exited ${code}`;
                reject(new Error(`${cmd} exited ${code}: ${msg.slice(0, 1000)}`));
                return;
            }
            resolve({ stdout, stderr });
        });
        if (input != null) child.stdin.write(input);
        child.stdin.end();
    });
}

export async function callAnthropic({ system, user, schema, schemaName, model = process.env.ANTHROPIC_MODEL || 'opus' }) {
    const cliModel = resolveModel(model);
    const jsonSchema = JSON.stringify(zodToJsonSchema(schema));

    const args = [
        '--print',
        '--output-format', 'json',
        '--json-schema', jsonSchema,
        '--model', cliModel,
        '--exclude-dynamic-system-prompt-sections',
    ];
    if (system) args.push('--system-prompt', system);

    const { stdout } = await spawnAndCollect('claude', args, { input: user });

    let envelope;
    try {
        envelope = JSON.parse(stdout);
    } catch (err) {
        throw new Error(`claude CLI returned non-JSON output (first 500 chars): ${stdout.slice(0, 500)}`);
    }

    if (envelope.is_error || envelope.subtype !== 'success') {
        throw new Error(`claude CLI error: ${envelope.api_error_status ?? envelope.subtype} — ${envelope.result?.slice?.(0, 500) ?? ''}`);
    }

    const raw = envelope.structured_output;
    if (raw == null) {
        throw new Error(`claude CLI gave no structured_output. Raw result: ${envelope.result?.slice?.(0, 500) ?? '(empty)'}`);
    }
    const data = schema.parse(raw);

    const u = envelope.usage ?? {};
    const usage = {
        input: (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0),
        output: u.output_tokens ?? 0,
        usd: 0, // subscription — no per-call cost
        model: cliModel,
        provider: 'claude-cli',
    };

    return { data, usage };
}