@loopingai/core 0.7.1 → 0.8.1

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.
@@ -1,442 +0,0 @@
1
- // The class from `ai`, the types from `@ai-sdk/provider`. `ai` re-exports
2
- // `APICallError` and is a required peer, so the only thing this adapter needs the
3
- // provider package for is types — which erase at build, and which `ai` does not
4
- // re-export.
5
- import { APICallError, UnsupportedFunctionalityError } from "ai";
6
- import { CredentialRejectedError } from "../errors.js";
7
- import { ANTHROPIC_PROVIDER, collectSamplingWarnings, mapPrompt, mapToolChoice, mapTools } from "./prompt.js";
8
- /**
9
- * Anthropic `stop_reason` → the spec's unified finish reason.
10
- *
11
- * `raw` is always carried through: the unified set is lossy, and a caller
12
- * debugging a truncated round wants to know it was `max_tokens` specifically.
13
- */
14
- function mapFinishReason(stopReason) {
15
- const raw = stopReason ?? undefined;
16
- switch (stopReason) {
17
- case "end_turn":
18
- case "stop_sequence":
19
- return { unified: "stop", raw };
20
- case "max_tokens":
21
- return { unified: "length", raw };
22
- case "tool_use":
23
- return { unified: "tool-calls", raw };
24
- case "refusal":
25
- return { unified: "content-filter", raw };
26
- case "pause_turn":
27
- return { unified: "other", raw };
28
- default:
29
- return { unified: "other", raw };
30
- }
31
- }
32
- /** Anthropic usage → the spec's nested counters. */
33
- function mapUsage(usage) {
34
- const cacheWrite = usage?.cache_creation_input_tokens ?? undefined;
35
- const cacheRead = usage?.cache_read_input_tokens ?? undefined;
36
- const noCache = usage?.input_tokens ?? undefined;
37
- // `input_tokens` is the *uncached remainder*, so the true prompt size is the
38
- // sum. Reporting only `input_tokens` as the total is the classic misread that
39
- // makes a well-cached agent look like it is barely sending any context.
40
- const total = noCache === undefined && cacheRead === undefined && cacheWrite === undefined
41
- ? undefined
42
- : (noCache ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
43
- return {
44
- inputTokens: { total, noCache, cacheRead, cacheWrite },
45
- outputTokens: {
46
- total: usage?.output_tokens ?? undefined,
47
- text: undefined,
48
- reasoning: undefined
49
- }
50
- };
51
- }
52
- /**
53
- * Anthropic content blocks → spec content parts.
54
- *
55
- * Two details that corrupt silently if missed:
56
- *
57
- * - `tool-call.input` is a **JSON string** on the result side, even though the
58
- * same-named field on the prompt side is a parsed value.
59
- * - a `thinking` block's `signature` must survive into `providerMetadata`, or
60
- * the next turn cannot replay it and the round after this one fails.
61
- */
62
- function mapContent(blocks) {
63
- const content = [];
64
- for (const block of blocks) {
65
- switch (block.type) {
66
- case "text":
67
- content.push({ type: "text", text: block.text });
68
- break;
69
- case "thinking":
70
- content.push({
71
- type: "reasoning",
72
- text: block.thinking,
73
- providerMetadata: {
74
- [ANTHROPIC_PROVIDER]: { signature: block.signature }
75
- }
76
- });
77
- break;
78
- case "redacted_thinking":
79
- content.push({
80
- type: "reasoning",
81
- text: "",
82
- providerMetadata: {
83
- [ANTHROPIC_PROVIDER]: { redactedData: block.data }
84
- }
85
- });
86
- break;
87
- case "tool_use":
88
- content.push({
89
- type: "tool-call",
90
- toolCallId: block.id,
91
- toolName: block.name,
92
- // Result side: a JSON string, not the object.
93
- input: JSON.stringify(block.input ?? {})
94
- });
95
- break;
96
- default:
97
- // Server-tool blocks (web search, code execution, …). Core never asks
98
- // for them; ignoring is correct and quiet is fine — a warning per block
99
- // would be noise on a feature nobody enabled.
100
- break;
101
- }
102
- }
103
- return content;
104
- }
105
- /**
106
- * Which authority refused, read off the response body the SDK parsed onto
107
- * `APIError.error`.
108
- *
109
- * Only the two authorities *core itself* puts on the path are recognised here.
110
- * They are unmistakable once you have seen them, and nothing else distinguishes
111
- * them — same status, same header set:
112
- *
113
- * ```jsonc
114
- * // AI Gateway, authentication enabled and no cf-aig-authorization sent
115
- * { "name": "AiGatewayError", "internalCode": 2009, "message": "Unauthorized" }
116
- * // Anthropic
117
- * { "type": "error", "error": { "type": "authentication_error", … } }
118
- * ```
119
- *
120
- * Matched on `name` **or** `internalCode`, because either alone is a single
121
- * upstream rename away from silently falling through.
122
- *
123
- * A deployment that puts its own intermediary between the two supplies
124
- * {@link AnthropicModelDeps.classifyAuthFailure}, which is consulted first —
125
- * that body's shape is the deployment's fact, not core's.
126
- *
127
- * `undefined` means "nothing here says a credential was refused", which is
128
- * distinct from `"unknown"` — see {@link asCredentialError}, where the two
129
- * differ by status.
130
- */
131
- function rejectedBy(body, classify) {
132
- const declared = classify?.(body);
133
- if (declared)
134
- return declared;
135
- if (typeof body !== "object" || body === null)
136
- return undefined;
137
- const parsed = body;
138
- if (parsed.name === "AiGatewayError" || parsed.internalCode === 2009)
139
- return "gateway";
140
- if (parsed.error?.type === "authentication_error")
141
- return "provider";
142
- return undefined;
143
- }
144
- /**
145
- * Anthropic's `Headers` (or whatever the SDK put there) as the plain object the
146
- * AI SDK's retry logic indexes.
147
- *
148
- * `getRetryDelayInMs` reads `headers["retry-after-ms"]` and `headers["retry-after"]`
149
- * with bracket access — a `Headers` instance answers `undefined` to both, so the
150
- * wait silently degrades to plain exponential backoff and the provider's own
151
- * "come back in N seconds" is thrown away. Lowercased on the way in because HTTP
152
- * header names are case-insensitive and `Headers.forEach` is the only iteration
153
- * order we can rely on.
154
- */
155
- function plainHeaders(value) {
156
- if (!value)
157
- return undefined;
158
- const out = {};
159
- if (typeof value.forEach === "function") {
160
- value.forEach((v, k) => {
161
- out[k.toLowerCase()] = v;
162
- });
163
- return out;
164
- }
165
- if (typeof value !== "object")
166
- return undefined;
167
- for (const [k, v] of Object.entries(value)) {
168
- if (typeof v === "string")
169
- out[k.toLowerCase()] = v;
170
- }
171
- return out;
172
- }
173
- /**
174
- * A provider error in the shape the AI SDK's retry logic can act on.
175
- *
176
- * This is what makes `maxRetries` mean anything for this adapter. `ai`'s
177
- * `retryWithExponentialBackoffRespectingRetryHeaders` gates on
178
- * `APICallError.isInstance(error) && error.isRetryable`, so a raw
179
- * `@anthropic-ai/sdk` error — which is what this used to rethrow — is never
180
- * retried, however retryable it obviously is. A 429 went straight past the
181
- * retry, burned the fallback slot on a model sharing the same credential, threw,
182
- * and made the Workflow retry the entire round instead of waiting the two
183
- * seconds the provider asked for.
184
- *
185
- * `isRetryable` is deliberately not passed: `APICallError` derives it from the
186
- * status (408/409/429/5xx), which is exactly the policy we want and one fewer
187
- * place for the two to disagree.
188
- *
189
- * Credentials are handled *before* this — see the call site. A 401 must stay
190
- * non-retryable and non-fallback-able, and it reaches core as
191
- * {@link CredentialRejectedError} instead.
192
- */
193
- function asApiCallError(err, body) {
194
- const source = err;
195
- const status = source?.status;
196
- // The gateway's provider-native URL is resolved per call inside the client, so
197
- // the adapter does not hold it. The model id is the useful half anyway.
198
- const url = `anthropic:messages:${body.model}`;
199
- if (typeof status !== "number") {
200
- // A transport failure: the request never reached a server that could answer
201
- // with a status. The SDK raises `APIConnectionError` /
202
- // `APIConnectionTimeoutError` for these, both built by `APIError.generate`
203
- // with an explicitly `undefined` status — which is the shape matched below,
204
- // since the SDK sets no distinguishing `name` and is an optional peer whose
205
- // classes may exist twice in one bundle.
206
- //
207
- // Mapping it matters because *nothing else* would retry it. The client runs
208
- // with `maxRetries: 0`, `isRetryable` is derived from a status this error
209
- // does not have, and the message — "Connection error." — matches none of
210
- // `isTransientAiError`'s fragments. A blip therefore burned the primary,
211
- // burned the fallback, and failed the task as `exhausted`.
212
- if (!isSdkError(source))
213
- return undefined;
214
- return new APICallError({
215
- message: source?.message ?? "Anthropic request failed to connect",
216
- url,
217
- requestBodyValues: body,
218
- // Explicit, because there is no status to derive it from. A connection
219
- // that never opened is the definition of worth trying again.
220
- isRetryable: true,
221
- cause: err
222
- });
223
- }
224
- return new APICallError({
225
- message: source?.message ?? `Anthropic request failed with ${status}`,
226
- url,
227
- requestBodyValues: body,
228
- statusCode: status,
229
- responseHeaders: plainHeaders(source?.headers),
230
- responseBody: source?.error === undefined ? undefined : JSON.stringify(source.error),
231
- cause: err
232
- });
233
- }
234
- /**
235
- * Whether this came out of the Anthropic SDK at all, as opposed to being a
236
- * programming error thrown from the same `try`.
237
- *
238
- * `APIError` assigns `status` and `type` unconditionally, so both are **own
239
- * properties even when undefined** — which is what separates a status-less
240
- * transport failure from a `TypeError`. Deliberately not `instanceof`: the SDK
241
- * is an optional peer and a bundle may hold two copies.
242
- */
243
- function isSdkError(err) {
244
- return (err instanceof Error && "status" in err && "type" in err && "headers" in err);
245
- }
246
- /**
247
- * Recognise a rejected credential, and say whose.
248
- *
249
- * Structural rather than `instanceof Anthropic.AuthenticationError`: the SDK is
250
- * an optional peer, so a bundle can hold two copies, and this must not depend on
251
- * which one threw.
252
- *
253
- * ## Why 401 and 403 are not treated alike
254
- *
255
- * A `401` **is** an authentication failure — that is what the status means — so
256
- * an unrecognised body still yields `"unknown"`, which is the honest answer and
257
- * still stops the ladder.
258
- *
259
- * A `403` is authorization, and Anthropic uses it for `permission_error`: a
260
- * perfectly valid credential that lacks access to *this* model or resource.
261
- * Reporting that as a dead credential is doubly wrong — it sends an operator to
262
- * rotate a working token, and it skips the fallback slot, which is the one thing
263
- * that could still answer, because the fallback is a **different model** and may
264
- * well be permitted. So a `403` counts only when the body positively names a
265
- * refused credential; anything else falls through to {@link asApiCallError} and
266
- * the fallback is tried as usual.
267
- */
268
- function asCredentialError(err, classify) {
269
- if (CredentialRejectedError.isInstance(err))
270
- return err;
271
- const status = err?.status;
272
- if (status !== 401 && status !== 403)
273
- return undefined;
274
- const source = rejectedBy(err?.error, classify);
275
- // A 403 nobody claimed is a permission problem, not a credential one.
276
- if (source === undefined && status === 403)
277
- return undefined;
278
- const message = err instanceof Error ? err.message : "a credential was rejected";
279
- return new CredentialRejectedError(message, {
280
- status,
281
- source: source ?? "unknown",
282
- cause: err
283
- });
284
- }
285
- export function createAnthropicLanguageModel(deps) {
286
- const cache = deps.cache ?? "5m";
287
- const ttl = cache === false ? undefined : cache;
288
- return {
289
- specificationVersion: "v4",
290
- provider: ANTHROPIC_PROVIDER,
291
- modelId: deps.modelId,
292
- // Anthropic fetches image and PDF URLs itself, so the SDK need not download
293
- // and re-encode them.
294
- supportedUrls: {
295
- "image/*": [/^https?:\/\/.+$/],
296
- "application/pdf": [/^https?:\/\/.+$/]
297
- },
298
- async doGenerate(options) {
299
- const warnings = [...collectSamplingWarnings(options)];
300
- const { system, messages, warnings: promptWarnings } = mapPrompt(options.prompt, {
301
- cache,
302
- defaultMaxTokens: deps.defaultMaxTokens
303
- });
304
- warnings.push(...promptWarnings);
305
- const tools = options.tools
306
- ? mapTools(options.tools, ttl, warnings)
307
- : undefined;
308
- if (options.responseFormat?.type === "json") {
309
- warnings.push({
310
- type: "unsupported",
311
- feature: "responseFormat",
312
- details: "structured outputs are not mapped yet; use a tool with a strict schema"
313
- });
314
- }
315
- const effort = mapEffort(options.reasoning ?? deps.effort, warnings);
316
- const body = {
317
- model: deps.modelId,
318
- max_tokens: options.maxOutputTokens ?? deps.defaultMaxTokens,
319
- messages,
320
- ...(system ? { system } : {}),
321
- ...(tools ? { tools } : {}),
322
- ...(options.toolChoice
323
- ? { tool_choice: mapToolChoice(options.toolChoice) }
324
- : {}),
325
- ...(options.stopSequences?.length
326
- ? { stop_sequences: options.stopSequences }
327
- : {}),
328
- ...(effort ? { output_config: { effort } } : {})
329
- };
330
- try {
331
- // Streamed on the wire, accumulated here into the single result the
332
- // spec returns. This is not an optimisation and not optional: the SDK
333
- // refuses a NON-streaming request outright — before any network call —
334
- // once `max_tokens` implies a response that could take more than ten
335
- // minutes, at `(60 * 60 * max_tokens) / 128_000 > 600`, i.e. above
336
- // 21,333 tokens. An agent whose whole point is long output would have
337
- // to be capped below that ceiling to use `create`.
338
- //
339
- // `doStream` still throws: what streams is the transport, not this
340
- // adapter's contract. Core calls `generateText`, which wants one
341
- // result, and it gets one.
342
- const client = await deps.client();
343
- const response = await client.messages
344
- .stream(body, {
345
- ...(options.abortSignal ? { signal: options.abortSignal } : {}),
346
- headers: { ...deps.headers, ...stripUndefined(options.headers) }
347
- })
348
- .finalMessage();
349
- return {
350
- // `refusal` arrives as a normal 200 with empty or partial content, so
351
- // this must never assume `content[0]` exists.
352
- content: mapContent(response.content ?? []),
353
- finishReason: mapFinishReason(response.stop_reason),
354
- usage: mapUsage(response.usage),
355
- warnings,
356
- request: { body },
357
- response: { id: response.id, modelId: response.model }
358
- };
359
- }
360
- catch (err) {
361
- // Credentials first, and the order is the whole point: a 401 is a
362
- // status the generic mapper below would happily wrap, and an
363
- // `APICallError` is something core's ladder is allowed to fall back
364
- // from. A dead token must stop the task instead.
365
- const credential = asCredentialError(err, deps.classifyAuthFailure);
366
- if (credential)
367
- throw credential;
368
- // Everything else in the shape the SDK's retry and core's transient
369
- // classifier can both read. See {@link asApiCallError}.
370
- const apiError = asApiCallError(err, body);
371
- if (apiError)
372
- throw apiError;
373
- throw err;
374
- }
375
- },
376
- /**
377
- * Not implemented, deliberately. Core's loops call `generateText`, which
378
- * never touches `doStream` — what streams is the transport inside
379
- * `doGenerate`, not this adapter's contract. The spec's own error is the
380
- * right one: it fails by name, and a caller reaching for `streamText` sees
381
- * which method is missing rather than something half-formed.
382
- */
383
- doStream() {
384
- throw new UnsupportedFunctionalityError({
385
- functionality: "doStream",
386
- message: "The Anthropic adapter implements doGenerate only. " +
387
- "Core's loops call generateText, which never streams. " +
388
- "Implement doStream in @loopingai/core/anthropic before using streamText."
389
- });
390
- }
391
- };
392
- }
393
- /**
394
- * Reasoning effort → `output_config.effort`, or nothing.
395
- *
396
- * The two vocabularies overlap but are not the same, and the difference is a
397
- * 400 rather than a type error: the spec's `reasoning` includes `"minimal"`,
398
- * which Anthropic's `effort` does not define. Casting across them — which is
399
- * what this replaced — silences the one check that would have caught it.
400
- *
401
- * `provider-default` and `none` mean "say nothing", so the field is omitted and
402
- * the API picks. `minimal` is mapped down to the nearest real level rather than
403
- * dropped: the caller asked for *less* thinking, and omitting the field would
404
- * silently give them the API default of `high` — the opposite.
405
- */
406
- function mapEffort(reasoning, warnings) {
407
- switch (reasoning) {
408
- case undefined:
409
- case "provider-default":
410
- case "none":
411
- return undefined;
412
- case "low":
413
- case "medium":
414
- case "high":
415
- case "xhigh":
416
- case "max":
417
- return reasoning;
418
- case "minimal":
419
- warnings.push({
420
- type: "unsupported",
421
- feature: "reasoning",
422
- details: 'Anthropic has no "minimal" effort; sent "low" instead'
423
- });
424
- return "low";
425
- default:
426
- warnings.push({
427
- type: "unsupported",
428
- feature: "reasoning",
429
- details: `unknown reasoning effort "${reasoning}"; the field was omitted`
430
- });
431
- return undefined;
432
- }
433
- }
434
- /** The spec allows `undefined` header values; `fetch` does not. */
435
- function stripUndefined(headers) {
436
- const out = {};
437
- for (const [key, value] of Object.entries(headers ?? {})) {
438
- if (value !== undefined)
439
- out[key] = value;
440
- }
441
- return out;
442
- }
@@ -1,84 +0,0 @@
1
- import type Anthropic from "@anthropic-ai/sdk";
2
- import type { LanguageModelV4CallOptions, LanguageModelV4FunctionTool, LanguageModelV4Prompt, LanguageModelV4ProviderTool, LanguageModelV4ToolChoice, SharedV4Warning } from "@ai-sdk/provider";
3
- /**
4
- * The AI SDK prompt → Anthropic Messages request mapping.
5
- *
6
- * Split out from the model itself because it is pure: prompt in, request body
7
- * plus warnings out, no client and no I/O. That is what makes the sharp edges
8
- * below testable without a network or a cassette, and every one of them is a
9
- * silent-corruption bug rather than a loud one.
10
- *
11
- * Three shape mismatches do the real damage if you get them wrong:
12
- *
13
- * 1. **There is no `tool` role in the Messages API.** The AI SDK models tool
14
- * results as their own role; Anthropic carries them as `tool_result` blocks
15
- * inside a **user** turn.
16
- * 2. **`thinking` blocks are signed and must be replayed byte-for-byte.** Opus 5
17
- * runs adaptive thinking by default and rejects a modified block. Core's loop
18
- * is multi-turn tool use on every round, so it replays the previous assistant
19
- * message every single time — drop or rebuild the signature and round two of
20
- * every task 400s.
21
- * 3. **`input` is a parsed value on the way in and a JSON string on the way
22
- * out.** `LanguageModelV4ToolCallPart.input` (prompt side) is `unknown`;
23
- * `LanguageModelV4ToolCall.input` (result side) is `string`. Mixing them up
24
- * type-checks and corrupts silently.
25
- */
26
- /** Namespaced provider key for anything we stash on a part. */
27
- export declare const ANTHROPIC_PROVIDER = "anthropic";
28
- /**
29
- * A tool-call id Anthropic will accept, from one it might not.
30
- *
31
- * The API validates this field against `^[a-zA-Z0-9_-]+$` and answers a
32
- * violation with a `400 invalid_request_error` naming the exact message index —
33
- * `messages.7.content.1.tool_use.id`. That is a *deterministic* failure on
34
- * replayed history, which makes it the worst kind: every retry and every
35
- * fallback re-sends the same poisoned message list and fails identically, so the
36
- * round burns its whole ladder without a single attempt that could have worked.
37
- * It cost a production task exactly that way.
38
- *
39
- * Applied to both sides of the pair — the assistant's `tool_use.id` and the
40
- * user turn's `tool_result.tool_use_id` — because the two must agree or the
41
- * request is malformed in a second, more confusing way. Since the mapping is a
42
- * pure function of the id, applying it independently at both call sites lands on
43
- * the same answer without threading state between them.
44
- *
45
- * This is a **backstop**, not the fix. Core's own generators emit safe ids
46
- * directly (see {@link file://../../subtasks/delegate.ts delegateToolCallId});
47
- * this catches the next generator someone writes, and any id that reaches the
48
- * adapter from outside core.
49
- *
50
- * Not injective, and deliberately not made so: two ids differing only in
51
- * unsafe characters collapse together. Adding a hash to avoid that would change
52
- * every id to guard against a collision that requires two synthetic calls in one
53
- * request whose ids differ *only* in punctuation. Provider-legible ids are worth
54
- * more than that.
55
- */
56
- export declare function providerSafeToolCallId(id: string): string;
57
- /**
58
- * How long a cache entry should live.
59
- *
60
- * `"5m"` is Anthropic's default and suits back-to-back model calls. `"1h"`
61
- * costs 2x on write instead of 1.25x and exists for agents whose rounds are
62
- * separated by long tool work — a container boot, an install, a test suite —
63
- * where a 5-minute entry has expired by the time the next round starts and the
64
- * whole prefix is re-billed at full price.
65
- */
66
- export type CacheTtl = "5m" | "1h";
67
- export interface PromptMappingOptions {
68
- /** Cache TTL, or `false` to place no breakpoints at all. */
69
- cache?: CacheTtl | false;
70
- /** `max_tokens` when the caller supplied none. Anthropic requires the field. */
71
- defaultMaxTokens: number;
72
- }
73
- export interface MappedPrompt {
74
- system: Anthropic.TextBlockParam[] | undefined;
75
- messages: Anthropic.MessageParam[];
76
- warnings: SharedV4Warning[];
77
- }
78
- /** Map the prompt half of a call: system blocks + turns. */
79
- export declare function mapPrompt(prompt: LanguageModelV4Prompt, options: PromptMappingOptions): MappedPrompt;
80
- /** Map tool definitions, putting the frozen-prefix breakpoint on the last one. */
81
- export declare function mapTools(tools: Array<LanguageModelV4FunctionTool | LanguageModelV4ProviderTool>, ttl: CacheTtl | undefined, warnings: SharedV4Warning[]): Anthropic.ToolUnion[] | undefined;
82
- /** Map tool choice. Note V4 uses `{type:"required"}`, not the bare string. */
83
- export declare function mapToolChoice(toolChoice: LanguageModelV4ToolChoice | undefined): Anthropic.ToolChoice | undefined;
84
- export declare function collectSamplingWarnings(options: LanguageModelV4CallOptions): SharedV4Warning[];