@intx/inference-discovery-anthropic 0.1.2

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 ADDED
@@ -0,0 +1,69 @@
1
+ # @intx/inference-discovery-anthropic
2
+
3
+ Anthropic provider plug-in for the discovery rig. Captures Claude
4
+ wire responses across streaming and non-streaming variants of each
5
+ capability and writes them into the shared fixture corpus.
6
+
7
+ See [`@intx/inference-discovery`](../inference-discovery/README.md)
8
+ for the runtime, the plug-in contract, and the `discover` CLI.
9
+
10
+ ## Models
11
+
12
+ - `claude-sonnet-4-5-20250929`
13
+ - `claude-opus-4-1-20250805`
14
+ - `claude-haiku-4-5-20251001`
15
+
16
+ The full per-capability list is in `SUPPORT_MATRIX` in
17
+ `@intx/inference-discovery/catalog`.
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { createAnthropicPlugin } from "@intx/inference-discovery-anthropic";
23
+
24
+ const plugin = createAnthropicPlugin({ apiKey: process.env.ANTHROPIC_API_KEY });
25
+ // Hand off to runCapture from @intx/inference-discovery.
26
+ ```
27
+
28
+ In practice the `bin/discover.ts` CLI does this wiring for you;
29
+ construct the plug-in directly only when writing tests or one-off
30
+ scripts.
31
+
32
+ ## Environment
33
+
34
+ | Variable | Purpose |
35
+ | ------------------- | ------------------------------- |
36
+ | `ANTHROPIC_API_KEY` | Sent as the `x-api-key` header. |
37
+
38
+ The key is redacted in captured fixtures.
39
+
40
+ ## Multi-step capabilities
41
+
42
+ Three capability families drive multi-step exchanges before the
43
+ runner writes the bundle:
44
+
45
+ - **Files API** (`files-api-reference`,
46
+ `files-api-reference-streaming`) — the plug-in first uploads the
47
+ intent's media asset to the Anthropic files endpoint, then uses
48
+ the returned file id to construct the messages body for the
49
+ second step. Each step writes its own `upload/` and `generate/`
50
+ subdirectory under the run root.
51
+ - **Multi-turn function calling**
52
+ (`function-calling-multi-turn`,
53
+ `function-calling-multi-turn-streaming`,
54
+ `function-calling-with-thinking`,
55
+ `function-calling-with-thinking-streaming`) — turn 1 is sent as
56
+ usual; the plug-in extracts the model's content blocks from the
57
+ parsed response and sends a turn-2 body that echoes the
58
+ assistant turn verbatim and appends the tool result. Each turn
59
+ writes its own `turn-1/` and `turn-2/` subdirectory.
60
+ - **Redacted thinking**
61
+ (`redacted-thinking`, `redacted-thinking-streaming`) — turn 1
62
+ produces a redacted thinking block that turn 2 must echo back
63
+ verbatim; the plug-in extracts the redacted block from the
64
+ response and assembles the turn-2 body around it.
65
+
66
+ The Files API and code-execution capabilities additionally set
67
+ per-step beta-flag headers (`files-api-2025-04-14`,
68
+ `code-execution-2025-05-22`). All other capabilities are
69
+ single-step.
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@intx/inference-discovery-anthropic",
3
+ "version": "0.1.2",
4
+ "license": "LGPL-2.1-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@intx/inference-discovery": "0.0.0",
14
+ "arktype": "^2.1.29"
15
+ }
16
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,17 @@
1
+ // API version pin recommended by Anthropic for first-party clients. New
2
+ // versions opt in to wire-shape changes; we pin so captures stay stable
3
+ // across upstream rollouts and only move under a deliberate bump here.
4
+ export const ANTHROPIC_VERSION = "2023-06-01";
5
+
6
+ export const API_KEY_HEADER = "x-api-key";
7
+ export const VERSION_HEADER = "anthropic-version";
8
+
9
+ export function buildAuthHeaders(apiKey: string): Record<string, string> {
10
+ if (apiKey.length === 0) {
11
+ throw new Error("anthropic: apiKey must be a non-empty string");
12
+ }
13
+ return {
14
+ [API_KEY_HEADER]: apiKey,
15
+ [VERSION_HEADER]: ANTHROPIC_VERSION,
16
+ };
17
+ }
@@ -0,0 +1,19 @@
1
+ import type { Capability } from "@intx/inference-discovery/catalog";
2
+
3
+ export const ANTHROPIC_BASE = "https://api.anthropic.com";
4
+ export const MESSAGES_PATH = "/v1/messages";
5
+ export const FILES_PATH = "/v1/files";
6
+
7
+ // Both streaming and non-streaming requests target /v1/messages; the
8
+ // `stream: true` field in the request body is what distinguishes them.
9
+ export function buildMessagesURL(): string {
10
+ return `${ANTHROPIC_BASE}${MESSAGES_PATH}`;
11
+ }
12
+
13
+ export function buildFilesURL(): string {
14
+ return `${ANTHROPIC_BASE}${FILES_PATH}`;
15
+ }
16
+
17
+ export function isStreamingCapability(capability: Capability): boolean {
18
+ return capability.endsWith("-streaming");
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,308 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ resolveMediaPath,
5
+ type Capability,
6
+ type CapabilityIntent,
7
+ } from "@intx/inference-discovery/catalog";
8
+ import type {
9
+ CaptureStep,
10
+ CapturedResponse,
11
+ IterateCaptureStepsOpts,
12
+ ProviderPlugin,
13
+ } from "@intx/inference-discovery";
14
+ import { buildAuthHeaders } from "./auth";
15
+ import {
16
+ buildFilesURL,
17
+ buildMessagesURL,
18
+ isStreamingCapability,
19
+ } from "./endpoint";
20
+ import { mediaTypeFor } from "./media";
21
+ import {
22
+ buildFilesApiGenerateBody,
23
+ buildFunctionCallingTurn2Body,
24
+ buildRedactedThinkingTurn2Body,
25
+ buildRequestBody,
26
+ } from "./request-body";
27
+ import { extractReasoningTrace } from "./reasoning";
28
+ import { extractContentBlocksFromSSE } from "./sse";
29
+
30
+ const PROVIDER_NAME = "anthropic";
31
+
32
+ const MODELS = [
33
+ "claude-sonnet-4-5-20250929",
34
+ "claude-opus-4-1-20250805",
35
+ "claude-haiku-4-5-20251001",
36
+ ] as const;
37
+
38
+ const REDACT_REQUEST_HEADERS = ["x-api-key"] as const;
39
+ const REDACT_RESPONSE_HEADERS: readonly string[] = [];
40
+
41
+ // Beta-flag header markers Anthropic requires for opt-in features.
42
+ // These live on per-step headers (not in buildAuthHeaders) because they
43
+ // apply per-request, not per-plug-in; auth headers are plug-in-wide.
44
+ const FILES_API_BETA = "files-api-2025-04-14";
45
+ const CODE_EXECUTION_BETA = "code-execution-2025-05-22";
46
+
47
+ const FUNCTION_CALLING_MULTI_TURN_CAPABILITIES: ReadonlySet<Capability> =
48
+ new Set<Capability>([
49
+ "function-calling-multi-turn",
50
+ "function-calling-multi-turn-streaming",
51
+ "function-calling-with-thinking",
52
+ "function-calling-with-thinking-streaming",
53
+ ]);
54
+
55
+ const REDACTED_THINKING_CAPABILITIES: ReadonlySet<Capability> =
56
+ new Set<Capability>(["redacted-thinking", "redacted-thinking-streaming"]);
57
+
58
+ const FILES_API_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
59
+ "files-api-reference",
60
+ "files-api-reference-streaming",
61
+ ]);
62
+
63
+ const CODE_EXECUTION_CAPABILITIES: ReadonlySet<Capability> =
64
+ new Set<Capability>(["code-execution", "code-execution-streaming"]);
65
+
66
+ export interface AnthropicPluginOptions {
67
+ apiKey: string;
68
+ }
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+
74
+ function basename(p: string): string {
75
+ const slash = p.lastIndexOf("/");
76
+ return slash < 0 ? p : p.slice(slash + 1);
77
+ }
78
+
79
+ function perCapabilityHeaders(
80
+ capability: Capability,
81
+ ): Record<string, string> | undefined {
82
+ if (FILES_API_CAPABILITIES.has(capability)) {
83
+ return { "anthropic-beta": FILES_API_BETA };
84
+ }
85
+ if (CODE_EXECUTION_CAPABILITIES.has(capability)) {
86
+ return { "anthropic-beta": CODE_EXECUTION_BETA };
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ function withPerCapabilityHeaders(
92
+ capability: Capability,
93
+ step: CaptureStep,
94
+ ): CaptureStep {
95
+ const extra = perCapabilityHeaders(capability);
96
+ if (extra === undefined) return step;
97
+ return { ...step, headers: { ...(step.headers ?? {}), ...extra } };
98
+ }
99
+
100
+ interface MultipartUpload {
101
+ contentType: string;
102
+ body: Uint8Array;
103
+ }
104
+
105
+ function buildMultipartUpload(opts: {
106
+ fieldName: string;
107
+ filename: string;
108
+ contentType: string;
109
+ bytes: Uint8Array;
110
+ }): MultipartUpload {
111
+ // Boundary is regenerated on every call, which means a captured
112
+ // request-headers.json carries a run-specific Content-Type header.
113
+ // Byte-diffing regenerated fixtures against committed ones will
114
+ // always differ on the boundary; structural comparison is the right
115
+ // tool for files-api fixture equivalence.
116
+ const boundary = `----intx-anthropic-${randomUUID().replace(/-/g, "")}`;
117
+ const encoder = new TextEncoder();
118
+ const preamble = encoder.encode(
119
+ `--${boundary}\r\n` +
120
+ `Content-Disposition: form-data; name="${opts.fieldName}"; filename="${opts.filename}"\r\n` +
121
+ `Content-Type: ${opts.contentType}\r\n\r\n`,
122
+ );
123
+ const epilogue = encoder.encode(`\r\n--${boundary}--\r\n`);
124
+ const body = new Uint8Array(
125
+ preamble.length + opts.bytes.length + epilogue.length,
126
+ );
127
+ body.set(preamble, 0);
128
+ body.set(opts.bytes, preamble.length);
129
+ body.set(epilogue, preamble.length + opts.bytes.length);
130
+ return {
131
+ contentType: `multipart/form-data; boundary=${boundary}`,
132
+ body,
133
+ };
134
+ }
135
+
136
+ function buildFilesUploadStep(intent: CapabilityIntent): {
137
+ step: CaptureStep;
138
+ filename: string;
139
+ mediaType: string;
140
+ } {
141
+ const media = intent.media?.[0];
142
+ if (media === undefined) {
143
+ throw new Error(
144
+ "anthropic files-api: intent.media[0] is required; the catalog's " +
145
+ "files-api-reference intent must declare the document to upload.",
146
+ );
147
+ }
148
+ const buf = readFileSync(resolveMediaPath(media));
149
+ const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
150
+ const filename = basename(media.path);
151
+ const mediaType = mediaTypeFor(media);
152
+ const multipart = buildMultipartUpload({
153
+ fieldName: "file",
154
+ filename,
155
+ contentType: mediaType,
156
+ bytes,
157
+ });
158
+ return {
159
+ step: {
160
+ kind: "raw",
161
+ subdir: "upload",
162
+ url: buildFilesURL(),
163
+ method: "POST",
164
+ contentType: multipart.contentType,
165
+ headers: { "anthropic-beta": FILES_API_BETA },
166
+ body: multipart.body,
167
+ },
168
+ filename,
169
+ mediaType,
170
+ };
171
+ }
172
+
173
+ function extractFileId(parsed: unknown): string {
174
+ if (!isRecord(parsed)) {
175
+ throw new Error(
176
+ "anthropic files-api: upload response is not a JSON object",
177
+ );
178
+ }
179
+ const id = parsed.id;
180
+ if (typeof id !== "string" || id.length === 0) {
181
+ throw new Error(
182
+ "anthropic files-api: upload response has no string 'id' field",
183
+ );
184
+ }
185
+ return id;
186
+ }
187
+
188
+ function makeJsonStep(opts: {
189
+ capability: Capability;
190
+ subdir: string | null;
191
+ body: unknown;
192
+ }): CaptureStep {
193
+ return withPerCapabilityHeaders(opts.capability, {
194
+ kind: "json",
195
+ subdir: opts.subdir,
196
+ url: buildMessagesURL(),
197
+ body: opts.body,
198
+ });
199
+ }
200
+
201
+ // Reconstructs the assistant content blocks from turn-1's CapturedResponse.
202
+ // For JSON turn-1, that is the parsed message body. For SSE turn-1, the
203
+ // runner returns bytes and we parse them through the SSE event stream.
204
+ // Both paths surface the blocks in the shape buildFunctionCallingTurn2Body
205
+ // and buildRedactedThinkingTurn2Body expect.
206
+ function turn1AssistantResponse(turn1: CapturedResponse): unknown {
207
+ if (turn1.parsed !== null) return turn1.parsed;
208
+ if (turn1.bytes === null) {
209
+ throw new Error(
210
+ "anthropic multi-turn: turn-1 CapturedResponse had neither parsed body nor SSE bytes",
211
+ );
212
+ }
213
+ const blocks = extractContentBlocksFromSSE(turn1.bytes);
214
+ return { content: blocks };
215
+ }
216
+
217
+ export function* iterateCaptureSteps(
218
+ opts: IterateCaptureStepsOpts,
219
+ ): Generator<CaptureStep, void, CapturedResponse> {
220
+ const { model, capability, intent } = opts;
221
+
222
+ if (FILES_API_CAPABILITIES.has(capability)) {
223
+ const upload = buildFilesUploadStep(intent);
224
+ const uploadResponse = yield upload.step;
225
+ const fileId = extractFileId(uploadResponse.parsed);
226
+ const generateBody = buildFilesApiGenerateBody({
227
+ model,
228
+ fileId,
229
+ intent,
230
+ stream: isStreamingCapability(capability),
231
+ });
232
+ yield makeJsonStep({
233
+ capability,
234
+ subdir: "generate",
235
+ body: generateBody,
236
+ });
237
+ return;
238
+ }
239
+
240
+ if (FUNCTION_CALLING_MULTI_TURN_CAPABILITIES.has(capability)) {
241
+ const turn1Body = buildRequestBody({ model, capability, intent });
242
+ const turn1Response = yield makeJsonStep({
243
+ capability,
244
+ subdir: "turn-1",
245
+ body: turn1Body,
246
+ });
247
+ const turn2Body = buildFunctionCallingTurn2Body({
248
+ model,
249
+ capability,
250
+ intent,
251
+ turn1Body,
252
+ turn1Response: turn1AssistantResponse(turn1Response),
253
+ });
254
+ yield makeJsonStep({
255
+ capability,
256
+ subdir: "turn-2",
257
+ body: turn2Body,
258
+ });
259
+ return;
260
+ }
261
+
262
+ if (REDACTED_THINKING_CAPABILITIES.has(capability)) {
263
+ // Turn-1 carries the canary prompt and thinking enabled; Anthropic
264
+ // returns either thinking or redacted_thinking blocks depending on
265
+ // whether the safety classifier fires. Turn-2 echoes the assistant
266
+ // content blocks verbatim and prompts a brief follow-up so the
267
+ // round-trip is exercised on the wire.
268
+ const turn1Body = buildRequestBody({ model, capability, intent });
269
+ const turn1Response = yield makeJsonStep({
270
+ capability,
271
+ subdir: "turn-1",
272
+ body: turn1Body,
273
+ });
274
+ const turn2Body = buildRedactedThinkingTurn2Body({
275
+ model,
276
+ intent,
277
+ turn1Body,
278
+ turn1Response: turn1AssistantResponse(turn1Response),
279
+ });
280
+ yield makeJsonStep({
281
+ capability,
282
+ subdir: "turn-2",
283
+ body: turn2Body,
284
+ });
285
+ return;
286
+ }
287
+
288
+ yield makeJsonStep({
289
+ capability,
290
+ subdir: null,
291
+ body: buildRequestBody({ model, capability, intent }),
292
+ });
293
+ }
294
+
295
+ export function createAnthropicPlugin(
296
+ opts: AnthropicPluginOptions,
297
+ ): ProviderPlugin {
298
+ const apiKey = opts.apiKey;
299
+ return {
300
+ name: PROVIDER_NAME,
301
+ models: MODELS,
302
+ redactRequestHeaders: REDACT_REQUEST_HEADERS,
303
+ redactResponseHeaders: REDACT_RESPONSE_HEADERS,
304
+ buildAuthHeaders: () => buildAuthHeaders(apiKey),
305
+ extractReasoningTrace,
306
+ iterateCaptureSteps,
307
+ };
308
+ }
package/src/media.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { MediaRef } from "@intx/inference-discovery/catalog";
2
+
3
+ // Single source of truth for the extension → MIME type mapping used by
4
+ // both the inline media path (vision-input, document-input) and the
5
+ // Files API upload path. Adding a new extension here is the only place
6
+ // it needs to land for both call sites to pick it up.
7
+ const EXTENSION_TO_MEDIA_TYPE: Readonly<Record<string, string>> = {
8
+ jpg: "image/jpeg",
9
+ jpeg: "image/jpeg",
10
+ png: "image/png",
11
+ gif: "image/gif",
12
+ webp: "image/webp",
13
+ pdf: "application/pdf",
14
+ };
15
+
16
+ export function extensionFor(path: string): string {
17
+ const dot = path.lastIndexOf(".");
18
+ if (dot < 0 || dot === path.length - 1) {
19
+ throw new Error(
20
+ `anthropic: cannot infer media type, no extension in path: ${path}`,
21
+ );
22
+ }
23
+ return path.slice(dot + 1).toLowerCase();
24
+ }
25
+
26
+ export function mediaTypeFor(ref: MediaRef): string {
27
+ const ext = extensionFor(ref.path);
28
+ const mime = EXTENSION_TO_MEDIA_TYPE[ext];
29
+ if (mime === undefined) {
30
+ throw new Error(
31
+ `anthropic: no media-type mapping for extension .${ext} (path ${ref.path})`,
32
+ );
33
+ }
34
+ return mime;
35
+ }