disclosure-bureau/web/lib/chat/claude-code.ts

105 lines
3.6 KiB
TypeScript

/**
* 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<ChatResponse> {
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,
};
},
};