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