@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,1407 @@
1
+ // Shared streaming harness — the 8-step pipeline described in INFERENCE.md.
2
+ //
3
+ // The harness:
4
+ // 1. Opens an HTTP connection with the adapter's built request
5
+ // 2. Parses the SSE byte stream into data lines
6
+ // 3. Passes each data line to the adapter's response parser
7
+ // 4. Accumulates partial message state from parser output
8
+ // 5. Emits events on the common event protocol
9
+ // 6. Checks AbortSignal between chunks
10
+ // 7. On error: classifies, emits inference.error, cleans up
11
+ // 8. On completion: emits inference.usage + inference.done
12
+ //
13
+ // Provider adapters never touch SSE parsing, connection lifecycle, abort
14
+ // handling, or event emission. They translate request/response shapes.
15
+ import { type } from "arktype";
16
+ import { getLogger } from "@intx/log";
17
+ import { detectResponseKind, } from "@intx/types/content-type";
18
+ import { parseSSE } from "./sse.js";
19
+ import { injectCredentials } from "./auth.js";
20
+ import { classifyHTTPError, classifyNetworkError, classifyAbortError, classifyStreamError, classifyTimeoutError, classifyProtocolMismatch, ProtocolMismatchError, } from "./errors.js";
21
+ import { createDefaultRetryPolicy } from "./retry-policy.js";
22
+ const logger = getLogger(["interchange", "inference", "harness"]);
23
+ /**
24
+ * Default per-call inactivity timeout (ms). Two minutes is conservative
25
+ * for reasoning-heavy models that emit `inference.thinking.delta` tokens
26
+ * regularly when actually working — sustained silence past this means
27
+ * the provider stream has genuinely stalled, not that the model is
28
+ * thinking. Operators can tune via `InferenceOptions.inactivityTimeoutMs`.
29
+ */
30
+ export const DEFAULT_INACTIVITY_TIMEOUT_MS = 120_000;
31
+ /**
32
+ * Default per-call total wall-clock cap (ms). Matches Anthropic's
33
+ * documented per-call recommendation and fits within typical CI
34
+ * timeouts. Operators can tune via `InferenceOptions.totalTimeoutMs`.
35
+ */
36
+ export const DEFAULT_TOTAL_TIMEOUT_MS = 600_000;
37
+ export const HarnessId = Symbol("HarnessId");
38
+ export function createDefaultScheduler() {
39
+ return {
40
+ setTimeout(callback, delayMs) {
41
+ const handle = setTimeout(callback, delayMs);
42
+ return () => {
43
+ clearTimeout(handle);
44
+ };
45
+ },
46
+ // `performance.now()` is monotonic and survives wall-clock
47
+ // adjustments (NTP, daylight-saving) that could otherwise make a
48
+ // long-running interval read as negative against `Date.now()`. The
49
+ // epoch differs from `Date.now()`, but consumers only ever read
50
+ // deltas across two `now()` calls from the same Scheduler instance.
51
+ now() {
52
+ return performance.now();
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * Construct runtime dependencies for `runInference` from an explicit adapter
58
+ * registry, binding `fetch` to `globalThis.fetch` and `scheduler` to the
59
+ * production wrapper. The registry is required so the caller makes an explicit
60
+ * choice of provider set; `@intx/inference/providers`' zero-arg
61
+ * `createDefaultDependencies()` is the honest default that supplies the
62
+ * built-in registry.
63
+ *
64
+ * @param adapters - Registry resolving inference sources to provider adapters
65
+ * @returns Fully-populated dependencies
66
+ */
67
+ export function createDependencies(adapters) {
68
+ return {
69
+ fetch: globalThis.fetch.bind(globalThis),
70
+ scheduler: createDefaultScheduler(),
71
+ adapters,
72
+ };
73
+ }
74
+ /**
75
+ * Run one fetch lifecycle and yield its events. Ends on the first
76
+ * `inference.error` or `inference.done`. The outer `runInference`
77
+ * consumes this generator, decides retry vs flush per the configured
78
+ * `RetryPolicy`, and either flushes the buffered events to the caller
79
+ * or discards them and re-enters this generator with the same opts.
80
+ *
81
+ * Not exported — the wrapper is the public entry point; calling this
82
+ * directly would bypass retry handling.
83
+ */
84
+ async function* runSingleAttempt(opts) {
85
+ const { turns, source, inferenceOptions, signal, nextSeq, deps } = opts;
86
+ // Per-call options override source-bound defaults. The merge happens
87
+ // here, once, so the adapter and timeout-resolution paths below all
88
+ // see the effective option set without having to remember the
89
+ // precedence rule.
90
+ const effectiveOptions = {
91
+ ...(source.defaults ?? {}),
92
+ ...(inferenceOptions ?? {}),
93
+ };
94
+ const model = source.model;
95
+ // Snapshot the source identity at call start. The harness reads
96
+ // `source.*` lazily across the rest of this function (and the adapter
97
+ // closes over `source` for its parseResponse), so a `setSource`
98
+ // mid-call would otherwise mutate the identity stamped onto the
99
+ // inference.usage and inference.done events for this very call.
100
+ // Capturing into a local LastCycleSource here is the single point
101
+ // that defends against that hot-swap.
102
+ //
103
+ // Scope of the defense: this snapshot protects *identity attribution*
104
+ // — what the director's policy hook and external event consumers see
105
+ // for `lastCycleSource` and `event.data.source`. It does NOT isolate
106
+ // the in-flight HTTP request from the swap: `resolveURL` reads
107
+ // `source.baseURL` live and `injectCredentials` reads `source.apiKey`
108
+ // live (both below). A mid-call `setSource` will route the request to
109
+ // the new endpoint with the new credentials while the resulting
110
+ // inference.done still carries the pre-swap identity. That is
111
+ // consistent with `LastCycleSource` deliberately excluding
112
+ // baseURL/apiKey, but it is worth knowing: the snapshot is
113
+ // identity-only, not a transactional freeze of the entire source.
114
+ const lastCycleSource = {
115
+ sourceId: source.id,
116
+ provider: source.provider,
117
+ model,
118
+ };
119
+ // Emit inference.start immediately.
120
+ yield { type: "inference.start", seq: nextSeq(), data: { model } };
121
+ // Mutable partial state — the harness owns this.
122
+ const partial = { text: "" };
123
+ const blockMap = new Map();
124
+ // Citations streamed from the provider. Indexed citations attribute
125
+ // to the block at the matching index and interleave into the
126
+ // finalized turn immediately after that block; unindexed citations
127
+ // append at the end of `content[]` per the CitationBlock attribution
128
+ // rule. The two collections capture distinct semantics, not just
129
+ // different keys.
130
+ const citationsByIndex = new Map();
131
+ const unindexedCitations = [];
132
+ // Prompt-level safety signals (no candidate index on the first
133
+ // capture). Appended to the finalized turn after indexed blocks.
134
+ const unindexedSafetyRatings = [];
135
+ let usageSeen = null;
136
+ const openToolCalls = new Map();
137
+ // OpenAI uses index-based tracking before we have a real callId.
138
+ const indexToCallId = new Map();
139
+ if (signal?.aborted) {
140
+ yield {
141
+ type: "inference.error",
142
+ seq: nextSeq(),
143
+ data: { error: classifyAbortError(), partial: snapshotPartial(partial) },
144
+ };
145
+ return;
146
+ }
147
+ let adapter;
148
+ try {
149
+ adapter = deps.adapters.resolve(lastCycleSource, source.quirks);
150
+ }
151
+ catch (cause) {
152
+ yield {
153
+ type: "inference.error",
154
+ seq: nextSeq(),
155
+ data: {
156
+ error: {
157
+ category: "fatal",
158
+ message: cause instanceof Error
159
+ ? cause.message
160
+ : `Unknown provider: ${lastCycleSource.provider}`,
161
+ },
162
+ partial: snapshotPartial(partial),
163
+ },
164
+ };
165
+ return;
166
+ }
167
+ let builtRequest;
168
+ try {
169
+ builtRequest = adapter.buildRequest(turns, model, effectiveOptions);
170
+ }
171
+ catch (cause) {
172
+ yield {
173
+ type: "inference.error",
174
+ seq: nextSeq(),
175
+ data: {
176
+ error: classifyNetworkError(cause),
177
+ partial: snapshotPartial(partial),
178
+ },
179
+ };
180
+ return;
181
+ }
182
+ // Resolve the full URL and inject credentials.
183
+ const url = resolveURL(builtRequest.url, source.baseURL);
184
+ const headers = injectCredentials(builtRequest.headers, source);
185
+ // Per-call timeouts. The inactivity timer fires when the harness
186
+ // hasn't yielded an event for `inactivityTimeoutMs`; the total timer
187
+ // is a wall-clock cap from fetch onwards. We own one AbortController,
188
+ // combine its signal with the caller's, and attribute the abort to
189
+ // whichever timer fired by checking `timeoutReason` at the catch site.
190
+ const inactivityTimeoutMs = effectiveOptions.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
191
+ const totalTimeoutMs = effectiveOptions.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
192
+ const scheduler = deps.scheduler;
193
+ const timeoutAbort = new AbortController();
194
+ let timeoutReason = null;
195
+ let cancelInactivity = null;
196
+ const armInactivity = () => {
197
+ cancelInactivity?.();
198
+ cancelInactivity = scheduler.setTimeout(() => {
199
+ timeoutReason = "inactivity";
200
+ timeoutAbort.abort();
201
+ }, inactivityTimeoutMs);
202
+ };
203
+ const cancelTotal = scheduler.setTimeout(() => {
204
+ timeoutReason = "total";
205
+ timeoutAbort.abort();
206
+ }, totalTimeoutMs);
207
+ // Per-timer cancellers are idempotent (the production scheduler's
208
+ // canceller wraps `clearTimeout`, which no-ops on a fired timer; the
209
+ // test scheduler's canceller flips a `cancelled` flag). Callers may
210
+ // invoke `cleanupTimers` exactly once; the `try/finally` around the
211
+ // generator body below is the single owner of that lifecycle.
212
+ const cleanupTimers = () => {
213
+ cancelTotal();
214
+ cancelInactivity?.();
215
+ cancelInactivity = null;
216
+ };
217
+ // Combined signal: the production code's existing caller-signal +
218
+ // our timeout controller, so a fetch implementation that respects
219
+ // AbortSignal sees both. `cleanupSignal` removes the abort listeners
220
+ // `combineSignals` installs on the caller signal so a long-lived
221
+ // caller signal (e.g., a session-scoped controller) does not
222
+ // accumulate one un-removed listener per call.
223
+ const { signal: fetchSignal, cleanup: cleanupSignal } = combineSignals(signal, timeoutAbort.signal);
224
+ try {
225
+ let response;
226
+ try {
227
+ response = await deps.fetch(url, {
228
+ method: "POST",
229
+ headers,
230
+ body: builtRequest.body,
231
+ signal: fetchSignal,
232
+ });
233
+ }
234
+ catch (cause) {
235
+ if (timeoutReason !== null) {
236
+ const thresholdMs = timeoutReason === "inactivity" ? inactivityTimeoutMs : totalTimeoutMs;
237
+ yield {
238
+ type: "inference.error",
239
+ seq: nextSeq(),
240
+ data: {
241
+ error: classifyTimeoutError(timeoutReason, thresholdMs),
242
+ partial: snapshotPartial(partial),
243
+ },
244
+ };
245
+ return;
246
+ }
247
+ if (signal?.aborted) {
248
+ yield {
249
+ type: "inference.error",
250
+ seq: nextSeq(),
251
+ data: {
252
+ error: classifyAbortError(),
253
+ partial: snapshotPartial(partial),
254
+ },
255
+ };
256
+ return;
257
+ }
258
+ yield {
259
+ type: "inference.error",
260
+ seq: nextSeq(),
261
+ data: {
262
+ error: classifyNetworkError(cause),
263
+ partial: snapshotPartial(partial),
264
+ },
265
+ };
266
+ return;
267
+ }
268
+ if (!response.ok) {
269
+ // Read the body as text once and then try to parse it as JSON.
270
+ // Calling `.json()` first and falling back to `.text()` on the
271
+ // same response does not work — per WHATWG fetch the body stream
272
+ // is locked/disturbed by the first read attempt, so the fallback
273
+ // throws `TypeError: body already consumed` and `errorBody` ends
274
+ // up `undefined`. Reading text-then-parsing covers both JSON and
275
+ // plain-text error bodies in a single pass.
276
+ //
277
+ // The read is bound to the combined fetch signal so a hostile
278
+ // server returning a 4xx/5xx with a body that never terminates
279
+ // cannot hang the call past the total-timeout horizon.
280
+ let errorBody;
281
+ try {
282
+ const text = await awaitWithSignal(response.text(), fetchSignal);
283
+ try {
284
+ errorBody = JSON.parse(text);
285
+ }
286
+ catch {
287
+ errorBody = text;
288
+ }
289
+ }
290
+ catch {
291
+ errorBody = undefined;
292
+ }
293
+ const errorMessage = extractErrorMessage(errorBody) ?? response.statusText;
294
+ const retryAfterMs = adapter.extractRetryAfterMs?.(response.headers);
295
+ yield {
296
+ type: "inference.error",
297
+ seq: nextSeq(),
298
+ data: {
299
+ error: classifyHTTPError(response.status, errorMessage, errorBody, retryAfterMs),
300
+ partial: snapshotPartial(partial),
301
+ },
302
+ };
303
+ return;
304
+ }
305
+ if (response.body === null) {
306
+ yield {
307
+ type: "inference.error",
308
+ seq: nextSeq(),
309
+ data: {
310
+ error: classifyNetworkError(new Error("Response body is null")),
311
+ partial: snapshotPartial(partial),
312
+ },
313
+ };
314
+ return;
315
+ }
316
+ // Captured as a const so the non-null narrowing from the guard above
317
+ // carries into the SSE branch of the event-source generator below (a
318
+ // bare `response.body` re-widens to nullable across the closure).
319
+ const responseBody = response.body;
320
+ let responseKind;
321
+ try {
322
+ responseKind = detectResponseKind(response.headers);
323
+ }
324
+ catch (cause) {
325
+ // A 2xx whose Content-Type is neither SSE nor JSON is a protocol
326
+ // violation, not a transient failure — surface it loudly rather than
327
+ // pushing unknown bytes through the SSE parser to yield an empty turn.
328
+ yield {
329
+ type: "inference.error",
330
+ seq: nextSeq(),
331
+ data: {
332
+ error: classifyProtocolMismatch(cause instanceof Error ? cause.message : String(cause)),
333
+ partial: snapshotPartial(partial),
334
+ },
335
+ };
336
+ return;
337
+ }
338
+ // Arm the inactivity timer now that the SSE stream is open. Every
339
+ // event we yield below resets it; sustained silence past
340
+ // `inactivityTimeoutMs` aborts the controller and the loop's catch
341
+ // surfaces the timeout error. A non-streaming JSON body has no
342
+ // inter-event silence to detect, so the timer stays disarmed there and
343
+ // the total-timeout controller alone bounds the buffered read.
344
+ if (responseKind === "sse") {
345
+ armInactivity();
346
+ }
347
+ // The event source: one branch per response kind, both feeding batches
348
+ // of raw adapter events into the shared accumulator below. SSE yields
349
+ // one batch per wire chunk; JSON buffers the whole body and yields a
350
+ // single batch.
351
+ const rawEventBatches = async function* () {
352
+ if (responseKind === "json") {
353
+ const body = await awaitWithSignal(response.text(), fetchSignal);
354
+ yield adapter.parseJSONResponse(body);
355
+ return;
356
+ }
357
+ for await (const sseData of parseSSE(responseBody)) {
358
+ // Reset inactivity timer — we just got something from the wire.
359
+ armInactivity();
360
+ yield adapter.parseResponse(sseData);
361
+ }
362
+ };
363
+ try {
364
+ for await (const rawEvents of rawEventBatches()) {
365
+ if (timeoutReason !== null) {
366
+ // The timeout aborted the stream; bubble up the right error
367
+ // shape rather than letting the abort masquerade as a
368
+ // caller-initiated cancellation.
369
+ const thresholdMs = timeoutReason === "inactivity"
370
+ ? inactivityTimeoutMs
371
+ : totalTimeoutMs;
372
+ yield {
373
+ type: "inference.error",
374
+ seq: nextSeq(),
375
+ data: {
376
+ error: classifyTimeoutError(timeoutReason, thresholdMs),
377
+ partial: snapshotPartial(partial),
378
+ },
379
+ };
380
+ return;
381
+ }
382
+ if (signal?.aborted) {
383
+ yield {
384
+ type: "inference.error",
385
+ seq: nextSeq(),
386
+ data: {
387
+ error: classifyAbortError(),
388
+ partial: snapshotPartial(partial),
389
+ },
390
+ };
391
+ return;
392
+ }
393
+ for (const raw of rawEvents) {
394
+ switch (raw.type) {
395
+ case "inference.text.delta": {
396
+ const idx = requireIndex(raw, "text.delta");
397
+ const existing = blockMap.get(idx);
398
+ if (existing === undefined) {
399
+ blockMap.set(idx, { kind: "text", text: raw.data.token });
400
+ }
401
+ else if (existing.kind === "text") {
402
+ existing.text += raw.data.token;
403
+ }
404
+ else {
405
+ throw new ProtocolMismatchError(`harness: text.delta at index ${String(idx)} collides with existing ${existing.kind} block`, raw);
406
+ }
407
+ // Running concat of all text deltas — backwards
408
+ // compatible with consumers that treat `partial.text` as
409
+ // "everything the assistant has typed so far," regardless
410
+ // of which content block it came from.
411
+ partial.text += raw.data.token;
412
+ yield {
413
+ type: "inference.text.delta",
414
+ seq: nextSeq(),
415
+ data: {
416
+ token: raw.data.token,
417
+ partial: snapshotPartial(partial),
418
+ index: idx,
419
+ },
420
+ };
421
+ break;
422
+ }
423
+ case "inference.refusal.delta": {
424
+ const idx = requireIndex(raw, "refusal.delta");
425
+ const existing = blockMap.get(idx);
426
+ if (existing === undefined) {
427
+ blockMap.set(idx, { kind: "refusal", reason: raw.data.token });
428
+ }
429
+ else if (existing.kind === "refusal") {
430
+ existing.reason += raw.data.token;
431
+ }
432
+ else {
433
+ throw new ProtocolMismatchError(`harness: refusal.delta at index ${String(idx)} collides with existing ${existing.kind} block`, raw);
434
+ }
435
+ // Re-yield with a fresh seq; the partial snapshot does
436
+ // not currently carry a `refusal` field (PartialMessage
437
+ // only knows text and thinking today), so the snapshot
438
+ // here reflects the surrounding text/thinking state.
439
+ // Subscribers needing the running refusal string
440
+ // accumulate tokens from the emitted delta events
441
+ // themselves, or read the finalized turn's RefusalBlock.
442
+ yield {
443
+ type: "inference.refusal.delta",
444
+ seq: nextSeq(),
445
+ data: {
446
+ token: raw.data.token,
447
+ partial: snapshotPartial(partial),
448
+ index: idx,
449
+ },
450
+ };
451
+ break;
452
+ }
453
+ case "inference.thinking.delta": {
454
+ const idx = requireIndex(raw, "thinking.delta");
455
+ const existing = blockMap.get(idx);
456
+ if (existing === undefined) {
457
+ blockMap.set(idx, { kind: "thinking", text: raw.data.token });
458
+ }
459
+ else if (existing.kind === "thinking") {
460
+ existing.text += raw.data.token;
461
+ }
462
+ else {
463
+ throw new ProtocolMismatchError(`harness: thinking.delta at index ${String(idx)} collides with existing ${existing.kind} block`, raw);
464
+ }
465
+ // Running concat of all thinking deltas across every
466
+ // thinking block. Under interleaving (thinking@0 "A",
467
+ // text@1 "X", thinking@2 "B"), `partial.thinking` ends
468
+ // up "AB" — backwards compatible with the pre-per-index
469
+ // single-buffer semantics. Consumers needing per-block
470
+ // structure walk the finalized turn's `content[]`.
471
+ const concat = (partial.thinking ?? "") + raw.data.token;
472
+ partial.thinking = concat;
473
+ yield {
474
+ type: "inference.thinking.delta",
475
+ seq: nextSeq(),
476
+ data: {
477
+ token: raw.data.token,
478
+ partial: snapshotPartial(partial),
479
+ index: idx,
480
+ },
481
+ };
482
+ break;
483
+ }
484
+ case "inference.block.signature": {
485
+ const idx = requireIndex(raw, "block.signature");
486
+ const existing = blockMap.get(idx);
487
+ if (existing === undefined) {
488
+ throw new ProtocolMismatchError(`harness: block.signature at index ${String(idx)} has no preceding block at that index`, raw);
489
+ }
490
+ // A signature authenticates the block whose part it rides on.
491
+ // The signable kinds are the ones whose ContentBlock carries a
492
+ // `signature` field; the others (redacted_thinking, refusal,
493
+ // code_execution_result) have no place to hold one.
494
+ if (existing.kind !== "thinking" &&
495
+ existing.kind !== "text" &&
496
+ existing.kind !== "tool_use" &&
497
+ existing.kind !== "image" &&
498
+ existing.kind !== "code_execution_request") {
499
+ throw new ProtocolMismatchError(`harness: block.signature at index ${String(idx)} targets an existing ${existing.kind} block, which does not carry a signature`, raw);
500
+ }
501
+ existing.signature = raw.data.signature;
502
+ yield {
503
+ type: "inference.block.signature",
504
+ seq: nextSeq(),
505
+ data: { signature: raw.data.signature, index: idx },
506
+ };
507
+ break;
508
+ }
509
+ case "inference.citation": {
510
+ const citation = raw.data.citation;
511
+ const citationIndex = raw.data.index;
512
+ if (citationIndex !== undefined) {
513
+ let list = citationsByIndex.get(citationIndex);
514
+ if (list === undefined) {
515
+ list = [];
516
+ citationsByIndex.set(citationIndex, list);
517
+ }
518
+ list.push(citation);
519
+ }
520
+ else {
521
+ unindexedCitations.push(citation);
522
+ }
523
+ yield {
524
+ type: "inference.citation",
525
+ seq: nextSeq(),
526
+ data: citationIndex !== undefined
527
+ ? { citation, index: citationIndex }
528
+ : { citation },
529
+ };
530
+ break;
531
+ }
532
+ case "inference.safety_rating": {
533
+ const safetyRating = raw.data.safetyRating;
534
+ unindexedSafetyRatings.push(safetyRating);
535
+ yield {
536
+ type: "inference.safety_rating",
537
+ seq: nextSeq(),
538
+ data: { safetyRating },
539
+ };
540
+ break;
541
+ }
542
+ case "inference.thinking.redacted": {
543
+ const idx = requireIndex(raw, "thinking.redacted");
544
+ const existing = blockMap.get(idx);
545
+ if (existing !== undefined) {
546
+ throw new ProtocolMismatchError(`harness: thinking.redacted at index ${String(idx)} collides with existing ${existing.kind} block`, raw);
547
+ }
548
+ blockMap.set(idx, {
549
+ kind: "redacted_thinking",
550
+ data: raw.data.redactedThinking.data,
551
+ });
552
+ yield {
553
+ type: "inference.thinking.redacted",
554
+ seq: nextSeq(),
555
+ data: {
556
+ redactedThinking: raw.data.redactedThinking,
557
+ index: idx,
558
+ },
559
+ };
560
+ break;
561
+ }
562
+ case "inference.tool_call.start": {
563
+ const toolIdx = requireIndex(raw, "tool_call.start");
564
+ const { callId, name } = raw.data;
565
+ openToolCalls.set(callId, { callId, name, argsBuffer: "" });
566
+ // OpenAI-flavoured adapters synthesize a placeholder
567
+ // callId on tool_call.delta events (the real id is only
568
+ // present on the start). Key the resolution map on the
569
+ // start event's `data.index` so the placeholder the
570
+ // delta emits (`String(blockIndex)`) maps back to the
571
+ // real id even when `tcDelta.index` is non-zero or
572
+ // non-contiguous.
573
+ indexToCallId.set(String(toolIdx), callId);
574
+ // Anchor the tool_use position in the per-index map.
575
+ // The map walk in final assembly will resolve the marker
576
+ // via `completedToolCalls` so the tool_use block lands
577
+ // in its wire-arrival position relative to text and
578
+ // thinking blocks. Collisions with another kind at the
579
+ // same index throw, matching the discipline of the
580
+ // text/thinking/redacted_thinking branches above —
581
+ // distinct kinds cannot share an index without losing
582
+ // the per-index ordering guarantee.
583
+ const existingAtIdx = blockMap.get(toolIdx);
584
+ if (existingAtIdx === undefined) {
585
+ blockMap.set(toolIdx, { kind: "tool_use", callId });
586
+ }
587
+ else if (existingAtIdx.kind !== "tool_use" ||
588
+ existingAtIdx.callId !== callId) {
589
+ throw new ProtocolMismatchError(`harness: tool_call.start at index ${String(toolIdx)} collides with existing ${existingAtIdx.kind} block`, raw);
590
+ }
591
+ partial.toolCalls = [
592
+ ...(partial.toolCalls ?? []),
593
+ {
594
+ id: callId,
595
+ name,
596
+ partialArguments: "",
597
+ },
598
+ ];
599
+ yield {
600
+ type: "inference.tool_call.start",
601
+ seq: nextSeq(),
602
+ data: {
603
+ callId,
604
+ name,
605
+ partial: snapshotPartial(partial),
606
+ index: toolIdx,
607
+ },
608
+ };
609
+ break;
610
+ }
611
+ case "inference.tool_call.delta": {
612
+ const { callId, argumentFragment } = raw.data;
613
+ // Resolve index-based callId to real callId if we have a mapping.
614
+ const resolvedId = indexToCallId.get(callId) ?? callId;
615
+ const tc = openToolCalls.get(resolvedId);
616
+ if (tc !== undefined) {
617
+ tc.argsBuffer += argumentFragment;
618
+ // Update partial.toolCalls entry.
619
+ if (partial.toolCalls !== undefined) {
620
+ for (const ptc of partial.toolCalls) {
621
+ if (ptc.id === resolvedId) {
622
+ ptc.partialArguments = tc.argsBuffer;
623
+ break;
624
+ }
625
+ }
626
+ }
627
+ yield {
628
+ type: "inference.tool_call.delta",
629
+ seq: nextSeq(),
630
+ data: {
631
+ callId: resolvedId,
632
+ argumentFragment,
633
+ partial: snapshotPartial(partial),
634
+ },
635
+ };
636
+ }
637
+ break;
638
+ }
639
+ case "inference.image_output": {
640
+ const imgIdx = requireIndex(raw, "image_output");
641
+ const existing = blockMap.get(imgIdx);
642
+ if (existing === undefined) {
643
+ blockMap.set(imgIdx, { kind: "image", image: raw.data.image });
644
+ }
645
+ else {
646
+ // Image blocks are atomic per event (no streaming
647
+ // chunks the way text deltas accumulate). A second
648
+ // image_output event at the same index, or any
649
+ // collision with a different block kind, is a
650
+ // protocol violation -- there is no coalesce branch
651
+ // for image_output by design.
652
+ throw new ProtocolMismatchError(`harness: image_output at index ${String(imgIdx)} collides with existing ${existing.kind} block`, raw);
653
+ }
654
+ // The `partial` snapshot is intentionally not updated:
655
+ // images are not streamed, so there is no
656
+ // "partial-image" concept to surface to snapshot
657
+ // consumers. The atomic event itself is the signal
658
+ // that the image has arrived. The forwarded payload
659
+ // carries the ImageBlock verbatim; elision (for logs)
660
+ // is the consumer's job and is enforced by the
661
+ // existing invariant test against `image_output`.
662
+ yield {
663
+ type: "inference.image_output",
664
+ seq: nextSeq(),
665
+ data: { image: raw.data.image, index: imgIdx },
666
+ };
667
+ break;
668
+ }
669
+ case "inference.code_execution.start": {
670
+ const ceIdx = requireIndex(raw, "code_execution.start");
671
+ const existing = blockMap.get(ceIdx);
672
+ if (existing === undefined) {
673
+ blockMap.set(ceIdx, {
674
+ kind: "code_execution_request",
675
+ request: raw.data.request,
676
+ });
677
+ }
678
+ else {
679
+ // Code-execution request blocks are atomic per
680
+ // event in their current form (Gemini delivers the
681
+ // full `code` in one part); a `delta` may extend
682
+ // the running request below, but the start handler
683
+ // never reuses an existing slot. Collision with a
684
+ // different kind at the same index is a wire bug.
685
+ throw new ProtocolMismatchError(`harness: code_execution.start at index ${String(ceIdx)} collides with existing ${existing.kind} block`, raw);
686
+ }
687
+ yield {
688
+ type: "inference.code_execution.start",
689
+ seq: nextSeq(),
690
+ data: { request: raw.data.request, index: ceIdx },
691
+ };
692
+ break;
693
+ }
694
+ case "inference.code_execution.delta": {
695
+ // Append a code fragment to the running request at
696
+ // the event's index. Gemini does not emit deltas
697
+ // (its `executableCode` is atomic), but the type
698
+ // system commits to the streaming lifecycle
699
+ // (`start -> delta* -> result`), so the handler is
700
+ // wired for providers that do chunk source code. The
701
+ // per-index router resolves the target block via
702
+ // the event's `index`; the `requestId` is then
703
+ // verified against the block's stored id as a
704
+ // consistency check that the routed block matches
705
+ // the back-pointer the delta carries (a mismatch
706
+ // would mean an upstream rerouting bug producing a
707
+ // confidently-wrong concatenation).
708
+ const ceIdx = requireIndex(raw, "code_execution.delta");
709
+ const existing = blockMap.get(ceIdx);
710
+ if (existing === undefined) {
711
+ throw new ProtocolMismatchError(`harness: code_execution.delta at index ${String(ceIdx)} with no preceding code_execution.start`, raw);
712
+ }
713
+ if (existing.kind !== "code_execution_request") {
714
+ throw new ProtocolMismatchError(`harness: code_execution.delta at index ${String(ceIdx)} routed to a ${existing.kind} block`, raw);
715
+ }
716
+ if (existing.request.id !== raw.data.requestId) {
717
+ throw new ProtocolMismatchError(`harness: code_execution.delta requestId ${JSON.stringify(raw.data.requestId)} does not match the block's request id ${JSON.stringify(existing.request.id)} at index ${String(ceIdx)}`, raw);
718
+ }
719
+ existing.request = {
720
+ ...existing.request,
721
+ code: existing.request.code + raw.data.codeFragment,
722
+ };
723
+ yield {
724
+ type: "inference.code_execution.delta",
725
+ seq: nextSeq(),
726
+ data: {
727
+ requestId: raw.data.requestId,
728
+ codeFragment: raw.data.codeFragment,
729
+ index: ceIdx,
730
+ },
731
+ };
732
+ break;
733
+ }
734
+ case "inference.code_execution.result": {
735
+ const ceIdx = requireIndex(raw, "code_execution.result");
736
+ const existing = blockMap.get(ceIdx);
737
+ if (existing === undefined) {
738
+ blockMap.set(ceIdx, {
739
+ kind: "code_execution_result",
740
+ result: raw.data.result,
741
+ });
742
+ }
743
+ else {
744
+ throw new ProtocolMismatchError(`harness: code_execution.result at index ${String(ceIdx)} collides with existing ${existing.kind} block`, raw);
745
+ }
746
+ yield {
747
+ type: "inference.code_execution.result",
748
+ seq: nextSeq(),
749
+ data: { result: raw.data.result, index: ceIdx },
750
+ };
751
+ break;
752
+ }
753
+ case "inference.usage": {
754
+ // Accumulate usage — providers may send multiple usage events
755
+ // (e.g., Anthropic sends one at message_start with input
756
+ // tokens, then one at message_delta with output tokens
757
+ // and input deliberately set to 0 by the parser to mean
758
+ // "no change to input"). Emit the cumulative
759
+ // post-merge total rather than the raw incoming so
760
+ // downstream consumers and invariants see a monotone
761
+ // non-decreasing stream — the raw incoming would
762
+ // observably "decrease" input from a real count back
763
+ // to 0 between the two events even though no decrease
764
+ // occurred in the underlying counter.
765
+ //
766
+ // The source field uses the call-start `lastCycleSource`
767
+ // snapshot rather than `raw.data.source`. The adapter
768
+ // stamps source on its own emit because the InferenceEvent
769
+ // type requires the field at every producer site, but the
770
+ // harness owns identity attribution for downstream
771
+ // consumers: the harness's snapshot is the single source
772
+ // of truth, the adapter's stamp is type-system overhead
773
+ // that gets replaced here. Both descriptors are equal by
774
+ // construction (the registry passes the same snapshot to
775
+ // the adapter factory), so the override is redundant for
776
+ // correctness; it exists so a future provider that
777
+ // synthesizes its own descriptor cannot drift from the
778
+ // call-start identity the rest of the harness commits to.
779
+ usageSeen = mergeUsage(usageSeen, raw.data.usage);
780
+ yield {
781
+ type: "inference.usage",
782
+ seq: nextSeq(),
783
+ data: { usage: usageSeen, source: lastCycleSource },
784
+ };
785
+ break;
786
+ }
787
+ // inference.done and inference.error from adapters are unexpected —
788
+ // the harness emits those itself. Ignore them.
789
+ default:
790
+ break;
791
+ }
792
+ }
793
+ }
794
+ }
795
+ catch (cause) {
796
+ if (timeoutReason !== null) {
797
+ const thresholdMs = timeoutReason === "inactivity" ? inactivityTimeoutMs : totalTimeoutMs;
798
+ yield {
799
+ type: "inference.error",
800
+ seq: nextSeq(),
801
+ data: {
802
+ error: classifyTimeoutError(timeoutReason, thresholdMs),
803
+ partial: snapshotPartial(partial),
804
+ },
805
+ };
806
+ return;
807
+ }
808
+ if (signal?.aborted) {
809
+ yield {
810
+ type: "inference.error",
811
+ seq: nextSeq(),
812
+ data: {
813
+ error: classifyAbortError(),
814
+ partial: snapshotPartial(partial),
815
+ },
816
+ };
817
+ return;
818
+ }
819
+ yield {
820
+ type: "inference.error",
821
+ seq: nextSeq(),
822
+ data: {
823
+ error: classifyStreamError(cause),
824
+ partial: snapshotPartial(partial),
825
+ },
826
+ };
827
+ return;
828
+ }
829
+ // Finalize any open tool calls that never received an explicit end event.
830
+ const completedToolCalls = [];
831
+ for (const tc of openToolCalls.values()) {
832
+ let parsedArgs;
833
+ try {
834
+ const raw = tc.argsBuffer.trim() === "" ? "{}" : tc.argsBuffer;
835
+ const parsed = JSON.parse(raw);
836
+ const validated = ParsedToolArgs(parsed);
837
+ parsedArgs = validated instanceof type.errors ? {} : validated;
838
+ }
839
+ catch {
840
+ parsedArgs = { _raw: tc.argsBuffer };
841
+ }
842
+ completedToolCalls.push({
843
+ type: "tool_call",
844
+ id: tc.callId,
845
+ name: tc.name,
846
+ arguments: parsedArgs,
847
+ });
848
+ yield {
849
+ type: "inference.tool_call.end",
850
+ seq: nextSeq(),
851
+ data: {
852
+ callId: tc.callId,
853
+ name: tc.name,
854
+ arguments: parsedArgs,
855
+ partial: snapshotPartial(partial),
856
+ },
857
+ };
858
+ }
859
+ const finalUsage = usageSeen ?? {
860
+ input: 0,
861
+ output: 0,
862
+ cacheRead: 0,
863
+ cacheWrite: 0,
864
+ thinking: 0,
865
+ };
866
+ // Emit inference.usage before inference.done per the protocol spec.
867
+ if (usageSeen === null) {
868
+ yield {
869
+ type: "inference.usage",
870
+ seq: nextSeq(),
871
+ data: { usage: finalUsage, source: lastCycleSource },
872
+ };
873
+ }
874
+ // Build the final assistant message by walking the per-index map
875
+ // in insertion order. JS `Map` preserves insertion order for all
876
+ // keys (including integers — distinct from plain object behaviour),
877
+ // so iteration here reproduces the wire-arrival order of content
878
+ // blocks regardless of the numeric values. Tool-call markers are
879
+ // resolved to the finalized ContentBlock from the completedToolCalls
880
+ // array via the marker's callId.
881
+ const completedToolCallsByCallId = new Map();
882
+ for (const tc of completedToolCalls) {
883
+ if (tc.type === "tool_call") {
884
+ completedToolCallsByCallId.set(tc.id, tc);
885
+ }
886
+ }
887
+ const contentBlocks = [];
888
+ // Emit a content block and immediately append (and consume) any
889
+ // citations registered at that block's index. Centralizing the
890
+ // per-emission interleave step here means each arm of the walk
891
+ // below just calls `emit(block, idx)`; a new block kind can't
892
+ // forget the interleave step. Consumed indices are deleted from
893
+ // `citationsByIndex` so the post-walk check below can detect any
894
+ // citation whose index pointed at a block that never emitted
895
+ // (orphan reference or block filtered out during finalization)
896
+ // and surface it loudly rather than silently dropping the
897
+ // citation from `content[]`.
898
+ const emit = (block, idx) => {
899
+ contentBlocks.push(block);
900
+ const atIdx = citationsByIndex.get(idx);
901
+ if (atIdx !== undefined) {
902
+ contentBlocks.push(...atIdx);
903
+ citationsByIndex.delete(idx);
904
+ }
905
+ };
906
+ for (const [idx, entry] of blockMap.entries()) {
907
+ if (entry.kind === "text") {
908
+ // Emit even with empty text if a signature was captured, so a
909
+ // signature riding on an otherwise-empty text carrier still
910
+ // round-trips (mirrors the thinking-block rule below).
911
+ if (entry.text.length === 0 && entry.signature === undefined) {
912
+ continue;
913
+ }
914
+ emit({
915
+ type: "text",
916
+ text: entry.text,
917
+ ...(entry.signature !== undefined
918
+ ? { signature: entry.signature }
919
+ : {}),
920
+ }, idx);
921
+ continue;
922
+ }
923
+ if (entry.kind === "thinking") {
924
+ // Emit thinking blocks even when text is empty if a signature
925
+ // was captured — Anthropic's redacted-adjacent flow can
926
+ // produce a thinking block whose visible text is empty but
927
+ // whose signature must round-trip on follow-up turns.
928
+ if (entry.text.length === 0 && entry.signature === undefined) {
929
+ continue;
930
+ }
931
+ emit({
932
+ type: "thinking",
933
+ thinking: entry.text,
934
+ ...(entry.signature !== undefined
935
+ ? { signature: entry.signature }
936
+ : {}),
937
+ }, idx);
938
+ continue;
939
+ }
940
+ if (entry.kind === "redacted_thinking") {
941
+ emit({ type: "redacted_thinking", data: entry.data }, idx);
942
+ continue;
943
+ }
944
+ if (entry.kind === "refusal") {
945
+ // Empty-reason refusals were filtered at the adapter's wire
946
+ // boundary (the OpenAI parser skips delta.refusal chunks with
947
+ // length 0), so an entry that reaches the final walk with an
948
+ // empty reason indicates either a synthetic capture or a
949
+ // future adapter without that guard. Skip rather than emit a
950
+ // RefusalBlock with reason: "" which would fail the type's
951
+ // documented "human-readable text the model emitted" contract.
952
+ if (entry.reason.length === 0)
953
+ continue;
954
+ emit({ type: "refusal", reason: entry.reason }, idx);
955
+ continue;
956
+ }
957
+ if (entry.kind === "tool_use") {
958
+ const finalized = completedToolCallsByCallId.get(entry.callId);
959
+ if (finalized === undefined) {
960
+ // Every tool_use marker is added in the
961
+ // inference.tool_call.start handler at the same time the
962
+ // entry is inserted into openToolCalls. The finalize loop
963
+ // above turns every openToolCalls entry into a
964
+ // completedToolCalls entry. So a marker whose callId is
965
+ // missing from completedToolCallsByCallId here would mean
966
+ // the start-time bookkeeping diverged from the finalize-
967
+ // time bookkeeping — surface it loudly rather than dropping
968
+ // the tool call from the final turn.
969
+ throw new ProtocolMismatchError(`harness: tool_use marker at callId ${entry.callId} has no matching completed tool call`, entry);
970
+ }
971
+ if (finalized.type !== "tool_call") {
972
+ throw new ProtocolMismatchError(`harness: tool_use marker at callId ${entry.callId} resolved to a ${finalized.type} block, not a tool_call`, entry);
973
+ }
974
+ emit(entry.signature !== undefined
975
+ ? { ...finalized, signature: entry.signature }
976
+ : finalized, idx);
977
+ continue;
978
+ }
979
+ if (entry.kind === "image") {
980
+ // Image blocks land here when an adapter delivered an
981
+ // `inference.image_output` event at this index. The
982
+ // ImageBlock is stored complete on the entry (images are
983
+ // atomic, not streamed), so the final-walk emits it
984
+ // verbatim. Citation interleave applies the same way as
985
+ // any other block kind.
986
+ emit(entry.signature !== undefined
987
+ ? { ...entry.image, signature: entry.signature }
988
+ : entry.image, idx);
989
+ continue;
990
+ }
991
+ if (entry.kind === "code_execution_request") {
992
+ // The request block carries whatever code accumulated
993
+ // across `code_execution.start` plus any subsequent
994
+ // `code_execution.delta` events at this index. Gemini's
995
+ // current wire delivers all of it atomically on `start`;
996
+ // streaming providers would extend `request.code` via the
997
+ // delta handler before this walk runs.
998
+ emit(entry.signature !== undefined
999
+ ? { ...entry.request, signature: entry.signature }
1000
+ : entry.request, idx);
1001
+ continue;
1002
+ }
1003
+ if (entry.kind === "code_execution_result") {
1004
+ emit(entry.result, idx);
1005
+ continue;
1006
+ }
1007
+ entry;
1008
+ }
1009
+ if (citationsByIndex.size > 0) {
1010
+ // A citation whose `index` pointed at a block that never made
1011
+ // it into `content[]` would otherwise be silently dropped. The
1012
+ // cases that get here in practice are upstream bugs: an adapter
1013
+ // emitted a citation indexed at a block that doesn't exist, or
1014
+ // at a block that the finalize walk filtered out (empty text,
1015
+ // empty thinking with no signature). Surface the bookkeeping
1016
+ // mismatch loudly rather than papering over it.
1017
+ const orphanIndices = Array.from(citationsByIndex.keys()).sort((a, b) => a - b);
1018
+ throw new ProtocolMismatchError(`harness: ${String(citationsByIndex.size)} citation index/indices have no matching emitted block in the final turn: ${orphanIndices.join(", ")}`, { orphanIndices });
1019
+ }
1020
+ contentBlocks.push(...unindexedCitations);
1021
+ contentBlocks.push(...unindexedSafetyRatings);
1022
+ const finalTurn = {
1023
+ role: "assistant",
1024
+ content: contentBlocks,
1025
+ model,
1026
+ timestamp: Date.now(),
1027
+ };
1028
+ const pacingDelayMs = adapter.extractPacingDelayMs?.(response.headers);
1029
+ yield {
1030
+ type: "inference.done",
1031
+ seq: nextSeq(),
1032
+ data: {
1033
+ turn: finalTurn,
1034
+ usage: finalUsage,
1035
+ source: lastCycleSource,
1036
+ ...(pacingDelayMs !== undefined && pacingDelayMs > 0
1037
+ ? { pacingDelayMs }
1038
+ : {}),
1039
+ },
1040
+ };
1041
+ }
1042
+ finally {
1043
+ // Single owner of the timer + signal-listener lifecycle. Runs on
1044
+ // every exit including normal completion, early `return`, thrown
1045
+ // errors, and consumer abandonment via `for await` `break`
1046
+ // (which invokes the generator's `return()` and triggers the
1047
+ // finally). Both cleanups are idempotent.
1048
+ cleanupTimers();
1049
+ cleanupSignal();
1050
+ }
1051
+ }
1052
+ /**
1053
+ * Run a single inference call with mechanical retry. Wraps
1054
+ * `runSingleAttempt` and consults the configured `RetryPolicy` (or the
1055
+ * default from `createDefaultRetryPolicy`) on every `inference.error`.
1056
+ *
1057
+ * Events from each attempt are buffered until the attempt terminates;
1058
+ * the wrapper only flushes them to the caller once it knows whether
1059
+ * the attempt resolved (`inference.done` or a policy-approved abort)
1060
+ * or whether the attempt's events should be discarded in favour of a
1061
+ * retry. The buffer-and-flush model is what guarantees the caller
1062
+ * sees a single clean event stream — exactly one `inference.start`,
1063
+ * no orphaned partial deltas, no leaked `inference.error`s from
1064
+ * attempts the policy chose to retry. The cost is that no events
1065
+ * reach the caller until the wrapper knows the attempt's terminal
1066
+ * shape, even on a successful first attempt. That trade-off is the
1067
+ * deliberate consequence of making "one clean stream" a hard contract
1068
+ * rather than a best-effort one. Consumers that need token-by-token
1069
+ * partials must pin a custom non-buffering wrapper — no streaming-
1070
+ * partials emission API exists today.
1071
+ *
1072
+ * The buffer is per-call and bounded by the size of one attempt's
1073
+ * event stream — no cross-call accumulation.
1074
+ *
1075
+ * Caller-visible seqs stay contiguous across retries. Each attempt
1076
+ * runs against a private seq allocator; on flush the wrapper
1077
+ * re-stamps the buffered events with seqs from the caller's
1078
+ * `nextSeq`, so a retry that discards an attempt does not leave a
1079
+ * gap in the consumer's seq stream.
1080
+ *
1081
+ * Between attempts the wrapper emits one `inference.retry` event with
1082
+ * the failed attempt's number, the policy-chosen `delayMs`, and the
1083
+ * classified error that triggered the retry. The `setTimeout` await
1084
+ * is driven by `deps.scheduler`, so virtual-clock test harnesses
1085
+ * advance retry delays without sleeping real wall-clock. The
1086
+ * caller-supplied `signal` short-circuits the retry delay: aborting
1087
+ * the signal mid-delay wakes the await immediately and the next
1088
+ * `runSingleAttempt` invocation surfaces `inference.error` of
1089
+ * category `aborted` from its entry-time signal check, which the
1090
+ * default policy aborts on.
1091
+ *
1092
+ * Policy-failure handling: if the policy throws synchronously or its
1093
+ * returned Promise rejects, the wrapper treats the failure as
1094
+ * `{ kind: "abort" }` and surfaces the *original* `inference.error`
1095
+ * to the caller. The policy's own exception is logged at `warn` so
1096
+ * operators can see when a custom policy is failing under load, and
1097
+ * dropped — the inference error is what the caller needs to act on,
1098
+ * not the bug in the policy callback.
1099
+ *
1100
+ * Synchronous throws from `runSingleAttempt` (`ProtocolMismatchError`
1101
+ * raised by the streaming parse or the finalization walk, etc.)
1102
+ * propagate out of `runInference`. The current attempt's buffered
1103
+ * events are discarded along with the throw — those represent
1104
+ * protocol bugs the policy mechanism is not equipped to absorb, and
1105
+ * the caller's `for await` rejects so the failure surfaces rather
1106
+ * than being silently buffered.
1107
+ */
1108
+ export async function* runInference(opts) {
1109
+ // Crash-loudly guards. The wrapper and the attempts it drives access
1110
+ // `deps.fetch` and `deps.adapters` (per `runSingleAttempt` invocation)
1111
+ // and `deps.scheduler` (read here for the monotonic time source) before
1112
+ // any event yields. A malformed `deps` from a JS caller would otherwise
1113
+ // surface as a confusing `Cannot read properties of undefined`. The
1114
+ // wrapper is the single public entrypoint to the harness; this is the
1115
+ // right layer to own the `deps` shape check.
1116
+ if (typeof opts.deps?.fetch !== "function") {
1117
+ throw new Error(`runInference: deps.fetch must be a function (got ${typeof opts.deps?.fetch}); pass createDefaultDependencies() or a test harness Dependencies object`);
1118
+ }
1119
+ if (typeof opts.deps.scheduler?.now !== "function") {
1120
+ const schedulerType = typeof opts.deps.scheduler;
1121
+ const detail = schedulerType === "object"
1122
+ ? "scheduler is missing the now() method"
1123
+ : `got ${schedulerType}`;
1124
+ throw new Error(`runInference: deps.scheduler must implement now() (${detail}); pass createDefaultDependencies() or a test harness Dependencies object`);
1125
+ }
1126
+ if (typeof opts.deps.adapters?.resolve !== "function") {
1127
+ const adaptersType = typeof opts.deps.adapters;
1128
+ const detail = adaptersType === "object"
1129
+ ? "adapters is missing the resolve() method"
1130
+ : `got ${adaptersType}`;
1131
+ throw new Error(`runInference: deps.adapters must implement resolve() (${detail}); pass createDependencies(adapters), createDefaultDependencies(), or a test harness Dependencies object`);
1132
+ }
1133
+ const policy = opts.inferenceOptions?.retryPolicy ?? createDefaultRetryPolicy();
1134
+ // The guards above proved `opts.deps.scheduler` is well-formed; the
1135
+ // rest of the wrapper reads it directly without the `?.` ceremony.
1136
+ const scheduler = opts.deps.scheduler;
1137
+ const startedAtMs = scheduler.now();
1138
+ const signal = opts.signal;
1139
+ for (let attempt = 1;; attempt++) {
1140
+ const buffered = [];
1141
+ let terminalError;
1142
+ // Per-attempt private allocator. `runSingleAttempt` allocates a
1143
+ // seq for every event it yields; if the attempt is discarded on
1144
+ // retry, any caller-visible seq it had consumed would leave a
1145
+ // gap in the consumer's stream — indistinguishable from the
1146
+ // "missed events during brief disconnection" the seq stream is
1147
+ // documented to expose. Allocate from a private counter here and
1148
+ // re-stamp the buffer with caller-visible seqs at flush time.
1149
+ let attemptSeq = 0;
1150
+ const attemptOpts = {
1151
+ ...opts,
1152
+ nextSeq: () => attemptSeq++,
1153
+ };
1154
+ for await (const event of runSingleAttempt(attemptOpts)) {
1155
+ buffered.push(event);
1156
+ if (event.type === "inference.error") {
1157
+ terminalError = event.data.error;
1158
+ break;
1159
+ }
1160
+ if (event.type === "inference.done") {
1161
+ break;
1162
+ }
1163
+ }
1164
+ if (terminalError === undefined) {
1165
+ // Successful attempt. Re-stamp the buffer with caller-visible
1166
+ // seqs (the private allocator's values are discarded) and
1167
+ // flush in order.
1168
+ for (const event of buffered)
1169
+ yield { ...event, seq: opts.nextSeq() };
1170
+ return;
1171
+ }
1172
+ // Consult the policy. Sync throws and Promise rejections both
1173
+ // resolve to an abort decision; the original inference.error
1174
+ // surfaces to the caller, not the policy's exception. The
1175
+ // exception is logged at warn so a custom policy that
1176
+ // misbehaves under load is not invisible — swallowing the
1177
+ // failure silently would hide the bug from operators.
1178
+ let decision;
1179
+ try {
1180
+ decision = await Promise.resolve(policy({
1181
+ error: terminalError,
1182
+ attempt,
1183
+ elapsedMs: scheduler.now() - startedAtMs,
1184
+ }));
1185
+ }
1186
+ catch (cause) {
1187
+ logger.warn `Retry policy threw at attempt ${String(attempt)}; treating as abort. error=${cause instanceof Error ? cause.message : String(cause)}`;
1188
+ decision = { kind: "abort" };
1189
+ }
1190
+ if (decision.kind === "abort") {
1191
+ // Flush the buffer (including the terminal inference.error)
1192
+ // with re-stamped caller-visible seqs and return. No
1193
+ // `inference.retry` event is emitted on the abort path.
1194
+ for (const event of buffered)
1195
+ yield { ...event, seq: opts.nextSeq() };
1196
+ return;
1197
+ }
1198
+ // Retry: discard the failed attempt's events, emit a single
1199
+ // inference.retry, await the delay, and re-enter the loop.
1200
+ yield {
1201
+ type: "inference.retry",
1202
+ seq: opts.nextSeq(),
1203
+ data: {
1204
+ attempt,
1205
+ delayMs: decision.delayMs,
1206
+ previousError: terminalError,
1207
+ },
1208
+ };
1209
+ const retryDelayMs = decision.delayMs;
1210
+ // Wire the caller-supplied signal into the delay so an abort
1211
+ // during the wait short-circuits to the next attempt within a
1212
+ // single virtual tick rather than blocking until the full
1213
+ // `retryDelayMs` elapses. A 60-second `retryAfterMs` on a quota
1214
+ // error would otherwise pin the wrapper for the full minute
1215
+ // before honouring cancellation. The shape is the standard one
1216
+ // for racing a scheduled timeout against an abort listener: a
1217
+ // single `settled` flag plus a `settle()` helper that cancels
1218
+ // whichever side did not fire and removes the listener so the
1219
+ // caller signal does not accumulate one stale entry per call.
1220
+ await new Promise((resolve) => {
1221
+ let settled = false;
1222
+ const settle = () => {
1223
+ if (settled)
1224
+ return;
1225
+ settled = true;
1226
+ cancelTimer();
1227
+ if (signal !== undefined) {
1228
+ signal.removeEventListener("abort", onAbort);
1229
+ }
1230
+ resolve();
1231
+ };
1232
+ const onAbort = () => {
1233
+ settle();
1234
+ };
1235
+ const cancelTimer = scheduler.setTimeout(() => {
1236
+ settle();
1237
+ }, retryDelayMs);
1238
+ if (signal !== undefined) {
1239
+ if (signal.aborted) {
1240
+ settle();
1241
+ }
1242
+ else {
1243
+ signal.addEventListener("abort", onAbort, { once: true });
1244
+ }
1245
+ }
1246
+ });
1247
+ }
1248
+ }
1249
+ function combineSignals(caller, internal) {
1250
+ if (caller === undefined) {
1251
+ const noopCleanup = () => {
1252
+ /* no listener was attached */
1253
+ };
1254
+ return { signal: internal, cleanup: noopCleanup };
1255
+ }
1256
+ const composite = new AbortController();
1257
+ const onCallerAbort = () => {
1258
+ composite.abort(caller.reason);
1259
+ };
1260
+ const onInternalAbort = () => {
1261
+ composite.abort(internal.reason);
1262
+ };
1263
+ let cleanedUp = false;
1264
+ const cleanup = () => {
1265
+ if (cleanedUp)
1266
+ return;
1267
+ cleanedUp = true;
1268
+ caller.removeEventListener("abort", onCallerAbort);
1269
+ internal.removeEventListener("abort", onInternalAbort);
1270
+ };
1271
+ if (caller.aborted) {
1272
+ composite.abort(caller.reason);
1273
+ }
1274
+ else {
1275
+ caller.addEventListener("abort", onCallerAbort, { once: true });
1276
+ }
1277
+ if (internal.aborted) {
1278
+ composite.abort(internal.reason);
1279
+ }
1280
+ else {
1281
+ internal.addEventListener("abort", onInternalAbort, { once: true });
1282
+ }
1283
+ return { signal: composite.signal, cleanup };
1284
+ }
1285
+ /**
1286
+ * Await `promise` but reject early if `signal` aborts in the meantime.
1287
+ * Used for non-streaming reads of the error response body so a hostile
1288
+ * server cannot hang the call by returning a 4xx/5xx with a body that
1289
+ * never terminates. The signal's listener is always removed before
1290
+ * settlement so this helper does not itself leak listeners.
1291
+ */
1292
+ async function awaitWithSignal(promise, signal) {
1293
+ if (signal.aborted) {
1294
+ throw new DOMException("aborted", "AbortError");
1295
+ }
1296
+ return new Promise((resolve, reject) => {
1297
+ const onAbort = () => {
1298
+ reject(new DOMException("aborted", "AbortError"));
1299
+ };
1300
+ signal.addEventListener("abort", onAbort, { once: true });
1301
+ promise.then((value) => {
1302
+ signal.removeEventListener("abort", onAbort);
1303
+ resolve(value);
1304
+ }, (err) => {
1305
+ signal.removeEventListener("abort", onAbort);
1306
+ reject(err instanceof Error ? err : new Error(String(err)));
1307
+ });
1308
+ });
1309
+ }
1310
+ function snapshotPartial(partial) {
1311
+ return {
1312
+ text: partial.text,
1313
+ ...(partial.thinking !== undefined ? { thinking: partial.thinking } : {}),
1314
+ ...(partial.toolCalls !== undefined
1315
+ ? {
1316
+ toolCalls: partial.toolCalls.map((tc) => ({
1317
+ id: tc.id,
1318
+ name: tc.name,
1319
+ partialArguments: tc.partialArguments,
1320
+ })),
1321
+ }
1322
+ : {}),
1323
+ };
1324
+ }
1325
+ // The harness's per-index routing is load-bearing on every delta
1326
+ // carrying an `index`. Provider adapters synthesize a default at the
1327
+ // adapter boundary if their wire shape doesn't carry one (e.g.
1328
+ // OpenAI Chat Completions emits `index: 0` explicitly on text and
1329
+ // thinking deltas because Chat Completions ships a single content
1330
+ // block per kind per response). A delta arriving at the harness
1331
+ // without an index is a wiring bug at the adapter, not data the
1332
+ // harness should silently route to block 0 — surfacing it as a
1333
+ // ProtocolMismatchError is the load-bearing alternative to corrupt
1334
+ // state.
1335
+ function requireIndex(event, variant) {
1336
+ const index = event.data.index;
1337
+ if (index === undefined) {
1338
+ throw new ProtocolMismatchError(`harness received ${event.type} (${variant}) without an index; ` +
1339
+ `provider adapters must synthesize an index at the boundary even ` +
1340
+ `when the wire shape doesn't carry one`, event);
1341
+ }
1342
+ return index;
1343
+ }
1344
+ function mergeUsage(existing, incoming) {
1345
+ if (existing === null)
1346
+ return incoming;
1347
+ return {
1348
+ input: existing.input + incoming.input,
1349
+ output: existing.output + incoming.output,
1350
+ cacheRead: existing.cacheRead + incoming.cacheRead,
1351
+ cacheWrite: existing.cacheWrite + incoming.cacheWrite,
1352
+ thinking: existing.thinking + incoming.thinking,
1353
+ };
1354
+ }
1355
+ function resolveURL(path, baseURL) {
1356
+ if (path.startsWith("http://") || path.startsWith("https://")) {
1357
+ return path;
1358
+ }
1359
+ const base = baseURL.endsWith("/") ? baseURL.slice(0, -1) : baseURL;
1360
+ return base + path;
1361
+ }
1362
+ const ParsedToolArgs = type("Record<string, unknown>");
1363
+ const ErrorBody = type({ error: { message: "string" } });
1364
+ const DirectMessageBody = type({ message: "string" });
1365
+ /**
1366
+ * Upper bound on the length of a plain-text error body that gets
1367
+ * promoted to `InferenceError.message`. Bodies longer than this are
1368
+ * truncated with a marker pointing operators at `error.raw`, which
1369
+ * always retains the untruncated body. Structured JSON envelopes are
1370
+ * not subject to this cap — their `message` fields are server-curated
1371
+ * and concise in practice.
1372
+ *
1373
+ * 500 characters covers a multi-line stack trace or a paragraph of
1374
+ * diagnostic text without blowing up the default director's
1375
+ * user-facing reply (which concatenates the message into a chat-style
1376
+ * string) or the timeline part stored by the hub event collector.
1377
+ */
1378
+ const MAX_PLAIN_TEXT_MESSAGE_CHARS = 500;
1379
+ function truncatePlainTextMessage(text) {
1380
+ if (text.length <= MAX_PLAIN_TEXT_MESSAGE_CHARS)
1381
+ return text;
1382
+ return `${text.slice(0, MAX_PLAIN_TEXT_MESSAGE_CHARS)}… (truncated; full body in error.raw)`;
1383
+ }
1384
+ function extractErrorMessage(body) {
1385
+ // Anthropic/OpenAI: { error: { message: "..." } }
1386
+ const errorBody = ErrorBody(body);
1387
+ if (!(errorBody instanceof type.errors)) {
1388
+ return errorBody.error.message;
1389
+ }
1390
+ // Direct message field as fallback.
1391
+ const directBody = DirectMessageBody(body);
1392
+ if (!(directBody instanceof type.errors)) {
1393
+ return directBody.message;
1394
+ }
1395
+ // Plain-text error bodies (HTML error pages, raw exception strings,
1396
+ // load-balancer diagnostics). The body reaches us via the
1397
+ // text-then-parse path in the `!response.ok` branch: when
1398
+ // JSON.parse failed, the raw string is stored as errorBody.
1399
+ // Surfacing it here means the operator-visible message contains
1400
+ // the server's actual diagnostic rather than just `statusText`.
1401
+ // `error.raw` always holds the untruncated body for audit-time
1402
+ // inspection.
1403
+ if (typeof body === "string" && body.length > 0) {
1404
+ return truncatePlainTextMessage(body);
1405
+ }
1406
+ return null;
1407
+ }