@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.
- package/LICENSE +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +38 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +68 -0
- package/dist/assembly.js +132 -0
- package/dist/audit-collector.d.ts +10 -0
- package/dist/audit-collector.js +139 -0
- package/dist/auth.d.ts +24 -0
- package/{src/auth.ts → dist/auth.js} +13 -19
- package/dist/authz-extension.d.ts +32 -0
- package/dist/authz-extension.js +100 -0
- package/dist/correlation.d.ts +25 -0
- package/dist/correlation.js +32 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +199 -0
- package/dist/director.d.ts +6 -0
- package/dist/director.js +56 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +83 -0
- package/dist/gates.d.ts +27 -0
- package/dist/gates.js +80 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1319 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +21 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +44 -0
- package/dist/providers/anthropic.d.ts +33 -0
- package/dist/providers/anthropic.js +670 -0
- package/dist/providers/google-genai-files.d.ts +48 -0
- package/dist/providers/google-genai-files.js +205 -0
- package/dist/providers/google-genai.d.ts +3 -0
- package/dist/providers/google-genai.js +1196 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +3 -0
- package/dist/providers/openai.js +609 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +920 -0
- package/dist/retry-policy.d.ts +31 -0
- package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +63 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.js +100 -0
- package/dist/tool-name.d.ts +6 -0
- package/dist/tool-name.js +110 -0
- package/dist/transform.d.ts +11 -0
- package/dist/transform.js +117 -0
- package/dist/transforms/index.d.ts +2 -0
- package/dist/transforms/index.js +1 -0
- package/dist/transforms/size-cap.d.ts +12 -0
- package/dist/transforms/size-cap.js +80 -0
- package/dist/turns.d.ts +21 -0
- package/dist/turns.js +135 -0
- package/package.json +21 -6
- package/src/actions.ts +0 -245
- package/src/adapter.ts +0 -57
- package/src/assembly.test.ts +0 -728
- package/src/assembly.ts +0 -250
- package/src/audit-collector.test.ts +0 -332
- package/src/audit-collector.ts +0 -172
- package/src/auth.test.ts +0 -117
- package/src/authz-extension.test.ts +0 -269
- package/src/authz-extension.ts +0 -145
- package/src/correlation.ts +0 -61
- package/src/default-director.test.ts +0 -314
- package/src/default-director.ts +0 -344
- package/src/director.ts +0 -87
- package/src/errors.test.ts +0 -133
- package/src/errors.ts +0 -115
- package/src/gates.ts +0 -128
- package/src/harness.test.ts +0 -655
- package/src/harness.ts +0 -1571
- package/src/index.ts +0 -76
- package/src/providers/anthropic.test.ts +0 -771
- package/src/providers/anthropic.ts +0 -810
- package/src/providers/google-genai-files.ts +0 -289
- package/src/providers/google-genai.ts +0 -1518
- package/src/providers/openai.ts +0 -719
- package/src/providers/registry.ts +0 -33
- package/src/reactor.test.ts +0 -3660
- package/src/reactor.ts +0 -1058
- package/src/scheduler.test.ts +0 -41
- package/src/sse.test.ts +0 -133
- package/src/sse.ts +0 -76
- package/src/state.ts +0 -135
- package/src/transform.test.ts +0 -207
- package/src/transform.ts +0 -159
- package/src/transforms/index.ts +0 -2
- package/src/transforms/size-cap.test.ts +0 -172
- package/src/transforms/size-cap.ts +0 -110
- package/src/turns.ts +0 -54
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/dist/harness.js
ADDED
|
@@ -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
|
+
}
|