| 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 |