105 lines
3.6 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
};
|