@intx/inference 0.1.2 → 0.2.2

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