@intx/inference 0.1.2 → 0.3.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.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +40 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +75 -0
  7. package/dist/assembly.js +133 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +46 -0
  13. package/dist/authz-extension.js +184 -0
  14. package/dist/correlation.d.ts +26 -0
  15. package/dist/correlation.js +39 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +228 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +28 -0
  23. package/dist/gates.js +103 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1407 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +37 -0
  31. package/dist/providers/anthropic.js +917 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +5 -0
  35. package/dist/providers/google-genai.js +1205 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +9 -0
  39. package/dist/providers/openai.js +903 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +1233 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +132 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +22 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,917 @@
1
+ import { type } from "arktype";
2
+ import { CitationBlock as CitationBlockType, formatSafetyRatingText, } from "@intx/types/runtime";
3
+ import { CREDENTIAL_SENTINEL } from "../auth.js";
4
+ import { ProtocolMismatchError } from "../errors.js";
5
+ import { decodeToolName, encodeToolName, } from "../tool-name.js";
6
+ // Anthropic's tool-name constraint is `^[a-zA-Z0-9_-]{1,128}$`, which rejects
7
+ // the raw package-qualified names for the same out-of-charset characters as
8
+ // the OpenAI family.
9
+ const ANTHROPIC_TOOL_NAME_LIMIT = {
10
+ provider: "anthropic",
11
+ maxLength: 128,
12
+ };
13
+ // Models that reject thinking:{type:"enabled",budget_tokens} and require
14
+ // thinking:{type:"adaptive"} with output_config.effort. The discovery
15
+ // plug-in's ADAPTIVE_THINKING_MODELS set must match this one; a guard test in
16
+ // the anthropic discovery package pins the two equal so they cannot drift.
17
+ export const ADAPTIVE_THINKING_MODELS = new Set([
18
+ "claude-sonnet-5",
19
+ "claude-opus-5",
20
+ "claude-fable-5",
21
+ "claude-opus-4-8",
22
+ "claude-opus-4-6",
23
+ "claude-opus-4-7",
24
+ "claude-sonnet-4-6",
25
+ ]);
26
+ // The effort this adapter sends on the adaptive-thinking wire in production.
27
+ // "high" is the Anthropic API default. The discovery capture rig deliberately
28
+ // sends "max" instead: only "max" reliably elicits a thinking block to capture,
29
+ // so the production default and the capture value are an intentional pair, not
30
+ // drift. ADAPTIVE_THINKING_MODELS above must match across the two layers; the
31
+ // effort values, by contrast, are meant to differ. A guard test in the
32
+ // discovery package checks both effort values.
33
+ export const ADAPTIVE_THINKING_EFFORT = "high";
34
+ // ---------------------------------------------------------------------------
35
+ // Request building
36
+ // ---------------------------------------------------------------------------
37
+ function buildRequest(messages, model, options) {
38
+ rejectUnsupportedResponseFormat(options.responseFormat);
39
+ const systemMessages = messages.filter((m) => m.role === "system");
40
+ const conversationMessages = messages.filter((m) => m.role !== "system");
41
+ const systemText = systemMessages
42
+ .flatMap((m) => m.content
43
+ .filter((b) => b.type === "text")
44
+ .map((b) => b.text))
45
+ .join("\n\n");
46
+ const effectiveSystem = options.systemPrompt
47
+ ? options.systemPrompt
48
+ : systemText || undefined;
49
+ const body = {
50
+ model,
51
+ max_tokens: options.maxTokens ?? 4096,
52
+ messages: conversationMessages.map((msg, i) => {
53
+ // Place a cache breakpoint on the last user message so all prior
54
+ // turns are cached on the next request.
55
+ const isLastUser = msg.role !== "assistant" &&
56
+ conversationMessages.slice(i + 1).every((m) => m.role === "assistant");
57
+ return toAnthropicMessage(msg, isLastUser);
58
+ }),
59
+ stream: true,
60
+ };
61
+ if (effectiveSystem) {
62
+ body["system"] = [
63
+ {
64
+ type: "text",
65
+ text: effectiveSystem,
66
+ cache_control: { type: "ephemeral" },
67
+ },
68
+ ];
69
+ }
70
+ if (options.thinking?.enabled) {
71
+ // Adaptive models reject the classic budget_tokens shape with
72
+ // invalid_request_error and require thinking:{type:"adaptive"}
73
+ // plus output_config.effort.
74
+ if (ADAPTIVE_THINKING_MODELS.has(model)) {
75
+ body["thinking"] = { type: "adaptive" };
76
+ body["output_config"] = { effort: ADAPTIVE_THINKING_EFFORT };
77
+ }
78
+ else {
79
+ body["thinking"] = {
80
+ type: "enabled",
81
+ budget_tokens: options.thinking.budgetTokens ?? 1024,
82
+ };
83
+ }
84
+ }
85
+ if (options.tools !== undefined && options.tools.length > 0) {
86
+ const tools = options.tools.map((t) => ({
87
+ name: encodeToolName(t.name, ANTHROPIC_TOOL_NAME_LIMIT),
88
+ description: t.description,
89
+ input_schema: t.inputSchema,
90
+ }));
91
+ const lastTool = tools[tools.length - 1];
92
+ if (lastTool !== undefined) {
93
+ lastTool["cache_control"] = { type: "ephemeral" };
94
+ }
95
+ body["tools"] = tools;
96
+ }
97
+ if (options.temperature !== undefined) {
98
+ body["temperature"] = options.temperature;
99
+ }
100
+ return {
101
+ url: "/v1/messages",
102
+ headers: {
103
+ "content-type": "application/json",
104
+ "x-api-key": CREDENTIAL_SENTINEL,
105
+ "anthropic-version": "2023-06-01",
106
+ },
107
+ body: JSON.stringify(body),
108
+ };
109
+ }
110
+ // Anthropic's Messages API has no native structured-outputs surface.
111
+ // `text` is the default and a no-op. `json` and `json-schema` raise
112
+ // here at the marshaling boundary rather than silently dropping the
113
+ // field; the codebase prefers loud failure at the wire boundary over
114
+ // a forward-synthesis shim (a hidden tool whose input_schema mirrors
115
+ // the requested schema) because no other adapter shim synthesizes
116
+ // requests the caller didn't author.
117
+ function rejectUnsupportedResponseFormat(format) {
118
+ if (format === undefined)
119
+ return;
120
+ if (format.kind === "text")
121
+ return;
122
+ throw new Error(`Anthropic adapter does not support structured outputs ` +
123
+ `(responseFormat.kind="${format.kind}").`);
124
+ }
125
+ function toAnthropicMessage(msg, cacheLastBlock) {
126
+ const role = msg.role === "assistant" ? "assistant" : "user";
127
+ // safety_rating is Gemini output-only metadata. Rewrite as text so
128
+ // role alternation and the block reason survive Anthropic history
129
+ // without a native safety_rating input shape.
130
+ const content = msg.content.map((block) => {
131
+ if (block.type === "safety_rating") {
132
+ return toAnthropicBlock({
133
+ type: "text",
134
+ text: formatSafetyRatingText(block),
135
+ });
136
+ }
137
+ return toAnthropicBlock(block);
138
+ });
139
+ if (cacheLastBlock) {
140
+ const lastBlock = content[content.length - 1];
141
+ if (lastBlock !== undefined) {
142
+ lastBlock["cache_control"] = { type: "ephemeral" };
143
+ }
144
+ }
145
+ return { role, content };
146
+ }
147
+ // Marshal a MediaSource into Anthropic's nested `source` shape.
148
+ // Base64 sources carry the mimeType on the wire as `media_type`.
149
+ // File-reference and URL sources carry no mimeType: Anthropic
150
+ // identifies file-reference content by id alone (encoded server-side
151
+ // at upload time) and infers URL-sourced content from the response of
152
+ // the fetch it performs. The MediaSource's mimeType is intentionally
153
+ // dropped at this layer for both. The internal `mimeType` requirement
154
+ // on the non-base64 variants keeps callers honest about what they
155
+ // have in hand even when the provider doesn't need it.
156
+ function toAnthropicMediaSource(source) {
157
+ if (source.kind === "base64") {
158
+ return {
159
+ type: "base64",
160
+ media_type: source.mimeType,
161
+ data: source.data,
162
+ };
163
+ }
164
+ if (source.kind === "file-reference") {
165
+ return {
166
+ type: "file",
167
+ file_id: source.reference,
168
+ };
169
+ }
170
+ if (source.kind === "url") {
171
+ return {
172
+ type: "url",
173
+ url: source.url,
174
+ };
175
+ }
176
+ // Exhaustiveness: a new MediaSource variant added without a case
177
+ // here fails this compile-time check.
178
+ source;
179
+ throw new Error(`unreachable: unknown MediaSource kind`);
180
+ }
181
+ // Map an Anthropic-streamed citation onto the internal CitationBlock.
182
+ // `textOffset` is intentionally not populated for any variant:
183
+ // Anthropic's offsets are document-relative (page numbers, doc char
184
+ // offsets, doc block indices) rather than text-relative — they don't
185
+ // correspond to UTF-16 positions in the preceding TextBlock that
186
+ // `CitationBlock.textOffset` describes. Computing text-relative
187
+ // offsets from `cited_text` substring search produces wrong answers
188
+ // whenever `cited_text` is paraphrased, appears multiple times, or
189
+ // spans wire-chunk boundaries; better to leave the field unset than
190
+ // guess.
191
+ //
192
+ // `encrypted_index` (web_search_result_location) has no echo-back
193
+ // target in CitationBlock today and is intentionally dropped at this
194
+ // layer. When echo-back of citation context lands, this is the layer
195
+ // to preserve it from.
196
+ function toCitationBlock(wire, index) {
197
+ if (wire.cited_text === undefined) {
198
+ throw new ProtocolMismatchError(`anthropic parseResponse: citation at block ${index} missing required \`cited_text\``, wire);
199
+ }
200
+ const citedText = wire.cited_text;
201
+ const source = {};
202
+ if (wire.title !== undefined)
203
+ source.title = wire.title;
204
+ if (wire.url !== undefined)
205
+ source.uri = wire.url;
206
+ if (wire.document_title !== undefined && source.title === undefined) {
207
+ source.title = wire.document_title;
208
+ }
209
+ if (wire.document_index !== undefined) {
210
+ source.documentRef = { index: wire.document_index };
211
+ }
212
+ switch (wire.type) {
213
+ case "web_search_result_location":
214
+ return { type: "citation", citedText, source };
215
+ case "page_location": {
216
+ // Anthropic page numbers are 1-indexed and inclusive on both
217
+ // ends per the documented PDF citation shape.
218
+ const start = wire.start_page_number;
219
+ const end = wire.end_page_number;
220
+ if (start === undefined || end === undefined) {
221
+ throw new ProtocolMismatchError(`anthropic parseResponse: page_location citation at block ${index} missing start_page_number or end_page_number`, wire);
222
+ }
223
+ return {
224
+ type: "citation",
225
+ citedText,
226
+ source,
227
+ location: { kind: "page", start, end },
228
+ };
229
+ }
230
+ case "char_location": {
231
+ const start = wire.start_char_index;
232
+ const end = wire.end_char_index;
233
+ if (start === undefined || end === undefined) {
234
+ throw new ProtocolMismatchError(`anthropic parseResponse: char_location citation at block ${index} missing start_char_index or end_char_index`, wire);
235
+ }
236
+ return {
237
+ type: "citation",
238
+ citedText,
239
+ source,
240
+ location: { kind: "char", start, end },
241
+ };
242
+ }
243
+ case "content_block_location": {
244
+ const start = wire.start_block_index;
245
+ const end = wire.end_block_index;
246
+ if (start === undefined || end === undefined) {
247
+ throw new ProtocolMismatchError(`anthropic parseResponse: content_block_location citation at block ${index} missing start_block_index or end_block_index`, wire);
248
+ }
249
+ return {
250
+ type: "citation",
251
+ citedText,
252
+ source,
253
+ location: { kind: "content-block", start, end },
254
+ };
255
+ }
256
+ default:
257
+ throw new ProtocolMismatchError(`anthropic parseResponse: unrecognized citation variant "${wire.type}" at block ${index}`, wire);
258
+ }
259
+ }
260
+ function toAnthropicBlock(block) {
261
+ switch (block.type) {
262
+ case "text":
263
+ return { type: "text", text: block.text };
264
+ case "thinking":
265
+ return {
266
+ type: "thinking",
267
+ thinking: block.thinking,
268
+ ...(block.signature !== undefined
269
+ ? { signature: block.signature }
270
+ : {}),
271
+ };
272
+ case "redacted_thinking":
273
+ // The opaque `data` blob must echo back verbatim on every
274
+ // follow-up turn that includes this block as context. Any
275
+ // mutation (truncation, base64-decoding-and-reencoding,
276
+ // whitespace normalization) produces a 400 from Anthropic with
277
+ // "messages.N.content.M.redacted_thinking: Field required" or
278
+ // a context-corruption error on subsequent turns. The
279
+ // RedactedThinkingBlock type carries it as `string` (opaque
280
+ // base64); pass through untouched.
281
+ return { type: "redacted_thinking", data: block.data };
282
+ case "image":
283
+ return { type: "image", source: toAnthropicMediaSource(block.source) };
284
+ case "document":
285
+ return {
286
+ type: "document",
287
+ source: toAnthropicMediaSource(block.source),
288
+ ...(block.title !== undefined ? { title: block.title } : {}),
289
+ ...(block.context !== undefined ? { context: block.context } : {}),
290
+ };
291
+ case "audio":
292
+ case "video":
293
+ throw new Error(`Anthropic adapter does not yet handle ${block.type} content blocks.`);
294
+ case "citation":
295
+ throw new Error("Anthropic adapter does not yet emit citation content blocks.");
296
+ case "safety_rating":
297
+ // Rewritten to text in toAnthropicMessage before this switch.
298
+ throw new Error("Anthropic adapter: safety_rating blocks must be rewritten to " +
299
+ "text before toAnthropicBlock.");
300
+ case "code_execution_request":
301
+ case "code_execution_result":
302
+ throw new Error(`Anthropic adapter does not yet emit ${block.type} content blocks.`);
303
+ case "refusal":
304
+ // Refusal blocks are an OpenAI strict-mode output shape. Echoing
305
+ // one back into an Anthropic request has no defined wire shape;
306
+ // surface the mismatch at the marshaling site rather than fall
307
+ // through to a silent drop.
308
+ throw new Error("Anthropic adapter does not handle refusal content blocks; " +
309
+ "they are emitted by OpenAI strict-mode structured outputs.");
310
+ case "tool_call":
311
+ return {
312
+ type: "tool_use",
313
+ id: block.id,
314
+ name: encodeToolName(block.name, ANTHROPIC_TOOL_NAME_LIMIT),
315
+ input: block.arguments,
316
+ };
317
+ case "tool_result":
318
+ return {
319
+ type: "tool_result",
320
+ tool_use_id: block.callId,
321
+ content: block.content.map((c) => {
322
+ if (c.type === "text") {
323
+ return { type: "text", text: c.text };
324
+ }
325
+ if (c.type === "image") {
326
+ return {
327
+ type: "image",
328
+ source: toAnthropicMediaSource(c.source),
329
+ };
330
+ }
331
+ // Anthropic's tool_result.content accepts only `text` and
332
+ // `image` blocks today. `document` in particular is rejected
333
+ // at the API edge; surface the failure at the marshaling
334
+ // site with the specific block type so the failure shows
335
+ // where the wrong block type was authored, not as an opaque
336
+ // HTTP 400 a round-trip later. The ContentBlock union allows
337
+ // these so the type system can grow uniformly; the wire
338
+ // surface lags.
339
+ throw new Error(`Anthropic adapter does not handle ${c.type} content blocks ` +
340
+ `inside tool_result.content; the API accepts only text and ` +
341
+ `image here.`);
342
+ }),
343
+ ...(block.isError ? { is_error: true } : {}),
344
+ };
345
+ }
346
+ }
347
+ // ---------------------------------------------------------------------------
348
+ // Response parsing
349
+ //
350
+ // The harness passes one SSE data payload per call. The parser accumulates
351
+ // no state — all partial state lives in the harness. The parser emits events
352
+ // for the fragments it sees; the harness updates the PartialMessage and
353
+ // injects it into the returned events.
354
+ //
355
+ // Because the harness owns partial state, the parser cannot construct the
356
+ // correct `partial` field. We emit raw delta events with a placeholder empty
357
+ // partial — the harness will replace it before forwarding. This is the
358
+ // design: adapters are pure translators, the harness owns all state.
359
+ // ---------------------------------------------------------------------------
360
+ const EMPTY_PARTIAL = { text: "" };
361
+ // Anthropic's SSE protocol guarantees `index` on every content_block_*
362
+ // event. Parsing it as required (not optional) means the type system
363
+ // carries the guarantee through to every emission site below — no
364
+ // defensive `?? 0` fallback that would silently route real protocol
365
+ // violations to block 0 and corrupt the `blockIndexToCallId` cache the
366
+ // parser uses to resolve input_json_delta lookups across multiple
367
+ // tool_use blocks at distinct indices. A malformed upstream missing
368
+ // `index` surfaces as a ProtocolMismatchError via the schema-validation
369
+ // throw site, with the offending payload preserved in `error.raw` for
370
+ // inspection.
371
+ // Anthropic's wire shape for a single citation, streamed inside a
372
+ // `citations_delta`. The `type` discriminator selects the location
373
+ // model:
374
+ // - web_search_result_location: URL + title, no document offsets
375
+ // - page_location: 1-indexed page numbers (inclusive start/end)
376
+ // - char_location: 0-indexed character offsets into the document
377
+ // - content_block_location: index into the document's content blocks
378
+ // Fields not relevant to a given variant are absent; the union is
379
+ // flat at the wire level. `encrypted_index` (web_search) is recorded
380
+ // only on the wire — it has no echo-back target in the internal
381
+ // CitationBlock today, so the adapter drops it.
382
+ const AnthropicCitation = type({
383
+ type: "string",
384
+ "cited_text?": "string",
385
+ "url?": "string",
386
+ "title?": "string",
387
+ "encrypted_index?": "string",
388
+ "document_index?": "number",
389
+ "document_title?": "string",
390
+ "start_page_number?": "number",
391
+ "end_page_number?": "number",
392
+ "start_char_index?": "number",
393
+ "end_char_index?": "number",
394
+ "start_block_index?": "number",
395
+ "end_block_index?": "number",
396
+ });
397
+ const ContentBlockDelta = type({
398
+ type: "'content_block_delta'",
399
+ index: "number",
400
+ delta: {
401
+ type: "string",
402
+ "text?": "string",
403
+ "thinking?": "string",
404
+ "partial_json?": "string",
405
+ "signature?": "string",
406
+ "citation?": AnthropicCitation,
407
+ },
408
+ });
409
+ const ContentBlockStart = type({
410
+ type: "'content_block_start'",
411
+ index: "number",
412
+ // Anthropic sends either content_block (snake_case) or contentBlock
413
+ // (camelCase). `data` is optional on the shared shape because only
414
+ // redacted_thinking blocks carry it; the redacted_thinking branch in
415
+ // the parser asserts presence and throws ProtocolMismatchError when
416
+ // it is missing, rather than synthesizing an empty string that would
417
+ // round-trip back to Anthropic as a corrupted block.
418
+ "content_block?": {
419
+ type: "string",
420
+ "id?": "string",
421
+ "name?": "string",
422
+ "data?": "string",
423
+ },
424
+ "contentBlock?": {
425
+ type: "string",
426
+ "id?": "string",
427
+ "name?": "string",
428
+ "data?": "string",
429
+ },
430
+ });
431
+ const ContentBlockStop = type({
432
+ type: "'content_block_stop'",
433
+ index: "number",
434
+ });
435
+ const MessageDelta = type({
436
+ type: "'message_delta'",
437
+ "usage?": { "output_tokens?": "number" },
438
+ });
439
+ const MessageStart = type({
440
+ type: "'message_start'",
441
+ "message?": {
442
+ "usage?": {
443
+ "input_tokens?": "number",
444
+ "output_tokens?": "number",
445
+ "cache_read_input_tokens?": "number",
446
+ "cache_creation_input_tokens?": "number",
447
+ },
448
+ },
449
+ });
450
+ const MessageStop = type({ type: "'message_stop'" });
451
+ const Ping = type({ type: "'ping'" });
452
+ const AnthropicSSEEvent = ContentBlockDelta.or(ContentBlockStart)
453
+ .or(ContentBlockStop)
454
+ .or(MessageDelta)
455
+ .or(MessageStart)
456
+ .or(MessageStop)
457
+ .or(Ping);
458
+ // Maps Anthropic's wire usage object onto the internal TokenUsage. Anthropic
459
+ // never reports a distinct thinking-token count, so `thinking` is always 0.
460
+ // Shared by the streaming `message_start` path and the non-streaming
461
+ // `parseJSONResponse`, whose usage objects carry the same field names.
462
+ function toInferenceUsage(usage) {
463
+ return {
464
+ input: usage.input_tokens ?? 0,
465
+ output: usage.output_tokens ?? 0,
466
+ cacheRead: usage.cache_read_input_tokens ?? 0,
467
+ cacheWrite: usage.cache_creation_input_tokens ?? 0,
468
+ thinking: 0,
469
+ };
470
+ }
471
+ function parseResponse(sseData, blockIndexToCallId, source) {
472
+ // Same protocol-mismatch posture as the openai adapter: a JSON parse
473
+ // failure or arktype rejection means the upstream emitted bytes that
474
+ // violate the Anthropic streaming protocol. Surface through
475
+ // ProtocolMismatchError so the harness's stream-error catch emits
476
+ // an inference.error with category "protocol_mismatch" carrying the
477
+ // offending data in error.raw, rather than dropping the chunk
478
+ // silently.
479
+ let parsed;
480
+ try {
481
+ parsed = JSON.parse(sseData);
482
+ }
483
+ catch (cause) {
484
+ const message = cause instanceof Error ? cause.message : String(cause);
485
+ throw new ProtocolMismatchError(`anthropic parseResponse: malformed JSON in SSE data payload: ${message}`, sseData);
486
+ }
487
+ const event = AnthropicSSEEvent(parsed);
488
+ if (event instanceof type.errors) {
489
+ throw new ProtocolMismatchError(`anthropic parseResponse: SSE event failed schema validation: ${event.summary}`, parsed);
490
+ }
491
+ // The seq field is a placeholder 0 — the harness assigns real sequence numbers.
492
+ const seq = 0;
493
+ switch (event.type) {
494
+ case "content_block_delta": {
495
+ const { delta, index } = event;
496
+ if (delta.type === "text_delta") {
497
+ const token = delta.text ?? "";
498
+ return [
499
+ {
500
+ type: "inference.text.delta",
501
+ seq,
502
+ data: { token, partial: EMPTY_PARTIAL, index },
503
+ },
504
+ ];
505
+ }
506
+ if (delta.type === "thinking_delta") {
507
+ const token = delta.thinking ?? "";
508
+ return [
509
+ {
510
+ type: "inference.thinking.delta",
511
+ seq,
512
+ data: { token, partial: EMPTY_PARTIAL, index },
513
+ },
514
+ ];
515
+ }
516
+ if (delta.type === "signature_delta") {
517
+ // Anthropic emits the cryptographic signature for a thinking block
518
+ // in a dedicated signature_delta event after the block's
519
+ // thinking_delta stream. The signature must be echoed back on any
520
+ // follow-up turn that includes the thinking block as context —
521
+ // otherwise the API rejects the request with
522
+ // "messages.N.content.M.thinking.signature: Field required".
523
+ const signature = delta.signature ?? "";
524
+ return [
525
+ {
526
+ type: "inference.block.signature",
527
+ seq,
528
+ data: { signature, index },
529
+ },
530
+ ];
531
+ }
532
+ if (delta.type === "input_json_delta") {
533
+ const callId = blockIndexToCallId.get(index);
534
+ if (callId === undefined) {
535
+ throw new ProtocolMismatchError(`anthropic parseResponse: input_json_delta for content block ${index} with no preceding tool_use start`, event);
536
+ }
537
+ const fragment = delta.partial_json ?? "";
538
+ return [
539
+ {
540
+ type: "inference.tool_call.delta",
541
+ seq,
542
+ data: {
543
+ callId,
544
+ argumentFragment: fragment,
545
+ partial: EMPTY_PARTIAL,
546
+ index,
547
+ },
548
+ },
549
+ ];
550
+ }
551
+ if (delta.type === "citations_delta") {
552
+ const wireCitation = delta.citation;
553
+ if (wireCitation === undefined) {
554
+ throw new ProtocolMismatchError(`anthropic parseResponse: citations_delta missing citation payload at block ${index}`, event);
555
+ }
556
+ const citation = toCitationBlock(wireCitation, index);
557
+ return [
558
+ {
559
+ type: "inference.citation",
560
+ seq,
561
+ data: { citation, index },
562
+ },
563
+ ];
564
+ }
565
+ return [];
566
+ }
567
+ case "content_block_start": {
568
+ const block = event.content_block ?? event.contentBlock;
569
+ if (block === undefined)
570
+ return [];
571
+ if (block.type === "tool_use") {
572
+ const { index } = event;
573
+ const callId = block.id ?? String(index);
574
+ blockIndexToCallId.set(index, callId);
575
+ const name = decodeToolName(block.name ?? "");
576
+ return [
577
+ {
578
+ type: "inference.tool_call.start",
579
+ seq,
580
+ data: { callId, name, partial: EMPTY_PARTIAL, index },
581
+ },
582
+ ];
583
+ }
584
+ if (block.type === "thinking") {
585
+ // Anchor the thinking block in the harness's per-index map
586
+ // via an empty thinking.delta. Anthropic can stream a
587
+ // signature_delta for a thinking block whose visible text is
588
+ // empty (redacted-adjacent flow); without this anchor, the
589
+ // signature would arrive at the harness with no preceding
590
+ // thinking entry at the same index and the per-index router
591
+ // would (correctly) reject it as a protocol violation. The
592
+ // empty-token delta is the parser-side analogue of the wire's
593
+ // `content_block_start` for thinking — it carries no visible
594
+ // content but reserves the index.
595
+ const { index } = event;
596
+ return [
597
+ {
598
+ type: "inference.thinking.delta",
599
+ seq,
600
+ data: { token: "", partial: EMPTY_PARTIAL, index },
601
+ },
602
+ ];
603
+ }
604
+ if (block.type === "redacted_thinking") {
605
+ // Anthropic delivers redacted_thinking as a one-shot inside
606
+ // content_block_start (no delta stream). The opaque `data`
607
+ // blob must echo back verbatim on every follow-up turn —
608
+ // mutating or synthesizing it corrupts the conversation
609
+ // context. A start event missing `data` is a protocol
610
+ // violation, not a default-to-empty case.
611
+ const { index } = event;
612
+ if (block.data === undefined) {
613
+ throw new ProtocolMismatchError(`anthropic parseResponse: content_block_start of type redacted_thinking ` +
614
+ `at index ${String(index)} missing required \`data\` field`, event);
615
+ }
616
+ return [
617
+ {
618
+ type: "inference.thinking.redacted",
619
+ seq,
620
+ data: {
621
+ redactedThinking: {
622
+ type: "redacted_thinking",
623
+ data: block.data,
624
+ },
625
+ index,
626
+ },
627
+ },
628
+ ];
629
+ }
630
+ // Non-tool_use, non-redacted_thinking content_block_start events
631
+ // (text, thinking) emit nothing here by design: each
632
+ // content_block_delta arrives with a typed delta (text_delta,
633
+ // thinking_delta, signature_delta) that the switch above
634
+ // discriminates on directly, so an upfront start emission would
635
+ // be redundant. Tool calls are the exception because their
636
+ // callId arrives only in the start event and must be cached
637
+ // against the block index for subsequent input_json_delta
638
+ // lookups; redacted_thinking is the exception because the block
639
+ // is delivered start-only with no follow-on deltas.
640
+ return [];
641
+ }
642
+ case "content_block_stop": {
643
+ // The harness handles finalizing tool calls when it sees this — we
644
+ // emit nothing here; the harness knows which blocks are complete.
645
+ return [];
646
+ }
647
+ case "message_delta": {
648
+ const outputTokens = event.usage?.output_tokens ?? 0;
649
+ const inferenceUsage = {
650
+ input: 0,
651
+ output: outputTokens,
652
+ cacheRead: 0,
653
+ cacheWrite: 0,
654
+ thinking: 0,
655
+ };
656
+ return [
657
+ {
658
+ type: "inference.usage",
659
+ seq,
660
+ data: { usage: inferenceUsage, source },
661
+ },
662
+ ];
663
+ }
664
+ case "message_start": {
665
+ blockIndexToCallId.clear();
666
+ const msgUsage = event.message?.usage;
667
+ if (msgUsage === undefined)
668
+ return [];
669
+ return [
670
+ {
671
+ type: "inference.usage",
672
+ seq,
673
+ data: { usage: toInferenceUsage(msgUsage), source },
674
+ },
675
+ ];
676
+ }
677
+ case "message_stop":
678
+ case "ping":
679
+ return [];
680
+ }
681
+ }
682
+ // ---------------------------------------------------------------------------
683
+ // Non-streaming response parsing
684
+ //
685
+ // The non-streaming Messages endpoint returns the same content blocks the
686
+ // streaming protocol delivers incrementally, delivered whole in one JSON
687
+ // body. `parseJSONResponse` re-expresses each complete block as the same
688
+ // InferenceEvent vocabulary `parseResponse` emits, so a replayed
689
+ // non-streaming capture feeds the harness accumulator identically to its
690
+ // streaming sibling. Block types the streaming parser does not model
691
+ // (server_tool_use, web_search_tool_result, code_execution_tool_result)
692
+ // emit nothing here too; bringing those cells to parity across both paths is
693
+ // owned by the strict-mode replay regression, not this parser.
694
+ // ---------------------------------------------------------------------------
695
+ const NonStreamingUsage = type({
696
+ "input_tokens?": "number",
697
+ "output_tokens?": "number",
698
+ "cache_read_input_tokens?": "number",
699
+ "cache_creation_input_tokens?": "number",
700
+ });
701
+ const NonStreamingMessage = type({
702
+ type: "'message'",
703
+ content: "unknown[]",
704
+ usage: NonStreamingUsage,
705
+ });
706
+ const BlockTag = type({ type: "string" });
707
+ const NonStreamingTextBlock = type({
708
+ type: "'text'",
709
+ "text?": "string",
710
+ "citations?": AnthropicCitation.array(),
711
+ });
712
+ const NonStreamingToolUseBlock = type({
713
+ type: "'tool_use'",
714
+ "id?": "string",
715
+ "name?": "string",
716
+ "input?": "unknown",
717
+ });
718
+ const NonStreamingThinkingBlock = type({
719
+ type: "'thinking'",
720
+ "thinking?": "string",
721
+ "signature?": "string",
722
+ });
723
+ const NonStreamingRedactedThinkingBlock = type({
724
+ type: "'redacted_thinking'",
725
+ "data?": "string",
726
+ });
727
+ function parseJSONResponse(body, source) {
728
+ let parsed;
729
+ try {
730
+ parsed = JSON.parse(body);
731
+ }
732
+ catch (cause) {
733
+ const message = cause instanceof Error ? cause.message : String(cause);
734
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: malformed JSON response body: ${message}`, body);
735
+ }
736
+ const message = NonStreamingMessage(parsed);
737
+ if (message instanceof type.errors) {
738
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: response failed schema validation: ${message.summary}`, parsed);
739
+ }
740
+ // The seq field is a placeholder 0 — the harness assigns real sequence
741
+ // numbers, exactly as on the streaming path.
742
+ const seq = 0;
743
+ const events = [];
744
+ message.content.forEach((rawBlock, index) => {
745
+ const tagged = BlockTag(rawBlock);
746
+ if (tagged instanceof type.errors) {
747
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: content block ${String(index)} has no string type: ${tagged.summary}`, rawBlock);
748
+ }
749
+ switch (tagged.type) {
750
+ case "text": {
751
+ const block = NonStreamingTextBlock(rawBlock);
752
+ if (block instanceof type.errors) {
753
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: text block ${String(index)} failed validation: ${block.summary}`, rawBlock);
754
+ }
755
+ events.push({
756
+ type: "inference.text.delta",
757
+ seq,
758
+ data: { token: block.text ?? "", partial: EMPTY_PARTIAL, index },
759
+ });
760
+ // The streaming path emits one inference.citation per citations_delta
761
+ // keyed to the enclosing text block's index; the non-streaming shape
762
+ // carries those same citations inline on the block.
763
+ for (const citation of block.citations ?? []) {
764
+ events.push({
765
+ type: "inference.citation",
766
+ seq,
767
+ data: { citation: toCitationBlock(citation, index), index },
768
+ });
769
+ }
770
+ break;
771
+ }
772
+ case "tool_use": {
773
+ const block = NonStreamingToolUseBlock(rawBlock);
774
+ if (block instanceof type.errors) {
775
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: tool_use block ${String(index)} failed validation: ${block.summary}`, rawBlock);
776
+ }
777
+ // callId falls back to the block index exactly as the streaming
778
+ // content_block_start does, so a tool_use block with no id still
779
+ // correlates its start and args delta.
780
+ const callId = block.id ?? String(index);
781
+ events.push({
782
+ type: "inference.tool_call.start",
783
+ seq,
784
+ data: {
785
+ callId,
786
+ name: decodeToolName(block.name ?? ""),
787
+ partial: EMPTY_PARTIAL,
788
+ index,
789
+ },
790
+ });
791
+ events.push({
792
+ type: "inference.tool_call.delta",
793
+ seq,
794
+ data: {
795
+ callId,
796
+ argumentFragment: JSON.stringify(block.input ?? {}),
797
+ partial: EMPTY_PARTIAL,
798
+ index,
799
+ },
800
+ });
801
+ break;
802
+ }
803
+ case "thinking": {
804
+ const block = NonStreamingThinkingBlock(rawBlock);
805
+ if (block instanceof type.errors) {
806
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: thinking block ${String(index)} failed validation: ${block.summary}`, rawBlock);
807
+ }
808
+ // Emit the thinking delta first so the harness has a thinking block
809
+ // at this index before the signature arrives; a signature with no
810
+ // preceding thinking entry is a protocol violation the harness
811
+ // rejects.
812
+ events.push({
813
+ type: "inference.thinking.delta",
814
+ seq,
815
+ data: { token: block.thinking ?? "", partial: EMPTY_PARTIAL, index },
816
+ });
817
+ if (block.signature !== undefined) {
818
+ events.push({
819
+ type: "inference.block.signature",
820
+ seq,
821
+ data: { signature: block.signature, index },
822
+ });
823
+ }
824
+ break;
825
+ }
826
+ case "redacted_thinking": {
827
+ const block = NonStreamingRedactedThinkingBlock(rawBlock);
828
+ if (block instanceof type.errors) {
829
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: redacted_thinking block ${String(index)} failed validation: ${block.summary}`, rawBlock);
830
+ }
831
+ // The opaque `data` blob must echo back verbatim on follow-up turns;
832
+ // a missing `data` is a protocol violation, not a default-to-empty
833
+ // case, matching the streaming redacted_thinking handling.
834
+ if (block.data === undefined) {
835
+ throw new ProtocolMismatchError(`anthropic parseJSONResponse: redacted_thinking block ${String(index)} missing required \`data\` field`, rawBlock);
836
+ }
837
+ events.push({
838
+ type: "inference.thinking.redacted",
839
+ seq,
840
+ data: {
841
+ redactedThinking: { type: "redacted_thinking", data: block.data },
842
+ index,
843
+ },
844
+ });
845
+ break;
846
+ }
847
+ default:
848
+ // server_tool_use, web_search_tool_result,
849
+ // code_execution_tool_result, and any future block type: the
850
+ // streaming parser emits nothing for these, so mirror that rather
851
+ // than diverge from a path with no passing reference yet.
852
+ break;
853
+ }
854
+ });
855
+ events.push({
856
+ type: "inference.usage",
857
+ seq,
858
+ data: { usage: toInferenceUsage(message.usage), source },
859
+ });
860
+ return events;
861
+ }
862
+ function extractRetryAfterMs(headers) {
863
+ const raw = headers.get("retry-after");
864
+ if (raw === null)
865
+ return undefined;
866
+ const seconds = Number(raw);
867
+ if (!Number.isFinite(seconds) || seconds <= 0)
868
+ return undefined;
869
+ return Math.ceil(seconds * 1000);
870
+ }
871
+ function extractPacingDelayMs(headers) {
872
+ // Check all rate limit dimensions and return the longest wait needed
873
+ const delays = [];
874
+ for (const prefix of [
875
+ "anthropic-ratelimit-requests",
876
+ "anthropic-ratelimit-input-tokens",
877
+ "anthropic-ratelimit-output-tokens",
878
+ "anthropic-ratelimit-tokens",
879
+ ]) {
880
+ const remaining = headers.get(`${prefix}-remaining`);
881
+ if (remaining === null)
882
+ continue;
883
+ const n = Number(remaining);
884
+ if (!Number.isFinite(n) || n > 0)
885
+ continue;
886
+ const reset = headers.get(`${prefix}-reset`);
887
+ if (reset === null)
888
+ continue;
889
+ const resetTime = Date.parse(reset);
890
+ if (Number.isNaN(resetTime))
891
+ continue;
892
+ const delayMs = resetTime - Date.now();
893
+ if (delayMs > 0)
894
+ delays.push(delayMs);
895
+ }
896
+ return delays.length > 0 ? Math.max(...delays) : undefined;
897
+ }
898
+ // The anthropic adapter carries no per-source accommodations today, so its
899
+ // quirks shape is empty. A quirks bag is deployment configuration crossing
900
+ // into the system at this boundary; rejecting unknown keys makes a
901
+ // misconfigured bag — for example an openai quirk pasted onto an anthropic
902
+ // source — fail loudly here rather than run silently ignored.
903
+ export const AnthropicQuirks = type({ "+": "reject" });
904
+ export function createAnthropicAdapter(source, quirks) {
905
+ const parsedQuirks = AnthropicQuirks(quirks ?? {});
906
+ if (parsedQuirks instanceof type.errors) {
907
+ throw new Error(`anthropic adapter: invalid quirks: ${parsedQuirks.summary}`);
908
+ }
909
+ const blockIndexToCallId = new Map();
910
+ return {
911
+ buildRequest,
912
+ parseResponse: (sseData) => parseResponse(sseData, blockIndexToCallId, source),
913
+ parseJSONResponse: (body) => parseJSONResponse(body, source),
914
+ extractRetryAfterMs,
915
+ extractPacingDelayMs,
916
+ };
917
+ }