@loopingai/core 0.5.1 → 0.6.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.
- package/README.md +6 -4
- package/dist/a2a/notify.d.ts +4 -3
- package/dist/a2a/notify.js +4 -3
- package/dist/agent/anthropic/index.d.ts +15 -0
- package/dist/agent/anthropic/index.js +19 -0
- package/dist/agent/anthropic/language-model.d.ts +59 -0
- package/dist/agent/anthropic/language-model.js +442 -0
- package/dist/agent/anthropic/prompt.d.ts +84 -0
- package/dist/agent/anthropic/prompt.js +541 -0
- package/dist/agent/anthropic/runtime.d.ts +79 -0
- package/dist/agent/anthropic/runtime.js +130 -0
- package/dist/agent/control.js +10 -9
- package/dist/agent/errors.d.ts +85 -0
- package/dist/agent/errors.js +64 -0
- package/dist/agent/final-reply.d.ts +14 -13
- package/dist/agent/final-reply.js +28 -11
- package/dist/agent/history.d.ts +3 -3
- package/dist/agent/history.js +2 -2
- package/dist/agent/index.d.ts +4 -2
- package/dist/agent/index.js +4 -2
- package/dist/agent/inference.d.ts +58 -1
- package/dist/agent/inference.js +44 -0
- package/dist/agent/model.d.ts +42 -25
- package/dist/agent/model.js +1 -48
- package/dist/agent/session.d.ts +6 -7
- package/dist/agent/session.js +3 -3
- package/dist/agent/workers-ai/index.d.ts +23 -0
- package/dist/agent/workers-ai/index.js +23 -0
- package/dist/agent/workers-ai/runtime.d.ts +42 -0
- package/dist/agent/workers-ai/runtime.js +63 -0
- package/dist/config.d.ts +49 -15
- package/dist/config.js +30 -1
- package/dist/contract/plugin.d.ts +63 -3
- package/dist/contract/plugin.js +76 -0
- package/dist/contract/recipe.d.ts +16 -17
- package/dist/db/db.d.ts +0 -1
- package/dist/db/migrations/index.js +8 -1
- package/dist/db/models/subtasks.d.ts +24 -25
- package/dist/db/models/subtasks.js +33 -76
- package/dist/db/schema.d.ts +2 -21
- package/dist/db/schema.js +2 -4
- package/dist/host/agent.d.ts +58 -4
- package/dist/host/agent.js +63 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/platform.d.ts +74 -11
- package/dist/platform.js +76 -13
- package/dist/round/agent.d.ts +36 -31
- package/dist/round/agent.js +61 -89
- package/dist/round/index.d.ts +3 -2
- package/dist/round/index.js +2 -2
- package/dist/round/policy.d.ts +2 -2
- package/dist/round/subagent.d.ts +19 -1
- package/dist/round/subagent.js +22 -5
- package/dist/round/turn.d.ts +32 -13
- package/dist/round/turn.js +83 -16
- package/dist/round/workflow.d.ts +23 -7
- package/dist/round/workflow.js +132 -65
- package/dist/runtime/index.d.ts +4 -2
- package/dist/runtime/index.js +6 -0
- package/dist/subagent/fingerprint.d.ts +2 -2
- package/dist/subagent/fingerprint.js +8 -17
- package/dist/subagent/index.d.ts +6 -4
- package/dist/subagent/index.js +8 -6
- package/dist/subagent/prompt.d.ts +4 -5
- package/dist/subagent/prompt.js +0 -8
- package/dist/subagent/run.d.ts +8 -1
- package/dist/subagent/run.js +59 -9
- package/dist/subtasks/catalog.d.ts +1 -1
- package/dist/subtasks/catalog.js +1 -1
- package/dist/subtasks/decomposition.d.ts +16 -20
- package/dist/subtasks/decomposition.js +27 -75
- package/dist/subtasks/delegate.d.ts +20 -1
- package/dist/subtasks/delegate.js +21 -16
- package/dist/subtasks/index.d.ts +1 -2
- package/dist/subtasks/index.js +1 -2
- package/dist/subtasks/subtask-types.d.ts +0 -8
- package/dist/subtasks/subtask-types.js +0 -7
- package/dist/subtasks/types.d.ts +45 -70
- package/dist/testing/mock-model.d.ts +35 -0
- package/dist/testing/mock-model.js +75 -0
- package/dist/testing/vcr-global-setup.d.ts +1 -3
- package/dist/testing/vcr-global-setup.js +1 -3
- package/dist/worker/index.d.ts +5 -12
- package/dist/worker/index.js +5 -12
- package/package.json +19 -1
- package/dist/subtasks/scheduler.d.ts +0 -48
- package/dist/subtasks/scheduler.js +0 -47
package/README.md
CHANGED
|
@@ -188,8 +188,8 @@ probed for), and hand-rolling it is how two agents in one repo drift apart.
|
|
|
188
188
|
|
|
189
189
|
### 4. Delegate, if your agent delegates
|
|
190
190
|
|
|
191
|
-
`@loopingai/core/round` adds the other half:
|
|
192
|
-
|
|
191
|
+
`@loopingai/core/round` adds the other half: durable Subtasks, concurrent
|
|
192
|
+
execution, isolated subagents, and the round loop over them.
|
|
193
193
|
|
|
194
194
|
```ts
|
|
195
195
|
import { RoundAgentBase, type RoundPolicy } from "@loopingai/core/round";
|
|
@@ -225,10 +225,11 @@ not drag in the A2A adapter, and the test harness cannot reach a production bund
|
|
|
225
225
|
| `@loopingai/core` | `createAgentRuntime`, the plugin contract, config shapes, platform facts |
|
|
226
226
|
| `@loopingai/core/a2a` | card signing, JWKS, gateway-JWT verify, push notify, task store, executor |
|
|
227
227
|
| `@loopingai/core/worker` | `createA2AWorker()` — the whole zero-trust edge |
|
|
228
|
-
| `@loopingai/core/agent` | session, history,
|
|
228
|
+
| `@loopingai/core/agent` | session, history, models + Workers AI, inference, budget, control tools |
|
|
229
229
|
| `@loopingai/core/host` | `LoopingAgent` — the Durable Object body — and `PluginHost` |
|
|
230
230
|
| `@loopingai/core/round` | the delegating round loop: `RoundAgentBase`, `runHandleTask`, `runTurn` |
|
|
231
|
-
| `@loopingai/core/
|
|
231
|
+
| `@loopingai/core/anthropic` | Claude as a second provider — _optional peer on `@anthropic-ai/sdk`_ |
|
|
232
|
+
| `@loopingai/core/subtasks` | delegation types, decomposition, the `delegate` tool |
|
|
232
233
|
| `@loopingai/core/subagent` | `RecipeSubagentBase`, resumable runs, fingerprinting, workspace |
|
|
233
234
|
| `@loopingai/core/db` | `AgentDB`, `notify_tasks` + `subtasks` schema, migrations, `PluginStore` |
|
|
234
235
|
| `@loopingai/core/testing` | VCR, `FakeSession`, `mockModel`, DO helpers, JWK fixtures — _workerd realm_ |
|
|
@@ -465,6 +466,7 @@ await withDb("accepts a turn once", async (db) => {
|
|
|
465
466
|
- **Bindings:** `AI`, one Durable Object, one Workflow
|
|
466
467
|
- **Secrets:** `A2A_SIGNING_KEY`, `GATEWAY_ORIGINS`
|
|
467
468
|
- **Peers, never bundled:** `agents`, `ai`, `workers-ai-provider`
|
|
469
|
+
- **Optional peer:** `@anthropic-ai/sdk`, needed only by `@loopingai/core/anthropic`
|
|
468
470
|
|
|
469
471
|
That last point is not stylistic: two copies of `agents` in one Worker breaks the
|
|
470
472
|
`Session` / `SessionMessage` types and every `instanceof`. For local development
|
package/dist/a2a/notify.d.ts
CHANGED
|
@@ -37,9 +37,10 @@ export { NOTIFICATION_TOKEN_HEADER } from "@loopingai/a2a-protocol";
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function buildSubmittedTask(taskId: string, contextId: string): PlainTask;
|
|
39
39
|
/**
|
|
40
|
-
* The terminal `completed` Task for a turn the agent deliberately did not
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* The terminal `completed` Task for a turn the agent deliberately did not
|
|
41
|
+
* answer — a turn an `AgentPlugin.shouldHandleTurn` gate declined, or whatever
|
|
42
|
+
* else a host treats as "nothing to say". Same shape as
|
|
43
|
+
* {@link buildSubmittedTask}: **no `status.message` at all**.
|
|
43
44
|
*
|
|
44
45
|
* The callback is still POSTed. The gateway's pending row has to resolve — we
|
|
45
46
|
* simply hand it nothing to post to Slack. There is no `messageId` because there
|
package/dist/a2a/notify.js
CHANGED
|
@@ -69,9 +69,10 @@ export function buildSubmittedTask(taskId, contextId) {
|
|
|
69
69
|
return buildBareTask(taskId, contextId, TaskState.TASK_STATE_SUBMITTED);
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* The terminal `completed` Task for a turn the agent deliberately did not
|
|
73
|
-
*
|
|
74
|
-
*
|
|
72
|
+
* The terminal `completed` Task for a turn the agent deliberately did not
|
|
73
|
+
* answer — a turn an `AgentPlugin.shouldHandleTurn` gate declined, or whatever
|
|
74
|
+
* else a host treats as "nothing to say". Same shape as
|
|
75
|
+
* {@link buildSubmittedTask}: **no `status.message` at all**.
|
|
75
76
|
*
|
|
76
77
|
* The callback is still POSTed. The gateway's pending row has to resolve — we
|
|
77
78
|
* simply hand it nothing to post to Slack. There is no `messageId` because there
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@loopingai/core/anthropic` — Claude as a second model provider.
|
|
3
|
+
*
|
|
4
|
+
* Its own subpath, and an **optional** peer dependency on `@anthropic-ai/sdk`,
|
|
5
|
+
* for the same reason `/round` is not re-exported from the root barrel: an agent
|
|
6
|
+
* that runs on Workers AI should not pay — in install size, in bundle bytes, or
|
|
7
|
+
* in a dependency it must keep current — for a provider it never calls.
|
|
8
|
+
*
|
|
9
|
+
* What lives here is a provider, not a capability. It ships no tools, no prompt
|
|
10
|
+
* copy and no policy; it satisfies {@link ModelRuntime} and stops.
|
|
11
|
+
*/
|
|
12
|
+
export { CredentialRejectedError, type CredentialRejectedBy } from "../errors.js";
|
|
13
|
+
export { createAnthropicLanguageModel, type AnthropicModelDeps } from "./language-model.js";
|
|
14
|
+
export { createAnthropicModelRuntime, type AnthropicRuntimeDeps } from "./runtime.js";
|
|
15
|
+
export { ANTHROPIC_PROVIDER, type CacheTtl } from "./prompt.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@loopingai/core/anthropic` — Claude as a second model provider.
|
|
3
|
+
*
|
|
4
|
+
* Its own subpath, and an **optional** peer dependency on `@anthropic-ai/sdk`,
|
|
5
|
+
* for the same reason `/round` is not re-exported from the root barrel: an agent
|
|
6
|
+
* that runs on Workers AI should not pay — in install size, in bundle bytes, or
|
|
7
|
+
* in a dependency it must keep current — for a provider it never calls.
|
|
8
|
+
*
|
|
9
|
+
* What lives here is a provider, not a capability. It ships no tools, no prompt
|
|
10
|
+
* copy and no policy; it satisfies {@link ModelRuntime} and stops.
|
|
11
|
+
*/
|
|
12
|
+
// Re-exported, not owned: a rejected credential is a fact about the path to a
|
|
13
|
+
// model, not about Anthropic, so the error lives with the rest of the provider
|
|
14
|
+
// contract in {@link file://../errors.ts}. An agent that only imports this
|
|
15
|
+
// subpath still gets it from one place.
|
|
16
|
+
export { CredentialRejectedError } from "../errors.js";
|
|
17
|
+
export { createAnthropicLanguageModel } from "./language-model.js";
|
|
18
|
+
export { createAnthropicModelRuntime } from "./runtime.js";
|
|
19
|
+
export { ANTHROPIC_PROVIDER } from "./prompt.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type { LanguageModelV4 } from "@ai-sdk/provider";
|
|
3
|
+
import { type CredentialRejectedBy } from "../errors.js";
|
|
4
|
+
import { type CacheTtl } from "./prompt.js";
|
|
5
|
+
/**
|
|
6
|
+
* A `LanguageModelV4` over `@anthropic-ai/sdk`, so core's loops can call Claude
|
|
7
|
+
* without knowing they are.
|
|
8
|
+
*
|
|
9
|
+
* Every loop in core is written against `generateText` from `ai` and a
|
|
10
|
+
* `LanguageModel` — {@link file://../../round/turn.ts turn.ts},
|
|
11
|
+
* {@link file://../../subagent/run.ts run.ts} and
|
|
12
|
+
* {@link file://../session.ts session.ts}. Satisfying that interface is what
|
|
13
|
+
* keeps the round loop, the control-tool repair ladder, subtask execution and
|
|
14
|
+
* the Workflow untouched by a second provider. The alternative — a bespoke
|
|
15
|
+
* Messages-API loop for one agent — would have given all of that up.
|
|
16
|
+
*
|
|
17
|
+
* `ai@7` accepts `LanguageModelV2 | V3 | V4`; this targets **v4**, the newest
|
|
18
|
+
* the installed `@ai-sdk/provider` defines.
|
|
19
|
+
*/
|
|
20
|
+
/** How the adapter reaches the API. Everything is injected so nothing reads env. */
|
|
21
|
+
export interface AnthropicModelDeps {
|
|
22
|
+
/**
|
|
23
|
+
* Constructed lazily by the runtime — see the note in
|
|
24
|
+
* {@link file://./runtime.ts}. Awaited, because building it has to resolve
|
|
25
|
+
* `env.AI.gateway(id).getUrl()`, which is async.
|
|
26
|
+
*/
|
|
27
|
+
client: () => Anthropic | Promise<Anthropic>;
|
|
28
|
+
/** Anthropic model id, e.g. `claude-opus-5`. */
|
|
29
|
+
modelId: string;
|
|
30
|
+
/** `max_tokens` when a caller supplies none. Anthropic requires the field. */
|
|
31
|
+
defaultMaxTokens: number;
|
|
32
|
+
/** Prompt-cache TTL, or `false` to place no breakpoints. Defaults to `"5m"`. */
|
|
33
|
+
cache?: CacheTtl | false;
|
|
34
|
+
/**
|
|
35
|
+
* Reasoning effort, when the caller does not set one per-call.
|
|
36
|
+
*
|
|
37
|
+
* Maps to `output_config.effort`. Coding and agentic work wants `"xhigh"`;
|
|
38
|
+
* `"high"` is the API default. Core never picks this — an agent does.
|
|
39
|
+
*/
|
|
40
|
+
effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
41
|
+
/** Extra headers merged into every request (AI Gateway metadata lives here). */
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Recognise a deployment-specific authority in a `401`/`403` body.
|
|
45
|
+
*
|
|
46
|
+
* Consulted before the built-in shapes; return `undefined` to fall through to
|
|
47
|
+
* them. It exists because a deployment may put an authenticated intermediary
|
|
48
|
+
* between the gateway and Anthropic, and only that deployment knows what its
|
|
49
|
+
* refusal looks like — core recognising one particular proxy's error body
|
|
50
|
+
* would be exactly the deployment policy this package does not ship.
|
|
51
|
+
*
|
|
52
|
+
* The remedy is what makes it worth distinguishing at all: a proxy that mints
|
|
53
|
+
* its caller credential per request has no secret to rotate, so reporting its
|
|
54
|
+
* `401` as `credential` sends an operator to replace a working token. See
|
|
55
|
+
* {@link file://../errors.ts CredentialRejectedBy}.
|
|
56
|
+
*/
|
|
57
|
+
classifyAuthFailure?: (body: unknown) => CredentialRejectedBy | undefined;
|
|
58
|
+
}
|
|
59
|
+
export declare function createAnthropicLanguageModel(deps: AnthropicModelDeps): LanguageModelV4;
|
|
@@ -0,0 +1,442 @@
|
|
|
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
|
+
}
|