1
11
12import type {
13 ChatChunk,
14 ChatMessage,
15 ChatRequest,
16 ChatResult,
17 EmbedRequest,
18 EmbedResult,
19 ModelInfo,
20 ProviderAdapter,
21 ToolCall,
22} from "../types.js";
23import { httpJson, ProviderHttpError, sseData, type HttpOptions } from "./http.js";
24
25export interface AnthropicOptions extends HttpOptions {
26 baseUrl?: string;
27 apiKey: string;
28 now?: () => number;
29}
30
31const ANTHROPIC_VERSION = "2023-06-01";
32
33interface AnthropicTextBlock {
34 type: "text";
35 text: string;
36}
37interface AnthropicToolUseBlock {
38 type: "tool_use";
39 id: string;
40 name: string;
41 input: Record<string, unknown>;
42}
43interface AnthropicToolResultPart {
44 type: "tool_result";
45 tool_use_id: string;
46 content: string;
47}
48
49type AnthropicSseEvent = {
50 type: string;
51 message?: { usage?: { input_tokens?: number; output_tokens?: number } };
52 index?: number;
53 content_block?: { type: string; text?: string; id?: string; name?: string };
54 delta?: { type: string; text?: string; partial_json?: string };
55 usage?: { input_tokens?: number; output_tokens?: number };
56 error?: { type?: string; message?: string };
57};
58
59export class AnthropicAdapter implements ProviderAdapter {
60 readonly id = "anthropic" as const;
61 private readonly baseUrl: string;
62 private readonly apiKey: string;
63 private readonly http: HttpOptions;
64 private readonly now: () => number;
65
66 constructor(options: AnthropicOptions) {
67 this.baseUrl = (options.baseUrl ?? "https://api.anthropic.com/v1").replace(/\/+$/, "");
68 this.apiKey = options.apiKey;
69 this.http = {
70 ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
71 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
72 ...(options.idleMs !== undefined ? { idleMs: options.idleMs } : {}),
73 };
74 this.now = options.now ?? Date.now;
75 }
76
77 private headers(): Record<string, string> {
78 return {
79 "content-type": "application/json",
80 "x-api-key": this.apiKey,
81 "anthropic-version": ANTHROPIC_VERSION,
82 };
83 }
84
85 async models(): Promise<ModelInfo[]> {
86
87 const base = {
88 provider: "anthropic" as const,
89 kind: "chat" as const,
90 toolCalls: true,
91 vision: true,
92 pricePer1kIn: 3,
93 pricePer1kOut: 15,
94 dimension: 0,
95 };
96 return [
97 { id: "claude-sonnet-4-20250514", contextWindow: 200_000, maxOutput: 64_000, ...base },
98 { id: "claude-opus-4-20250514", contextWindow: 200_000, maxOutput: 32_000, ...base, pricePer1kIn: 15, pricePer1kOut: 75 },
99 { id: "claude-3-5-haiku-20241022", contextWindow: 200_000, maxOutput: 8_192, ...base, pricePer1kIn: 0.8, pricePer1kOut: 4 },
100 ];
101 }
102
103 async *chat(req: ChatRequest): AsyncIterable<ChatChunk> {
104 const system = extractSystem(req.messages);
105 const messages = req.messages
106 .filter((m) => m.role !== "system")
107 .map(mapMessage);
108
109 const body: Record<string, unknown> = {
110 model: req.model,
111 messages,
112 max_tokens: req.maxTokens ?? 4_096,
113 stream: true,
114 };
115 if (system) body.system = system;
116 if (req.temperature !== undefined) body.temperature = req.temperature;
117 if (req.tools && req.tools.length > 0) {
118 body.tools = req.tools.map((t) => ({
119 name: t.name,
120 description: t.description,
121 input_schema: t.parameters,
122 }));
123 body.tool_choice = mapToolChoice(req.toolChoice);
124 }
125
126 const controller = new AbortController();
127 const timer = setTimeout(() => controller.abort(), this.http.timeoutMs ?? 60_000);
128
129 const onCallerAbort = () => controller.abort();
130 if (req.signal) {
131 if (req.signal.aborted) controller.abort();
132 else req.signal.addEventListener("abort", onCallerAbort, { once: true });
133 }
134 let response: Response;
135 try {
136 const fetchImpl = this.http.fetchImpl ?? fetch;
137 response = await fetchImpl(`${this.baseUrl}/messages`, {
138 method: "POST",
139 headers: this.headers(),
140 body: JSON.stringify(body),
141 signal: controller.signal,
142 });
143 } finally {
144 clearTimeout(timer);
145 if (req.signal) req.signal.removeEventListener("abort", onCallerAbort);
146 }
147 if (!response.ok) {
148 const text = await response.text().catch(() => "");
149 throw new ProviderHttpError(response.status, `HTTP ${response.status}: ${text.slice(0, 200)}`, response.status === 429 || response.status >= 500);
150 }
151
152 let inputTokens = 0;
153 let outputTokens = 0;
154 let stopReason: string | null = null;
155
156 const toolBlocks = new Map<number, { id: string; name: string; args: string }>();
157
158 try {
159 for await (const event of sseData<AnthropicSseEvent>(response, this.http, req.signal)) {
160 if (event.type === "message_start" && event.message?.usage) {
161 inputTokens = event.message.usage.input_tokens ?? 0;
162 } else if (event.type === "content_block_start" && event.content_block && event.content_block.type === "tool_use") {
163 const idx = event.index ?? 0;
164 toolBlocks.set(idx, {
165 id: event.content_block.id ?? `toolu_${idx}`,
166 name: event.content_block.name ?? "",
167 args: "",
168 });
169 } else if (event.type === "content_block_delta" && event.delta) {
170 if (event.delta.type === "text_delta" && event.delta.text) {
171 yield { delta: event.delta.text, done: false };
172 } else if (event.delta.type === "input_json_delta" && event.delta.partial_json) {
173 const idx = event.index ?? 0;
174 const block = toolBlocks.get(idx);
175 if (block) block.args += event.delta.partial_json;
176 }
177 } else if (event.type === "message_delta") {
178 if (event.usage) outputTokens = Math.max(outputTokens, event.usage.output_tokens ?? 0);
179 const stop = (event as { delta?: { stop_reason?: string } }).delta?.stop_reason;
180 if (stop) stopReason = stop;
181 } else if (event.type === "error") {
182 throw new ProviderHttpError(502, `provider error: ${event.error?.message ?? "unknown"}`, true);
183 }
184 }
185 } catch (err) {
186
187
188
189 if (err instanceof ProviderHttpError || (err instanceof Error && err.name === "AbortError")) throw err;
190 throw new ProviderHttpError(502, err instanceof Error ? err.message : "stream failed", true);
191 }
192
193
194 for (const [idx, block] of [...toolBlocks.entries()].sort((a, b) => a[0] - b[0])) {
195 yield {
196 toolCallDelta: { id: block.id, name: block.name, arguments: parseArgs(block.args) },
197 done: false,
198 };
199 }
200 const finalReason: ChatResult["finishReason"] =
201 stopReason === "tool_use" || toolBlocks.size > 0
202 ? "tool_calls"
203 : stopReason === "max_tokens"
204 ? "length"
205 : "stop";
206 yield {
207 usage: { in: inputTokens, out: outputTokens, total: inputTokens + outputTokens },
208 finishReason: finalReason,
209 done: true,
210 };
211 }
212
213
219 async chatCollected(req: ChatRequest): Promise<{ content: string; toolCalls: ToolCall[]; usage: { in: number; out: number }; finishReason: ChatResult["finishReason"] }> {
220 const body: Record<string, unknown> = {
221 model: req.model,
222 messages: req.messages.filter((m) => m.role !== "system").map(mapMessage),
223 max_tokens: req.maxTokens ?? 4_096,
224 };
225 const system = extractSystem(req.messages);
226 if (system) body.system = system;
227 if (req.temperature !== undefined) body.temperature = req.temperature;
228 if (req.tools && req.tools.length > 0) {
229 body.tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }));
230 body.tool_choice = mapToolChoice(req.toolChoice);
231 }
232 const res = await httpJson<{
233 content: Array<AnthropicTextBlock | AnthropicToolUseBlock>;
234 usage: { input_tokens: number; output_tokens: number };
235 stop_reason: string | null;
236 }>(
237 `${this.baseUrl}/messages`,
238 { method: "POST", headers: this.headers(), body: JSON.stringify(body) },
239 this.http,
240 );
241 let content = "";
242 const toolCalls: ToolCall[] = [];
243 for (const block of res.content) {
244 if (block.type === "text") content += block.text;
245 if (block.type === "tool_use") toolCalls.push({ id: block.id, name: block.name, arguments: block.input ?? {} });
246 }
247 return {
248 content,
249 toolCalls,
250 usage: { in: res.usage.input_tokens, out: res.usage.output_tokens },
251 finishReason: mapStopReason(res.stop_reason ?? "end_turn", toolCalls.length > 0),
252 };
253 }
254
255
256 async embed(_req: EmbedRequest): Promise<EmbedResult> {
257 throw new ProviderHttpError(
258 501,
259 "Anthropic has no embedding API; the gateway routes embed to the fallback provider",
260 false,
261 );
262 }
263
264 async health(): Promise<{ ok: boolean; latencyMs: number }> {
265 const started = this.now();
266 try {
267 await httpJson(`${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, { ...this.http, timeoutMs: 5_000 });
268 return { ok: true, latencyMs: this.now() - started };
269 } catch {
270 return { ok: false, latencyMs: this.now() - started };
271 }
272 }
273}
274
275
276
277function extractSystem(messages: ChatMessage[]): string | null {
278 const parts: string[] = [];
279 for (const m of messages) {
280 if (m.role === "system" && typeof m.content === "string") parts.push(m.content);
281 }
282 return parts.length > 0 ? parts.join("\n\n") : null;
283}
284
285function mapMessage(msg: ChatMessage): Record<string, unknown> {
286 if (msg.role === "tool") {
287 const part: AnthropicToolResultPart = {
288 type: "tool_result",
289 tool_use_id: msg.toolCallId ?? "",
290 content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content),
291 };
292 return { role: "user", content: [part] };
293 }
294 if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) {
295 const content: Array<Record<string, unknown>> = [];
296 if (typeof msg.content === "string" && msg.content.length > 0) {
297 content.push({ type: "text", text: msg.content });
298 }
299 for (const tc of msg.toolCalls) {
300 content.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments });
301 }
302 return { role: "assistant", content };
303 }
304 return { role: msg.role === "assistant" ? "assistant" : "user", content: msg.content };
305}
306
307function mapToolChoice(tc: ChatRequest["toolChoice"]): unknown {
308 if (tc === undefined || tc === "auto") return { type: "auto" };
309 if (tc === "none") return { type: "none" };
310 return { type: "tool", name: tc.name };
311}
312
313function mapStopReason(reason: string, sawToolUse: boolean): ChatResult["finishReason"] {
314 if (reason === "tool_use" || sawToolUse) return "tool_calls";
315 if (reason === "max_tokens" || reason === "length") return "length";
316 return "stop";
317}
318
319function parseArgs(args: string): Record<string, unknown> {
320 if (!args.trim()) return {};
321 try {
322 const parsed: unknown = JSON.parse(args);
323 return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
324 } catch {
325 return {};
326 }
327}
328