@sembl/provider-anthropic 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sembl contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # @sembl/provider-anthropic
2
+
3
+ Anthropic provider for SEMBL. Structured output is obtained by declaring the
4
+ target schema as a single tool and forcing the model to call it, so arguments
5
+ come back parsed and shape-checked by the API.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @sembl/core @sembl/provider-anthropic @anthropic-ai/sdk
11
+ ```
12
+
13
+ `@anthropic-ai/sdk` is a peer dependency: the host app owns the version and,
14
+ usually, the client instance.
15
+
16
+ ## Usage
17
+
18
+ ```ts
19
+ import { sembl, SemblConfig } from "@sembl/core";
20
+ import { AnthropicProvider } from "@sembl/provider-anthropic";
21
+
22
+ SemblConfig.configure({
23
+ provider: new AnthropicProvider({ model: "claude-sonnet-5", apiKey }),
24
+ bundle,
25
+ });
26
+
27
+ const draft = await sembl(listingHtml).partialCoerceTo(StayDetailsSchema);
28
+ ```
29
+
30
+ ### Bring your own client
31
+
32
+ When the host app already resolves credentials its own way — Secret Manager,
33
+ Vault, Bedrock, Vertex — pass the client instead of an API key. Anything with a
34
+ compatible `messages.create` works, including `AnthropicBedrock` and
35
+ `AnthropicVertex`.
36
+
37
+ ```ts
38
+ const provider = new AnthropicProvider({
39
+ model: "claude-sonnet-5",
40
+ client: await getAnthropic(), // your cached, secret-managed client
41
+ });
42
+ ```
43
+
44
+ ### Per-call model selection
45
+
46
+ `Provider` is cheap to construct and holds no connection state, so a service
47
+ that lets callers pick a model can build one per request over a shared client:
48
+
49
+ ```ts
50
+ const provider = new AnthropicProvider({ model: req.model, client });
51
+ await coerce(input, { provider, schema, bundle });
52
+ ```
53
+
54
+ ## Options
55
+
56
+ | Option | Default | Notes |
57
+ | ------------- | -------------------- | ------------------------------------------------------------ |
58
+ | `model` | — | Required. |
59
+ | `client` | — | Pre-built SDK client. Takes precedence over `apiKey`/`baseURL`. |
60
+ | `apiKey` | `ANTHROPIC_API_KEY` | Falls back to the SDK's own env lookup. |
61
+ | `baseURL` | Anthropic production | Ignored when `client` is set. |
62
+ | `temperature` | `0` | |
63
+ | `maxTokens` | `4096` | Anthropic requires an explicit output budget. |
64
+ | `toolName` | `extract_<SchemaId>` | Sanitized to Anthropic's `^[a-zA-Z0-9_-]{1,64}$`. |
65
+ | `cachePrompt` | `false` | Cache the stable prefix — tool definition plus system prompt. |
66
+ | `cacheTtl` | `"5m"` | `"5m"` or `"1h"`. Ignored unless `cachePrompt` is set. |
67
+ | `maxRetries` | `2` | Retries per call, handled by the SDK. |
68
+ | `timeoutMs` | `120000` | Per-attempt timeout. |
69
+
70
+ ## Prompt caching
71
+
72
+ Every call against the same schema sends the same tool definition and the same
73
+ system prompt; only the user input differs. `cachePrompt: true` marks that
74
+ prefix so the API processes it once and serves it from cache afterwards:
75
+
76
+ ```ts
77
+ const provider = new AnthropicProvider({
78
+ model: "claude-sonnet-5",
79
+ apiKey,
80
+ cachePrompt: true,
81
+ });
82
+ ```
83
+
84
+ One breakpoint does the whole job. The API renders `tools` before `system`, so
85
+ marking the trailing system block covers the tool definition too, and the user
86
+ input — the only part that varies — sits after it in `messages`, where it
87
+ invalidates nothing.
88
+
89
+ **When it pays.** Cache reads cost about a tenth of an ordinary input token,
90
+ but a write costs about a quarter more than one. Two calls sharing a prefix
91
+ already break even on the 5-minute TTL; a single one-off call is slightly worse
92
+ off than leaving caching alone. Bulk imports — hundreds of listings through one
93
+ schema — are the case this exists for.
94
+
95
+ **When it does nothing.** A prefix shorter than the model's minimum cacheable
96
+ length is silently not cached (no error, no write): the minimum is
97
+ model-dependent and ranges from 512 to 4096 tokens, so a handful of fields with
98
+ short descriptions may never reach it. Caches are also scoped per model and per
99
+ workspace, so splitting a batch across models writes a separate entry for each.
100
+
101
+ **Choosing the TTL.** A read refreshes the entry, so with calls less than five
102
+ minutes apart the default `"5m"` stays warm indefinitely and is the cheaper
103
+ write. `"1h"` is for traffic with longer gaps — it survives them, but the write
104
+ costs roughly twice as much, so it needs three or so calls to pay for itself.
105
+
106
+ **Check that it is working.** `ProviderResponse.usage` reports it:
107
+
108
+ ```ts
109
+ const { usage } = await provider.complete(request);
110
+ usage?.cacheWriteTokens; // prefix written to cache — expect this on call one
111
+ usage?.cacheReadTokens; // prefix served from cache — expect this after that
112
+ ```
113
+
114
+ A write on every call means something upstream is changing the prefix between
115
+ calls: a schema description built with a timestamp in it, non-deterministic
116
+ ordering of the enum values in a `dynamicEnum` field, a per-request tool name.
117
+ Note that Anthropic reports these *alongside* `promptTokens` rather than inside
118
+ it, so the prompt actually processed is the sum of the three.
119
+
120
+ ## Resilience
121
+
122
+ Retries and timeouts are the SDK's, not a layer on top of it. It already
123
+ retries connection errors, 408, 409, 429 and 5xx with exponential backoff and
124
+ jitter, and honours `retry-after`. This provider only picks the numbers:
125
+ `maxRetries` (2, the SDK's own default) and `timeoutMs` (2 minutes, down from
126
+ the SDK's 10 — a long time for one listing to hold up an import queue).
127
+
128
+ Each retry gets its own timeout, so the worst-case wall clock for a call is
129
+ roughly `timeoutMs * (maxRetries + 1)` plus backoff. Size it accordingly.
130
+
131
+ When you supply your own `client`, that client's transport policy is left
132
+ alone unless you set `maxRetries` or `timeoutMs` explicitly — in which case
133
+ they are applied per call, overriding the client for SEMBL's requests only.
134
+
135
+ ## Errors
136
+
137
+ Failures arrive as `AnthropicProviderError` with a `kind` you can branch on
138
+ instead of matching messages:
139
+
140
+ | `kind` | Means | `retryable` |
141
+ | ------------- | ---------------------------------------------- | ----------- |
142
+ | `"api"` | Transport or API failure — rate limit, overload, timeout, bad request | `true` when transient |
143
+ | `"truncated"` | Output hit the token cap mid-tool-call | `false` |
144
+ | `"no_output"` | The model answered without calling the extraction tool | `false` |
145
+
146
+ ```ts
147
+ try {
148
+ await coerce(listingHtml, { provider, schema, bundle });
149
+ } catch (error) {
150
+ if (error instanceof AnthropicProviderError && error.retryable) {
151
+ return requeue(listing); // rate limited or overloaded
152
+ }
153
+ throw error;
154
+ }
155
+ ```
156
+
157
+ `retryable` says the failure was transient in nature — the SDK has already
158
+ retried it `maxRetries` times, so this is about whether the item is worth
159
+ re-queueing rather than whether to loop immediately. The originating SDK error
160
+ is kept on `cause`, and `status` / `stopReason` carry the API's own account of
161
+ what happened. `@sembl/provider-openai` throws the same three `kind`s, so
162
+ caller code that branches on them survives a provider swap.
163
+
164
+ ## Schema dialect
165
+
166
+ Anthropic takes ordinary JSON Schema, so this provider emits the `"standard"`
167
+ dialect: optional fields are omitted from `required` rather than being made
168
+ nullable the way OpenAI structured outputs demand. That keeps the model from
169
+ inventing explicit `null`s for fields the source never mentioned — which
170
+ matters for `partialCoerce`, where an absent field and a null field mean
171
+ different things to the caller.
@@ -0,0 +1,162 @@
1
+ import { ProviderConfig, Provider, ProviderRequest, ProviderResponse, RuntimeSchema, SchemaBundle, ResolvedEnums } from '@sembl/core';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+
4
+ /**
5
+ * Configuration specific to the Anthropic provider.
6
+ *
7
+ * Supply either `client` or `apiKey`. Prefer `client` when the host app
8
+ * already resolves credentials its own way (Secret Manager, Vault, Bedrock,
9
+ * a Vertex client) — the provider will reuse that client as-is rather than
10
+ * constructing its own.
11
+ */
12
+ interface AnthropicProviderConfig extends ProviderConfig {
13
+ /**
14
+ * A pre-built Anthropic client. Takes precedence over `apiKey`/`baseURL`.
15
+ * Also accepts an `AnthropicBedrock` / `AnthropicVertex` client — anything
16
+ * exposing a compatible `messages.create`.
17
+ */
18
+ client?: Pick<Anthropic, "messages">;
19
+ /** Anthropic API key. Ignored when `client` is supplied. */
20
+ apiKey?: string;
21
+ /** Base URL override. Ignored when `client` is supplied. */
22
+ baseURL?: string;
23
+ /**
24
+ * Name given to the extraction tool the model is forced to call.
25
+ * Defaults to a sanitized form of the schema id. Only override this if a
26
+ * name shows up somewhere you care about (logs, prompt-cache keys).
27
+ */
28
+ toolName?: string;
29
+ /**
30
+ * Mark the stable prefix of the request — the tool definition and the
31
+ * system prompt — as cacheable, so a run of calls against the same schema
32
+ * pays to process it once instead of once per call.
33
+ *
34
+ * Off by default: a cache write costs more than an ordinary read of the
35
+ * same tokens, so a single call, or a prefix below the model's minimum
36
+ * cacheable length, comes out slightly behind. Turn it on for batches.
37
+ * `ProviderResponse.usage.cacheReadTokens` says whether it is paying off.
38
+ */
39
+ cachePrompt?: boolean;
40
+ /**
41
+ * Lifetime of the cached prefix. Ignored unless `cachePrompt` is set.
42
+ *
43
+ * `"5m"` (the default) is refreshed by every read, so back-to-back calls
44
+ * keep it alive indefinitely and it is the cheaper write. Choose `"1h"`
45
+ * only for traffic with gaps longer than five minutes between calls — it
46
+ * survives the gap, but the write costs roughly twice as much.
47
+ */
48
+ cacheTtl?: "5m" | "1h";
49
+ /**
50
+ * How many times the SDK retries a failed call before giving up. The SDK
51
+ * retries connection errors, 408/409/429 and 5xx with exponential backoff
52
+ * and honours `retry-after`, so there is nothing to hand-roll here.
53
+ *
54
+ * Defaults to {@link DEFAULT_MAX_RETRIES}. When a `client` is supplied,
55
+ * leaving this unset keeps that client's own policy.
56
+ */
57
+ maxRetries?: number;
58
+ /**
59
+ * Timeout for a single attempt, in milliseconds. Retries each get their
60
+ * own attempt, so the worst-case wall clock is roughly
61
+ * `timeoutMs * (maxRetries + 1)` plus backoff.
62
+ *
63
+ * Defaults to {@link DEFAULT_TIMEOUT_MS}. When a `client` is supplied,
64
+ * leaving this unset keeps that client's own policy.
65
+ */
66
+ timeoutMs?: number;
67
+ }
68
+ /** Anthropic requires an explicit output budget; this is used when none is set. */
69
+ declare const DEFAULT_MAX_TOKENS = 4096;
70
+ /** Matches the SDK's own default; stated here so it survives an SDK change. */
71
+ declare const DEFAULT_MAX_RETRIES = 2;
72
+ /**
73
+ * Two minutes per attempt. The SDK's own default is ten, which is a long time
74
+ * for a backend import to sit on one listing when the retry is cheap.
75
+ */
76
+ declare const DEFAULT_TIMEOUT_MS = 120000;
77
+
78
+ /**
79
+ * Anthropic provider implementation.
80
+ *
81
+ * Structured output is obtained by declaring the target schema as a single
82
+ * tool and forcing the model to call it (`tool_choice: { type: "tool" }`), so
83
+ * the arguments come back already parsed and shape-checked by the API — no
84
+ * JSON scraped out of prose.
85
+ *
86
+ * Retries and timeouts are the SDK's (exponential backoff, `retry-after`
87
+ * aware); this class only chooses the numbers and translates whatever comes
88
+ * back out into an {@link AnthropicProviderError}.
89
+ */
90
+ declare class AnthropicProvider implements Provider {
91
+ private client;
92
+ private config;
93
+ private callOptions;
94
+ constructor(config: AnthropicProviderConfig);
95
+ complete(request: ProviderRequest): Promise<ProviderResponse>;
96
+ /**
97
+ * The system prompt, marked as a cache breakpoint when caching is on.
98
+ *
99
+ * One breakpoint is enough: the API renders `tools` before `system`, so a
100
+ * marker on the trailing system block covers the tool definition too — and
101
+ * the user input, the only part that changes between calls, sits after it
102
+ * in `messages` where it invalidates nothing.
103
+ */
104
+ private buildSystem;
105
+ /** Issue the call, translating SDK failures into typed provider errors. */
106
+ private send;
107
+ }
108
+
109
+ /**
110
+ * Why a provider call failed, in the terms a caller can act on.
111
+ *
112
+ * A batch import wants to route these differently: `"api"` failures are worth
113
+ * re-queueing, `"truncated"` needs a bigger output budget, and `"no_output"`
114
+ * is a property of that one listing's content — retrying it changes nothing.
115
+ *
116
+ * The same three kinds are used by `@sembl/provider-openai`, so a caller that
117
+ * branches on `kind` keeps working when the provider is swapped.
118
+ */
119
+ type ProviderErrorKind = "api" | "truncated" | "no_output";
120
+ /**
121
+ * Error thrown by the Anthropic provider.
122
+ *
123
+ * Branch on `kind` rather than matching the message — messages stay
124
+ * diagnostic and are free to change.
125
+ */
126
+ declare class AnthropicProviderError extends Error {
127
+ /** What class of failure this is. */
128
+ readonly kind: ProviderErrorKind;
129
+ /**
130
+ * Whether another attempt could plausibly succeed. The SDK has already
131
+ * retried retryable transport failures (see `maxRetries`); this says only
132
+ * that the failure was transient in nature, so a caller running a queue can
133
+ * re-enqueue the item rather than dead-letter it.
134
+ */
135
+ readonly retryable: boolean;
136
+ /** HTTP status, when the failure came back as an API error. */
137
+ readonly status?: number;
138
+ /** Anthropic's `stop_reason`, when the call returned a message we rejected. */
139
+ readonly stopReason?: string;
140
+ constructor(message: string, options: {
141
+ kind: ProviderErrorKind;
142
+ retryable: boolean;
143
+ status?: number;
144
+ stopReason?: string;
145
+ cause?: unknown;
146
+ });
147
+ }
148
+
149
+ /** Anthropic tool names must match `^[a-zA-Z0-9_-]{1,64}$`. */
150
+ declare function toToolName(schemaId: string): string;
151
+ /**
152
+ * Convert a RuntimeSchema to an Anthropic tool `input_schema`.
153
+ *
154
+ * Unlike OpenAI structured outputs, Anthropic takes ordinary JSON Schema, so
155
+ * optional fields are left out of `required` instead of being made nullable.
156
+ * That keeps the model from inventing explicit `null`s for fields the source
157
+ * text simply never mentioned — which matters for partial coercion, where an
158
+ * absent field and a null field mean different things to the caller.
159
+ */
160
+ declare function toInputSchema(schema: RuntimeSchema, bundle?: SchemaBundle, resolvedEnums?: ResolvedEnums): Record<string, unknown>;
161
+
162
+ export { AnthropicProvider, type AnthropicProviderConfig, AnthropicProviderError, DEFAULT_MAX_RETRIES, DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_MS, type ProviderErrorKind, toInputSchema, toToolName };
package/dist/index.js ADDED
@@ -0,0 +1,178 @@
1
+ // src/anthropic-provider.ts
2
+ import Anthropic from "@anthropic-ai/sdk";
3
+
4
+ // src/anthropic-config.ts
5
+ var DEFAULT_MAX_TOKENS = 4096;
6
+ var DEFAULT_MAX_RETRIES = 2;
7
+ var DEFAULT_TIMEOUT_MS = 12e4;
8
+
9
+ // src/errors.ts
10
+ import { APIConnectionError, APIError } from "@anthropic-ai/sdk";
11
+ var AnthropicProviderError = class extends Error {
12
+ /** What class of failure this is. */
13
+ kind;
14
+ /**
15
+ * Whether another attempt could plausibly succeed. The SDK has already
16
+ * retried retryable transport failures (see `maxRetries`); this says only
17
+ * that the failure was transient in nature, so a caller running a queue can
18
+ * re-enqueue the item rather than dead-letter it.
19
+ */
20
+ retryable;
21
+ /** HTTP status, when the failure came back as an API error. */
22
+ status;
23
+ /** Anthropic's `stop_reason`, when the call returned a message we rejected. */
24
+ stopReason;
25
+ constructor(message, options) {
26
+ super(message, { cause: options.cause });
27
+ this.name = "AnthropicProviderError";
28
+ this.kind = options.kind;
29
+ this.retryable = options.retryable;
30
+ this.status = options.status;
31
+ this.stopReason = options.stopReason;
32
+ }
33
+ };
34
+ function toProviderError(error) {
35
+ if (error instanceof APIError) {
36
+ const status = error.status;
37
+ const retryable = error instanceof APIConnectionError || status === void 0 || status === 408 || status === 409 || status === 429 || status >= 500;
38
+ return new AnthropicProviderError(
39
+ `Anthropic request failed${status ? ` (${status})` : ""}: ${error.message}`,
40
+ { kind: "api", retryable, status, cause: error }
41
+ );
42
+ }
43
+ return new AnthropicProviderError(
44
+ `Anthropic request failed: ${error instanceof Error ? error.message : String(error)}`,
45
+ { kind: "api", retryable: false, cause: error }
46
+ );
47
+ }
48
+
49
+ // src/schema-converter.ts
50
+ import { runtimeSchemaToJsonSchema } from "@sembl/core";
51
+ function toToolName(schemaId) {
52
+ const cleaned = schemaId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 57);
53
+ return `extract_${cleaned || "schema"}`.slice(0, 64);
54
+ }
55
+ function toInputSchema(schema, bundle, resolvedEnums) {
56
+ return runtimeSchemaToJsonSchema(schema, bundle, {
57
+ dialect: "standard",
58
+ resolvedEnums
59
+ });
60
+ }
61
+
62
+ // src/anthropic-provider.ts
63
+ var AnthropicProvider = class {
64
+ client;
65
+ config;
66
+ callOptions;
67
+ constructor(config) {
68
+ this.config = config;
69
+ if (config.client) {
70
+ this.client = config.client;
71
+ this.callOptions = config.maxRetries === void 0 && config.timeoutMs === void 0 ? void 0 : { maxRetries: config.maxRetries, timeout: config.timeoutMs };
72
+ } else {
73
+ this.client = new Anthropic({
74
+ apiKey: config.apiKey,
75
+ baseURL: config.baseURL,
76
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
77
+ timeout: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
78
+ });
79
+ this.callOptions = void 0;
80
+ }
81
+ }
82
+ async complete(request) {
83
+ const toolName = this.config.toolName ?? toToolName(request.schema.id);
84
+ const maxTokens = this.config.maxTokens ?? DEFAULT_MAX_TOKENS;
85
+ const inputSchema = toInputSchema(
86
+ request.schema,
87
+ request.bundle,
88
+ request.resolvedEnums
89
+ );
90
+ const message = await this.send(
91
+ {
92
+ model: this.config.model,
93
+ max_tokens: maxTokens,
94
+ temperature: this.config.temperature ?? 0,
95
+ system: this.buildSystem(request.systemPrompt),
96
+ messages: [{ role: "user", content: request.userInput }],
97
+ tools: [
98
+ {
99
+ name: toolName,
100
+ description: request.schema.description,
101
+ input_schema: inputSchema
102
+ }
103
+ ],
104
+ tool_choice: { type: "tool", name: toolName }
105
+ },
106
+ this.callOptions
107
+ );
108
+ const toolUse = message.content.find(
109
+ (block) => block.type === "tool_use" && block.name === toolName
110
+ );
111
+ if (!toolUse) {
112
+ if (message.stop_reason === "max_tokens") {
113
+ throw new AnthropicProviderError(
114
+ `Anthropic hit the ${maxTokens}-token output cap before completing the "${toolName}" call. Raise maxTokens, or coerce into a smaller schema.`,
115
+ { kind: "truncated", retryable: false, stopReason: "max_tokens" }
116
+ );
117
+ }
118
+ throw new AnthropicProviderError(
119
+ `Anthropic returned no "${toolName}" tool call (stop_reason: ${message.stop_reason ?? "unknown"})`,
120
+ {
121
+ kind: "no_output",
122
+ retryable: false,
123
+ stopReason: message.stop_reason ?? void 0
124
+ }
125
+ );
126
+ }
127
+ return {
128
+ data: toolUse.input,
129
+ usage: {
130
+ promptTokens: message.usage.input_tokens,
131
+ completionTokens: message.usage.output_tokens,
132
+ totalTokens: message.usage.input_tokens + message.usage.output_tokens,
133
+ ...message.usage.cache_read_input_tokens != null && {
134
+ cacheReadTokens: message.usage.cache_read_input_tokens
135
+ },
136
+ ...message.usage.cache_creation_input_tokens != null && {
137
+ cacheWriteTokens: message.usage.cache_creation_input_tokens
138
+ }
139
+ }
140
+ };
141
+ }
142
+ /**
143
+ * The system prompt, marked as a cache breakpoint when caching is on.
144
+ *
145
+ * One breakpoint is enough: the API renders `tools` before `system`, so a
146
+ * marker on the trailing system block covers the tool definition too — and
147
+ * the user input, the only part that changes between calls, sits after it
148
+ * in `messages` where it invalidates nothing.
149
+ */
150
+ buildSystem(systemPrompt) {
151
+ if (!this.config.cachePrompt) return systemPrompt;
152
+ return [
153
+ {
154
+ type: "text",
155
+ text: systemPrompt,
156
+ cache_control: { type: "ephemeral", ttl: this.config.cacheTtl ?? "5m" }
157
+ }
158
+ ];
159
+ }
160
+ /** Issue the call, translating SDK failures into typed provider errors. */
161
+ async send(body, options) {
162
+ try {
163
+ return await this.client.messages.create(body, options);
164
+ } catch (error) {
165
+ throw toProviderError(error);
166
+ }
167
+ }
168
+ };
169
+ export {
170
+ AnthropicProvider,
171
+ AnthropicProviderError,
172
+ DEFAULT_MAX_RETRIES,
173
+ DEFAULT_MAX_TOKENS,
174
+ DEFAULT_TIMEOUT_MS,
175
+ toInputSchema,
176
+ toToolName
177
+ };
178
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/anthropic-provider.ts","../src/anthropic-config.ts","../src/errors.ts","../src/schema-converter.ts"],"sourcesContent":["import Anthropic from \"@anthropic-ai/sdk\";\nimport type { Provider, ProviderRequest, ProviderResponse } from \"@sembl/core\";\nimport type { AnthropicProviderConfig } from \"./anthropic-config.js\";\nimport {\n DEFAULT_MAX_RETRIES,\n DEFAULT_MAX_TOKENS,\n DEFAULT_TIMEOUT_MS,\n} from \"./anthropic-config.js\";\nimport { AnthropicProviderError, toProviderError } from \"./errors.js\";\nimport { toInputSchema, toToolName } from \"./schema-converter.js\";\n\n/** Per-call overrides handed to the SDK alongside the request body. */\ninterface CallOptions {\n maxRetries?: number;\n timeout?: number;\n}\n\n/**\n * Anthropic provider implementation.\n *\n * Structured output is obtained by declaring the target schema as a single\n * tool and forcing the model to call it (`tool_choice: { type: \"tool\" }`), so\n * the arguments come back already parsed and shape-checked by the API — no\n * JSON scraped out of prose.\n *\n * Retries and timeouts are the SDK's (exponential backoff, `retry-after`\n * aware); this class only chooses the numbers and translates whatever comes\n * back out into an {@link AnthropicProviderError}.\n */\nexport class AnthropicProvider implements Provider {\n private client: Pick<Anthropic, \"messages\">;\n private config: AnthropicProviderConfig;\n private callOptions: CallOptions | undefined;\n\n constructor(config: AnthropicProviderConfig) {\n this.config = config;\n\n if (config.client) {\n // The host owns this client's transport policy, so only override it\n // per-call where the caller asked for something specific.\n this.client = config.client;\n this.callOptions =\n config.maxRetries === undefined && config.timeoutMs === undefined\n ? undefined\n : { maxRetries: config.maxRetries, timeout: config.timeoutMs };\n } else {\n this.client = new Anthropic({\n apiKey: config.apiKey,\n baseURL: config.baseURL,\n maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,\n timeout: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,\n });\n this.callOptions = undefined;\n }\n }\n\n async complete(request: ProviderRequest): Promise<ProviderResponse> {\n const toolName = this.config.toolName ?? toToolName(request.schema.id);\n const maxTokens = this.config.maxTokens ?? DEFAULT_MAX_TOKENS;\n const inputSchema = toInputSchema(\n request.schema,\n request.bundle,\n request.resolvedEnums,\n );\n\n const message = await this.send(\n {\n model: this.config.model,\n max_tokens: maxTokens,\n temperature: this.config.temperature ?? 0,\n system: this.buildSystem(request.systemPrompt),\n messages: [{ role: \"user\", content: request.userInput }],\n tools: [\n {\n name: toolName,\n description: request.schema.description,\n input_schema: inputSchema as Anthropic.Tool[\"input_schema\"],\n },\n ],\n tool_choice: { type: \"tool\", name: toolName },\n },\n this.callOptions,\n );\n\n const toolUse = message.content.find(\n (block): block is Anthropic.ToolUseBlock =>\n block.type === \"tool_use\" && block.name === toolName,\n );\n\n if (!toolUse) {\n if (message.stop_reason === \"max_tokens\") {\n throw new AnthropicProviderError(\n `Anthropic hit the ${maxTokens}-token output cap before completing the \"${toolName}\" call. ` +\n \"Raise maxTokens, or coerce into a smaller schema.\",\n { kind: \"truncated\", retryable: false, stopReason: \"max_tokens\" },\n );\n }\n throw new AnthropicProviderError(\n `Anthropic returned no \"${toolName}\" tool call (stop_reason: ${message.stop_reason ?? \"unknown\"})`,\n {\n kind: \"no_output\",\n retryable: false,\n stopReason: message.stop_reason ?? undefined,\n },\n );\n }\n\n return {\n data: toolUse.input as Record<string, unknown>,\n usage: {\n promptTokens: message.usage.input_tokens,\n completionTokens: message.usage.output_tokens,\n totalTokens: message.usage.input_tokens + message.usage.output_tokens,\n ...(message.usage.cache_read_input_tokens != null && {\n cacheReadTokens: message.usage.cache_read_input_tokens,\n }),\n ...(message.usage.cache_creation_input_tokens != null && {\n cacheWriteTokens: message.usage.cache_creation_input_tokens,\n }),\n },\n };\n }\n\n /**\n * The system prompt, marked as a cache breakpoint when caching is on.\n *\n * One breakpoint is enough: the API renders `tools` before `system`, so a\n * marker on the trailing system block covers the tool definition too — and\n * the user input, the only part that changes between calls, sits after it\n * in `messages` where it invalidates nothing.\n */\n private buildSystem(\n systemPrompt: string,\n ): string | Anthropic.TextBlockParam[] {\n if (!this.config.cachePrompt) return systemPrompt;\n\n return [\n {\n type: \"text\",\n text: systemPrompt,\n cache_control: { type: \"ephemeral\", ttl: this.config.cacheTtl ?? \"5m\" },\n },\n ];\n }\n\n /** Issue the call, translating SDK failures into typed provider errors. */\n private async send(\n body: Anthropic.MessageCreateParamsNonStreaming,\n options: CallOptions | undefined,\n ): Promise<Anthropic.Message> {\n try {\n return await this.client.messages.create(body, options);\n } catch (error) {\n throw toProviderError(error);\n }\n }\n}\n","import type Anthropic from \"@anthropic-ai/sdk\";\nimport type { ProviderConfig } from \"@sembl/core\";\n\n/**\n * Configuration specific to the Anthropic provider.\n *\n * Supply either `client` or `apiKey`. Prefer `client` when the host app\n * already resolves credentials its own way (Secret Manager, Vault, Bedrock,\n * a Vertex client) — the provider will reuse that client as-is rather than\n * constructing its own.\n */\nexport interface AnthropicProviderConfig extends ProviderConfig {\n /**\n * A pre-built Anthropic client. Takes precedence over `apiKey`/`baseURL`.\n * Also accepts an `AnthropicBedrock` / `AnthropicVertex` client — anything\n * exposing a compatible `messages.create`.\n */\n client?: Pick<Anthropic, \"messages\">;\n /** Anthropic API key. Ignored when `client` is supplied. */\n apiKey?: string;\n /** Base URL override. Ignored when `client` is supplied. */\n baseURL?: string;\n /**\n * Name given to the extraction tool the model is forced to call.\n * Defaults to a sanitized form of the schema id. Only override this if a\n * name shows up somewhere you care about (logs, prompt-cache keys).\n */\n toolName?: string;\n /**\n * Mark the stable prefix of the request — the tool definition and the\n * system prompt — as cacheable, so a run of calls against the same schema\n * pays to process it once instead of once per call.\n *\n * Off by default: a cache write costs more than an ordinary read of the\n * same tokens, so a single call, or a prefix below the model's minimum\n * cacheable length, comes out slightly behind. Turn it on for batches.\n * `ProviderResponse.usage.cacheReadTokens` says whether it is paying off.\n */\n cachePrompt?: boolean;\n /**\n * Lifetime of the cached prefix. Ignored unless `cachePrompt` is set.\n *\n * `\"5m\"` (the default) is refreshed by every read, so back-to-back calls\n * keep it alive indefinitely and it is the cheaper write. Choose `\"1h\"`\n * only for traffic with gaps longer than five minutes between calls — it\n * survives the gap, but the write costs roughly twice as much.\n */\n cacheTtl?: \"5m\" | \"1h\";\n /**\n * How many times the SDK retries a failed call before giving up. The SDK\n * retries connection errors, 408/409/429 and 5xx with exponential backoff\n * and honours `retry-after`, so there is nothing to hand-roll here.\n *\n * Defaults to {@link DEFAULT_MAX_RETRIES}. When a `client` is supplied,\n * leaving this unset keeps that client's own policy.\n */\n maxRetries?: number;\n /**\n * Timeout for a single attempt, in milliseconds. Retries each get their\n * own attempt, so the worst-case wall clock is roughly\n * `timeoutMs * (maxRetries + 1)` plus backoff.\n *\n * Defaults to {@link DEFAULT_TIMEOUT_MS}. When a `client` is supplied,\n * leaving this unset keeps that client's own policy.\n */\n timeoutMs?: number;\n}\n\n/** Anthropic requires an explicit output budget; this is used when none is set. */\nexport const DEFAULT_MAX_TOKENS = 4096;\n\n/** Matches the SDK's own default; stated here so it survives an SDK change. */\nexport const DEFAULT_MAX_RETRIES = 2;\n\n/**\n * Two minutes per attempt. The SDK's own default is ten, which is a long time\n * for a backend import to sit on one listing when the retry is cheap.\n */\nexport const DEFAULT_TIMEOUT_MS = 120_000;\n","import { APIConnectionError, APIError } from \"@anthropic-ai/sdk\";\n\n/**\n * Why a provider call failed, in the terms a caller can act on.\n *\n * A batch import wants to route these differently: `\"api\"` failures are worth\n * re-queueing, `\"truncated\"` needs a bigger output budget, and `\"no_output\"`\n * is a property of that one listing's content — retrying it changes nothing.\n *\n * The same three kinds are used by `@sembl/provider-openai`, so a caller that\n * branches on `kind` keeps working when the provider is swapped.\n */\nexport type ProviderErrorKind = \"api\" | \"truncated\" | \"no_output\";\n\n/**\n * Error thrown by the Anthropic provider.\n *\n * Branch on `kind` rather than matching the message — messages stay\n * diagnostic and are free to change.\n */\nexport class AnthropicProviderError extends Error {\n /** What class of failure this is. */\n public readonly kind: ProviderErrorKind;\n /**\n * Whether another attempt could plausibly succeed. The SDK has already\n * retried retryable transport failures (see `maxRetries`); this says only\n * that the failure was transient in nature, so a caller running a queue can\n * re-enqueue the item rather than dead-letter it.\n */\n public readonly retryable: boolean;\n /** HTTP status, when the failure came back as an API error. */\n public readonly status?: number;\n /** Anthropic's `stop_reason`, when the call returned a message we rejected. */\n public readonly stopReason?: string;\n\n constructor(\n message: string,\n options: {\n kind: ProviderErrorKind;\n retryable: boolean;\n status?: number;\n stopReason?: string;\n cause?: unknown;\n },\n ) {\n super(message, { cause: options.cause });\n this.name = \"AnthropicProviderError\";\n this.kind = options.kind;\n this.retryable = options.retryable;\n this.status = options.status;\n this.stopReason = options.stopReason;\n }\n}\n\n/**\n * Wrap an SDK-level failure as an `AnthropicProviderError`.\n *\n * Retryability is read off the SDK's own error classes rather than the\n * message: connection failures and timeouts are transient by construction,\n * and of the status codes only 408/409/429 and 5xx are worth another attempt —\n * the same set the SDK itself retries internally.\n */\nexport function toProviderError(error: unknown): AnthropicProviderError {\n if (error instanceof APIError) {\n const status = error.status;\n const retryable =\n error instanceof APIConnectionError ||\n status === undefined ||\n status === 408 ||\n status === 409 ||\n status === 429 ||\n status >= 500;\n\n return new AnthropicProviderError(\n `Anthropic request failed${status ? ` (${status})` : \"\"}: ${error.message}`,\n { kind: \"api\", retryable, status, cause: error },\n );\n }\n\n // Anything else (an AbortError, a bug in a caller-supplied client) is\n // surfaced with the same shape so callers only need one catch.\n return new AnthropicProviderError(\n `Anthropic request failed: ${error instanceof Error ? error.message : String(error)}`,\n { kind: \"api\", retryable: false, cause: error },\n );\n}\n","import type { RuntimeSchema, ResolvedEnums, SchemaBundle } from \"@sembl/core\";\nimport { runtimeSchemaToJsonSchema } from \"@sembl/core\";\n\n/** Anthropic tool names must match `^[a-zA-Z0-9_-]{1,64}$`. */\nexport function toToolName(schemaId: string): string {\n const cleaned = schemaId.replace(/[^a-zA-Z0-9_-]/g, \"_\").slice(0, 57);\n return `extract_${cleaned || \"schema\"}`.slice(0, 64);\n}\n\n/**\n * Convert a RuntimeSchema to an Anthropic tool `input_schema`.\n *\n * Unlike OpenAI structured outputs, Anthropic takes ordinary JSON Schema, so\n * optional fields are left out of `required` instead of being made nullable.\n * That keeps the model from inventing explicit `null`s for fields the source\n * text simply never mentioned — which matters for partial coercion, where an\n * absent field and a null field mean different things to the caller.\n */\nexport function toInputSchema(\n schema: RuntimeSchema,\n bundle?: SchemaBundle,\n resolvedEnums?: ResolvedEnums,\n): Record<string, unknown> {\n return runtimeSchemaToJsonSchema(schema, bundle, {\n dialect: \"standard\",\n resolvedEnums,\n });\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@sembl/provider-anthropic",
3
+ "version": "0.1.0",
4
+ "description": "Anthropic provider for SEMBL, using forced tool calls for structured output.",
5
+ "keywords": [
6
+ "llm",
7
+ "structured-output",
8
+ "anthropic",
9
+ "claude",
10
+ "tool-use",
11
+ "extraction"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Sembl contributors",
15
+ "homepage": "https://github.com/nickrunner/sembl#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/nickrunner/sembl/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/nickrunner/sembl.git",
22
+ "directory": "packages/provider-anthropic"
23
+ },
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "sideEffects": false,
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "dependencies": {
47
+ "@sembl/core": "0.1.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@anthropic-ai/sdk": ">=0.30.0"
51
+ },
52
+ "devDependencies": {
53
+ "@anthropic-ai/sdk": "^0.65.0",
54
+ "tsup": "^8.0.0",
55
+ "typescript": "^5.5.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsup",
59
+ "dev": "tsup --watch"
60
+ }
61
+ }