@zerotal/ai 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ import {
2
+ AiCancelledError,
3
+ AiDriverUnavailableError,
4
+ AiRateLimitError,
5
+ AiRefusedError,
6
+ AiRequestError,
7
+ AiSchemaError,
8
+ } from "../errors.ts";
9
+ import { recheckAgainstSchema, translateSchema, type SchemaInput } from "../schema.ts";
10
+ import { modelRejectsSampling } from "../pricing.ts";
11
+ import type {
12
+ AiMessage,
13
+ AiObjectResponse,
14
+ AiRequest,
15
+ AiResponse,
16
+ AiStopReason,
17
+ AiStreamChunk,
18
+ AiTool,
19
+ AiToolCall,
20
+ AiUsage,
21
+ AnthropicConfigShape,
22
+ } from "../types.ts";
23
+ import { normalizeMessages, type AiDriver, type DriverStatus } from "./AiDriver.ts";
24
+ import type {
25
+ AnthropicBlock,
26
+ AnthropicClient,
27
+ AnthropicConstructor,
28
+ AnthropicMessage,
29
+ AnthropicMessagesApi,
30
+ AnthropicModule,
31
+ AnthropicUsage,
32
+ LoadedAnthropic,
33
+ } from "./anthropic-sdk.ts";
34
+
35
+ /** Opts into the server-side refusal fallback. */
36
+ const FALLBACK_BETA = "server-side-fallback-2026-07-01";
37
+
38
+ /**
39
+ * Roughly the shortest system prompt worth a cache breakpoint.
40
+ *
41
+ * The minimum cacheable prefix is 512 tokens on Claude Opus 5 and 1024 on the
42
+ * 4.x line, and a prefix below it silently does not cache — no error, just a
43
+ * breakpoint that never pays. ~4 chars per token puts 1024 tokens near 4000
44
+ * characters, so this is the conservative side of both thresholds.
45
+ */
46
+ const CACHEABLE_SYSTEM_CHARS = 4000;
47
+
48
+ /**
49
+ * The Anthropic driver.
50
+ *
51
+ * Several details here are load-bearing rather than stylistic, and each is
52
+ * commented where it appears: sampling parameters are dropped (they 400),
53
+ * `max_tokens` covers thinking *and* text, a refusal is a 200 that must be
54
+ * checked before the content is read, and errors are mapped by SDK class.
55
+ */
56
+ export class AnthropicDriver implements AiDriver {
57
+ readonly name = "anthropic";
58
+
59
+ private _loaded: LoadedAnthropic | undefined;
60
+ private _loading: Promise<LoadedAnthropic> | undefined;
61
+ private _warnedSampling = false;
62
+
63
+ /**
64
+ * @param config - The resolved `drivers.anthropic` block.
65
+ * @param inject - A pre-built client, for tests. Nothing else supplies it.
66
+ */
67
+ constructor(
68
+ private readonly config: AnthropicConfigShape,
69
+ inject?: LoadedAnthropic,
70
+ ) {
71
+ this._loaded = inject;
72
+ }
73
+
74
+ get model(): string {
75
+ return this.config.model;
76
+ }
77
+
78
+ // ── Generation ───────────────────────────────────────────────────────────
79
+
80
+ async text(request: AiRequest): Promise<AiResponse> {
81
+ const { client } = await this._load();
82
+ const params = this._params(request, false);
83
+
84
+ const message = await this._call(() =>
85
+ this._api(client).create(params, this._options(request)),
86
+ );
87
+
88
+ return this._toResponse(message);
89
+ }
90
+
91
+ async *stream(request: AiRequest): AsyncIterable<AiStreamChunk> {
92
+ const { client } = await this._load();
93
+ const params = this._params(request, true);
94
+
95
+ const stream = this._api(client).stream(params, this._options(request));
96
+
97
+ let partial = "";
98
+ try {
99
+ for await (const event of stream) {
100
+ if (event.type !== "content_block_delta") continue;
101
+ const delta = (event as { delta: { type: string; text?: string; thinking?: string } })
102
+ .delta;
103
+
104
+ if (delta.type === "text_delta" && delta.text !== undefined) {
105
+ partial += delta.text;
106
+ yield { type: "text", text: delta.text };
107
+ } else if (delta.type === "thinking_delta" && delta.thinking !== undefined) {
108
+ yield { type: "thinking", text: delta.thinking };
109
+ }
110
+ }
111
+ } catch (error) {
112
+ throw this._mapError(error, request);
113
+ }
114
+
115
+ const final = await this._call(() => stream.finalMessage());
116
+
117
+ // A mid-stream refusal has already handed the caller real text. Carrying it
118
+ // on the error lets them discard a partial answer knowingly instead of
119
+ // shipping a truncated one.
120
+ this._assertNotRefused(final, partial);
121
+
122
+ const response = this._toResponse(final);
123
+ for (const call of response.toolCalls) yield { type: "tool_call", call };
124
+ yield { type: "done", response };
125
+ }
126
+
127
+ async object<T>(request: AiRequest, schema: SchemaInput): Promise<AiObjectResponse<T>> {
128
+ const { client } = await this._load();
129
+
130
+ const params = this._params(request, false);
131
+ const outputConfig = (params["output_config"] ?? {}) as Record<string, unknown>;
132
+ params["output_config"] = {
133
+ ...outputConfig,
134
+ format: { type: "json_schema", schema: translateSchema(schema) },
135
+ };
136
+ // Structured output and tool use are separate constraints on the same turn;
137
+ // asking for both makes the answer's shape ambiguous.
138
+ delete params["tools"];
139
+
140
+ const message = await this._call(() =>
141
+ this._api(client).create(params, this._options(request)),
142
+ );
143
+ this._assertNotRefused(message);
144
+
145
+ const text = textOf(message.content);
146
+ let parsed: unknown;
147
+ try {
148
+ parsed = JSON.parse(text);
149
+ } catch {
150
+ throw new AiSchemaError(
151
+ `The model's answer was constrained to a schema but did not parse as JSON. ` +
152
+ `This usually means the response was truncated — check stop_reason ` +
153
+ `(${message.stop_reason ?? "null"}) and raise maxTokens.`,
154
+ { stopReason: message.stop_reason, text: text.slice(0, 200) },
155
+ );
156
+ }
157
+
158
+ return {
159
+ // Re-check here, not in the manager: the constraints stripped during
160
+ // translation are only knowable next to the schema that lost them.
161
+ object: recheckAgainstSchema<T>(schema, parsed),
162
+ model: message.model,
163
+ usage: toUsage(message.usage),
164
+ raw: message,
165
+ };
166
+ }
167
+
168
+ async countTokens(request: AiRequest): Promise<number> {
169
+ const { client } = await this._load();
170
+ const api = this._api(client);
171
+ if (!api.countTokens) return 0;
172
+
173
+ const params = this._params(request, false);
174
+ // Counting is about the prompt; the response ceiling and sampling knobs are
175
+ // not part of the question and some of them are rejected outright.
176
+ delete params["max_tokens"];
177
+ delete params["output_config"];
178
+ delete params["fallbacks"];
179
+ delete params["betas"];
180
+
181
+ const result = await this._call(() => api.countTokens!(params, this._options(request)));
182
+ return result.input_tokens;
183
+ }
184
+
185
+ async verify(): Promise<DriverStatus> {
186
+ try {
187
+ const response = await this.text({
188
+ prompt: "Reply with the single word: ok",
189
+ maxTokens: 64,
190
+ effort: "low",
191
+ });
192
+ return {
193
+ ok: true,
194
+ model: response.model,
195
+ detail: `${response.text.trim().slice(0, 60)} · ${response.usage.inputTokens} in / ${response.usage.outputTokens} out`,
196
+ };
197
+ } catch (error) {
198
+ return {
199
+ ok: false,
200
+ model: this.config.model,
201
+ detail: error instanceof Error ? error.message : String(error),
202
+ };
203
+ }
204
+ }
205
+
206
+ // ── Request construction ─────────────────────────────────────────────────
207
+
208
+ /** Whether this call goes through the beta namespace (it does iff fallbacks are on). */
209
+ private _api(client: AnthropicClient): AnthropicMessagesApi {
210
+ return this.config.fallbacks ? client.beta.messages : client.messages;
211
+ }
212
+
213
+ private _options(request: AiRequest): { signal?: AbortSignal; timeout: number } {
214
+ // The SDK's timeout is in milliseconds, unlike the Python SDK's seconds.
215
+ return request.signal
216
+ ? { signal: request.signal, timeout: this.config.timeout }
217
+ : { timeout: this.config.timeout };
218
+ }
219
+
220
+ private _params(request: AiRequest, streaming: boolean): Record<string, unknown> {
221
+ const model = request.model ?? this.config.model;
222
+ const maxTokens =
223
+ request.maxTokens ?? (streaming ? this.config.streamMaxTokens : this.config.maxTokens);
224
+
225
+ if (request.temperature !== undefined && modelRejectsSampling(model) && !this._warnedSampling) {
226
+ this._warnedSampling = true;
227
+ console.warn(
228
+ `[Zerotal/ai] temperature was supplied but ${model} rejects temperature/top_p/top_k with ` +
229
+ `a 400. Dropping it. Use effort ('low' … 'max') to trade thoroughness for cost.`,
230
+ );
231
+ }
232
+
233
+ const params: Record<string, unknown> = {
234
+ model,
235
+ // Caps thinking *plus* response text — not the answer alone. Thinking is on
236
+ // by default on Claude Opus 5, so a budget sized for the prose truncates.
237
+ max_tokens: maxTokens,
238
+ messages: toAnthropicMessages(normalizeMessages(request)),
239
+ thinking: { type: "adaptive" },
240
+ output_config: { effort: request.effort ?? this.config.effort },
241
+ };
242
+
243
+ const system = this._system(request);
244
+ if (system) params["system"] = system;
245
+
246
+ if (request.tools?.length) params["tools"] = request.tools.map(toAnthropicTool);
247
+
248
+ if (this.config.fallbacks) {
249
+ // `"default"` routes by refusal category, so there is no fallback model
250
+ // list to maintain — and none to migrate when one is retired.
251
+ params["fallbacks"] = "default";
252
+ params["betas"] = [FALLBACK_BETA];
253
+ }
254
+
255
+ // Provider options are passed through untouched and last, so an app can set
256
+ // anything this surface does not model — including overriding what it does.
257
+ Object.assign(params, request.providerOptions?.["anthropic"] ?? {});
258
+
259
+ return params;
260
+ }
261
+
262
+ /** The system prompt, marked cacheable when it is long enough to be worth it. */
263
+ private _system(request: AiRequest): unknown {
264
+ const text = request.system;
265
+ if (!text) return undefined;
266
+
267
+ const cache =
268
+ (request.cache ?? this.config.cacheSystem) && text.length >= CACHEABLE_SYSTEM_CHARS;
269
+ if (!cache) return text;
270
+
271
+ return [{ type: "text", text, cache_control: { type: "ephemeral" } }];
272
+ }
273
+
274
+ // ── Response handling ────────────────────────────────────────────────────
275
+
276
+ private _toResponse(message: AnthropicMessage): AiResponse {
277
+ this._assertNotRefused(message);
278
+
279
+ const text = textOf(message.content);
280
+ const toolCalls = toolCallsOf(message.content);
281
+
282
+ return {
283
+ text,
284
+ model: message.model,
285
+ usage: toUsage(message.usage),
286
+ stopReason: toStopReason(message.stop_reason),
287
+ toolCalls,
288
+ // `raw` is the provider's own block list, replayed unchanged — see the
289
+ // field's docs for why rebuilding it from `text` is not equivalent.
290
+ assistantTurn: { role: "assistant", content: text, toolCalls, raw: message.content },
291
+ raw: message,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * A refusal arrives as HTTP **200** with empty or partial content.
297
+ *
298
+ * So this has to run before anything reads `content[0]` — otherwise the
299
+ * failure mode is a crash on a response the API considers perfectly fine, at
300
+ * whatever call site happened to index first.
301
+ */
302
+ private _assertNotRefused(message: AnthropicMessage, partialText = ""): void {
303
+ if (message.stop_reason !== "refusal") return;
304
+ const details = message.stop_details ?? {};
305
+ throw new AiRefusedError(details.category ?? null, details.explanation ?? null, partialText);
306
+ }
307
+
308
+ // ── Loading and errors ───────────────────────────────────────────────────
309
+
310
+ /** Load the optional SDK once, and keep both the class and the client. */
311
+ private async _load(): Promise<LoadedAnthropic> {
312
+ if (this._loaded) return this._loaded;
313
+ this._loading ??= this._import();
314
+ this._loaded = await this._loading;
315
+ return this._loaded;
316
+ }
317
+
318
+ private async _import(): Promise<LoadedAnthropic> {
319
+ // A non-literal specifier: the package is an optional peer, so the compiler
320
+ // must not try to resolve it in apps that never installed it.
321
+ const specifier = "@anthropic-ai/sdk";
322
+ let loaded: unknown;
323
+ try {
324
+ loaded = await import(specifier);
325
+ } catch {
326
+ throw new AiDriverUnavailableError("anthropic", "@anthropic-ai/sdk");
327
+ }
328
+
329
+ const ctor = (loaded as AnthropicModule).default;
330
+ const options: { apiKey: string; timeout: number; baseURL?: string } = {
331
+ apiKey: this.config.apiKey,
332
+ timeout: this.config.timeout,
333
+ };
334
+ if (this.config.baseUrl) options.baseURL = this.config.baseUrl;
335
+
336
+ return { ctor, client: new ctor(options) };
337
+ }
338
+
339
+ /** Run a provider call, translating its failure into this package's vocabulary. */
340
+ private async _call<T>(fn: () => Promise<T>, request?: AiRequest): Promise<T> {
341
+ try {
342
+ return await fn();
343
+ } catch (error) {
344
+ throw this._mapError(error, request);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Map an SDK error onto ours **by class**, never by matching its message.
350
+ *
351
+ * The SDK already retries 429s and 5xx with backoff, so there is deliberately
352
+ * no retry here — a second layer would multiply the wait and hide the first.
353
+ */
354
+ private _mapError(error: unknown, request?: AiRequest): Error {
355
+ if (request?.signal?.aborted) return new AiCancelledError();
356
+
357
+ const ctor: AnthropicConstructor | undefined = this._loaded?.ctor;
358
+ if (ctor) {
359
+ if (error instanceof ctor.RateLimitError) {
360
+ const retryAfter = retryAfterOf(error);
361
+ return retryAfter === undefined
362
+ ? new AiRateLimitError(error.message)
363
+ : new AiRateLimitError(error.message, retryAfter);
364
+ }
365
+ if (error instanceof ctor.APIConnectionError) {
366
+ return new AiRequestError(error.message, 0);
367
+ }
368
+ if (error instanceof ctor.APIError) {
369
+ return new AiRequestError(error.message, (error as { status?: number }).status ?? 0);
370
+ }
371
+ }
372
+
373
+ if (error instanceof Error && error.name === "AbortError") return new AiCancelledError();
374
+ return error instanceof Error ? error : new Error(String(error));
375
+ }
376
+ }
377
+
378
+ // ── Translation helpers ─────────────────────────────────────────────────────
379
+
380
+ /** Concatenate the text blocks; ignore thinking and tool blocks. */
381
+ function textOf(blocks: AnthropicBlock[]): string {
382
+ let out = "";
383
+ for (const block of blocks) {
384
+ if (block.type === "text" && typeof block["text"] === "string") out += block["text"];
385
+ }
386
+ return out;
387
+ }
388
+
389
+ function toolCallsOf(blocks: AnthropicBlock[]): AiToolCall[] {
390
+ const calls: AiToolCall[] = [];
391
+ for (const block of blocks) {
392
+ if (block.type !== "tool_use") continue;
393
+ calls.push({
394
+ id: String(block["id"]),
395
+ name: String(block["name"]),
396
+ input: (block["input"] ?? {}) as Record<string, unknown>,
397
+ });
398
+ }
399
+ return calls;
400
+ }
401
+
402
+ function toUsage(usage: AnthropicUsage): AiUsage {
403
+ return {
404
+ inputTokens: usage.input_tokens,
405
+ outputTokens: usage.output_tokens,
406
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0,
407
+ cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
408
+ };
409
+ }
410
+
411
+ function toStopReason(reason: string | null): AiStopReason {
412
+ switch (reason) {
413
+ case "end_turn":
414
+ case "max_tokens":
415
+ case "tool_use":
416
+ case "pause_turn":
417
+ case "refusal":
418
+ case "stop_sequence":
419
+ return reason;
420
+ default:
421
+ return "unknown";
422
+ }
423
+ }
424
+
425
+ function toAnthropicTool(tool: AiTool): Record<string, unknown> {
426
+ return {
427
+ name: tool.name,
428
+ description: tool.description,
429
+ input_schema: tool.inputSchema,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Turn this package's messages into the provider's.
435
+ *
436
+ * A turn that carries `raw` is replayed verbatim. That matters more than it
437
+ * looks: an assistant turn containing thinking or cache blocks is rejected if
438
+ * it is reconstructed from text, because the signature does not survive the
439
+ * round trip.
440
+ */
441
+ function toAnthropicMessages(messages: AiMessage[]): Array<Record<string, unknown>> {
442
+ return messages.map((message) => {
443
+ if (message.raw !== undefined) return { role: message.role, content: message.raw };
444
+
445
+ if (message.toolResults?.length) {
446
+ return {
447
+ role: "user",
448
+ content: message.toolResults.map((result) => ({
449
+ type: "tool_result",
450
+ tool_use_id: result.id,
451
+ content: result.content,
452
+ ...(result.isError ? { is_error: true } : {}),
453
+ })),
454
+ };
455
+ }
456
+
457
+ if (message.toolCalls?.length) {
458
+ const blocks: Array<Record<string, unknown>> = [];
459
+ if (message.content) blocks.push({ type: "text", text: message.content });
460
+ for (const call of message.toolCalls) {
461
+ blocks.push({ type: "tool_use", id: call.id, name: call.name, input: call.input });
462
+ }
463
+ return { role: message.role, content: blocks };
464
+ }
465
+
466
+ return { role: message.role, content: message.content };
467
+ });
468
+ }
469
+
470
+ /** `retry-after`, when the SDK surfaced the header. */
471
+ function retryAfterOf(error: unknown): number | undefined {
472
+ const headers = (error as { headers?: unknown }).headers;
473
+ if (!headers) return undefined;
474
+
475
+ const raw =
476
+ typeof (headers as Headers).get === "function"
477
+ ? (headers as Headers).get("retry-after")
478
+ : ((headers as Record<string, string>)["retry-after"] ?? null);
479
+
480
+ if (!raw) return undefined;
481
+ const seconds = Number(raw);
482
+ return Number.isFinite(seconds) ? seconds : undefined;
483
+ }