返回 html-video
anthropic-api.ts
根目录 / packages / runtime / src / defs / anthropic-api.ts
1 /**
2 * Anthropic Messages API (HTTP) agent.
3 *
4 * Bypasses the `claude --print` CLI entirely — that interface has a
5 * non-deterministic "silently return 1 byte" failure mode on long creative
6 * outputs (verified by hand on Joey's machine). Talking to the Messages API
7 * directly is reliable and stream-friendly.
8 *
9 * Auth resolution (first match wins):
10 * 1. ANTHROPIC_API_KEY (canonical)
11 * 2. ANTHROPIC_AUTH_TOKEN (Joey's OpenRouter routing setup)
12 *
13 * Base URL:
14 * ANTHROPIC_BASE_URL or default https://api.anthropic.com
15 * When OpenRouter is in use, ANTHROPIC_BASE_URL is set to
16 * https://openrouter.ai/api — the OpenRouter shim accepts the standard
17 * Anthropic Messages payload + bare model names (claude-sonnet-4-6 etc).
18 *
19 * Model: claude-sonnet-4-6 by default. Sonnet 4.6 strikes the best balance
20 * of speed / creativity / instruction-following for our HTML output;
21 * upgradable per-call later if needed.
22 */
23 import type { AgentDef, AgentEvent } from '../types.js';
24
25 const DEFAULT_MODEL = 'claude-sonnet-4-6';
26 const DEFAULT_BASE = 'https://api.anthropic.com';
27 const HV_MODEL_ENV = 'HV_AGENT_MODEL';
28
29 function resolveAuth(): { token: string; baseUrl: string; model: string } | null {
30 const token = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || '';
31 if (!token) return null;
32 const baseUrl = (process.env.ANTHROPIC_BASE_URL || DEFAULT_BASE).replace(/\/+$/, '');
33 const model = process.env[HV_MODEL_ENV] || DEFAULT_MODEL;
34 return { token, baseUrl, model };
35 }
36
37 export const anthropicApi: AgentDef = {
38 id: 'anthropic-api',
39 name: 'Anthropic API (direct)',
40 bin: 'anthropic-api',
41 versionArgs: [],
42 buildArgs: () => [],
43 streamFormat: 'plain',
44 kind: 'http',
45 installUrl: 'https://docs.claude.com/en/api/getting-started',
46
47 async httpProbe() {
48 const auth = resolveAuth();
49 if (!auth) {
50 return {
51 available: false,
52 hint: 'Set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). For OpenRouter routing also set ANTHROPIC_BASE_URL=https://openrouter.ai/api.',
53 };
54 }
55 return {
56 available: true,
57 version: `${auth.model} via ${new URL(auth.baseUrl).host}`,
58 };
59 },
60
61 async httpHandler(prompt, _ctx, onEvent, signal) {
62 const auth = resolveAuth();
63 if (!auth) {
64 onEvent({ type: 'error', message: 'No ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN in env' });
65 return { exitCode: -1 };
66 }
67
68 // OpenRouter expects bearer tokens via Authorization. The Anthropic
69 // direct API uses x-api-key. We try x-api-key first (works on both)
70 // and let the host respond. OpenRouter accepts x-api-key too.
71 const headers: Record<string, string> = {
72 'content-type': 'application/json',
73 'anthropic-version': '2023-06-01',
74 'x-api-key': auth.token,
75 authorization: `Bearer ${auth.token}`,
76 };
77
78 const url = `${auth.baseUrl}/v1/messages`;
79 let res: Response;
80 try {
81 res = await fetch(url, {
82 method: 'POST',
83 headers,
84 signal,
85 body: JSON.stringify({
86 model: auth.model,
87 max_tokens: 16000,
88 stream: true,
89 messages: [{ role: 'user', content: prompt }],
90 }),
91 });
92 } catch (err) {
93 const msg = err instanceof Error ? err.message : String(err);
94 onEvent({ type: 'error', message: `fetch failed: ${msg}` });
95 return { exitCode: -1 };
96 }
97
98 if (!res.ok || !res.body) {
99 const body = await res.text().catch(() => '');
100 onEvent({
101 type: 'error',
102 message: `${res.status} ${res.statusText}${body ? `: ${body.slice(0, 400)}` : ''}`,
103 });
104 return { exitCode: -1 };
105 }
106
107 // Anthropic SSE format: event: <type>\ndata: <json>\n\n
108 // Events we care about:
109 // content_block_delta { delta: { type: 'text_delta', text } } → emit text
110 // message_stop → done
111 // error → emit error
112 const reader = res.body.getReader();
113 const decoder = new TextDecoder();
114 let buf = '';
115 try {
116 while (true) {
117 const { done, value } = await reader.read();
118 if (done) break;
119 buf += decoder.decode(value, { stream: true });
120 const events = buf.split('\n\n');
121 buf = events.pop() ?? '';
122 for (const ev of events) {
123 let dataLine = '';
124 for (const line of ev.split('\n')) {
125 if (line.startsWith('data:')) dataLine = line.slice(5).trim();
126 }
127 if (!dataLine || dataLine === '[DONE]') continue;
128 try {
129 const parsed = JSON.parse(dataLine) as { type?: string; delta?: { type?: string; text?: string }; error?: { message?: string } };
130 if (parsed.type === 'content_block_delta' && parsed.delta?.type === 'text_delta' && parsed.delta.text) {
131 onEvent({ type: 'text', chunk: parsed.delta.text });
132 } else if (parsed.type === 'error' && parsed.error?.message) {
133 onEvent({ type: 'error', message: parsed.error.message });
134 }
135 // ignore message_start / content_block_start / message_delta / ping etc
136 } catch {
137 /* malformed line — skip */
138 }
139 }
140 }
141 } catch (err) {
142 const msg = err instanceof Error ? err.message : String(err);
143 // AbortError is expected when the user cancels; don't log it as a hard error
144 if (msg !== 'BodyStreamBuffer was aborted' && !msg.includes('aborted')) {
145 onEvent({ type: 'error', message: `stream read failed: ${msg}` });
146 }
147 return { exitCode: -1 };
148 }
149 return { exitCode: 0 };
150 },
151 };
152
152 lines TYPESCRIPT