@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 +69 -0
- package/package.json +16 -0
- package/src/auth.ts +17 -0
- package/src/endpoint.ts +19 -0
- package/src/index.ts +308 -0
- package/src/media.ts +35 -0
- package/src/plugin.test.ts +681 -0
- package/src/reasoning.ts +64 -0
- package/src/request-body.ts +637 -0
- package/src/sse.fixture-contract.test.ts +51 -0
- package/src/sse.test.ts +188 -0
- package/src/sse.ts +273 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
+
}
|
package/src/endpoint.ts
ADDED
|
@@ -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
|
+
}
|