/** * Claude Code provider — OAuth via CLAUDE_CODE_OAUTH_TOKEN. * * Spawns the `claude` CLI as subprocess (same pattern as scripts/02-vision-page.py). * The CLI reads CLAUDE_CODE_OAUTH_TOKEN from env and uses it instead of the * credentials file. NEVER uses ANTHROPIC_API_KEY — this project forbids it. * * Requires the `claude` binary in PATH. In Docker, install with: * RUN curl -fsSL https://claude.ai/install.sh | bash */ import { spawn } from "node:child_process"; import type { ChatProvider, ChatRequest, ChatResponse } from "./types"; const MODEL = process.env.CLAUDE_CODE_MODEL || "haiku"; const TIMEOUT_MS = 90_000; function buildPrompt(req: ChatRequest): string { // Single-shot prompt: collapse history into a structured transcript. const parts: string[] = []; parts.push(req.system); parts.push("\n\n# CONVERSATION HISTORY\n"); const recent = req.messages.slice(-20); for (const m of recent.slice(0, -1)) { parts.push(`${m.role.toUpperCase()}: ${m.content}`); } const last = recent[recent.length - 1]; if (last) parts.push(`\nUSER ASKS NOW: ${last.content}`); return parts.join("\n"); } export const claudeCodeProvider: ChatProvider = { name: "claude-code", isAvailable: () => Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN), async send(req: ChatRequest): Promise { const prompt = buildPrompt(req); const t0 = Date.now(); const text = await new Promise<{ result: string; durationMs: number; tokensIn?: number; tokensOut?: number; costUsd?: number }>((resolve, reject) => { const child = spawn( "claude", [ "-p", "--model", MODEL, "--output-format", "json", "--max-turns", "3", "--", prompt, ], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, // forwards CLAUDE_CODE_OAUTH_TOKEN }, ); let stdout = ""; let stderr = ""; child.stdout.on("data", (c) => (stdout += c.toString())); child.stderr.on("data", (c) => (stderr += c.toString())); const t = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`claude CLI timeout > ${TIMEOUT_MS}ms`)); }, TIMEOUT_MS); child.on("error", (e) => { clearTimeout(t); reject(new Error(`claude CLI spawn failed: ${e.message}`)); }); child.on("close", (code) => { clearTimeout(t); if (code !== 0) { // Detect rate limit shape — surface a typed error const msg = stderr.slice(-500) || `rc=${code}`; const err = new Error(`claude-code rc=${code}: ${msg}`); if (/usage limit|rate.?limit|429/i.test(stderr)) { (err as Error & { isRateLimit?: boolean }).isRateLimit = true; } return reject(err); } try { const cli = JSON.parse(stdout); if (cli.is_error) { return reject(new Error(`claude-code reported error: ${cli.result?.slice(0, 300)}`)); } resolve({ result: cli.result || "", durationMs: cli.duration_ms || Date.now() - t0, tokensIn: cli.usage?.input_tokens, tokensOut: cli.usage?.output_tokens, costUsd: cli.total_cost_usd, }); } catch (e) { reject(new Error(`claude-code stdout parse: ${e instanceof Error ? e.message : String(e)}`)); } }); }); return { provider: "claude-code", model: MODEL, content: text.result, tokensIn: text.tokensIn, tokensOut: text.tokensOut, costUsd: text.costUsd, durationMs: text.durationMs, }; }, };