@intx/inference 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +38 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +68 -0
  7. package/dist/assembly.js +132 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +32 -0
  13. package/dist/authz-extension.js +100 -0
  14. package/dist/correlation.d.ts +25 -0
  15. package/dist/correlation.js +32 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +199 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +27 -0
  23. package/dist/gates.js +80 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1319 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +33 -0
  31. package/dist/providers/anthropic.js +670 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +3 -0
  35. package/dist/providers/google-genai.js +1196 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +3 -0
  39. package/dist/providers/openai.js +609 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +920 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +117 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +21 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,609 @@
1
+ import { type } from "arktype";
2
+ import { BEARER_CREDENTIAL_SENTINEL } from "../auth.js";
3
+ import { ProtocolMismatchError } from "../errors.js";
4
+ import { decodeToolName, encodeToolName, } from "../tool-name.js";
5
+ // OpenAI's function-name constraint is `^[a-zA-Z0-9_-]{1,64}$`; the
6
+ // OpenAI-compatible backends this adapter also serves (DeepSeek, Kimi) share
7
+ // that charset and reject the raw package-qualified names outright.
8
+ const OPENAI_TOOL_NAME_LIMIT = {
9
+ provider: "openai",
10
+ maxLength: 64,
11
+ };
12
+ // ---------------------------------------------------------------------------
13
+ // Request building
14
+ // ---------------------------------------------------------------------------
15
+ function buildRequest(messages, model, options) {
16
+ const convertedMessages = messages.flatMap(toOpenAIMessage);
17
+ const body = {
18
+ model,
19
+ max_tokens: options.maxTokens ?? 4096,
20
+ messages: convertedMessages,
21
+ stream: true,
22
+ };
23
+ if (options.temperature !== undefined) {
24
+ body["temperature"] = options.temperature;
25
+ }
26
+ if (options.tools !== undefined && options.tools.length > 0) {
27
+ body["tools"] = options.tools.map((t) => ({
28
+ type: "function",
29
+ function: {
30
+ name: encodeToolName(t.name, OPENAI_TOOL_NAME_LIMIT),
31
+ description: t.description,
32
+ parameters: t.inputSchema,
33
+ },
34
+ }));
35
+ }
36
+ if (options.systemPrompt) {
37
+ // Prepend a system message if provided via options (takes priority over
38
+ // any system messages already in the history).
39
+ body["messages"] = [
40
+ { role: "system", content: options.systemPrompt },
41
+ ...convertedMessages,
42
+ ];
43
+ }
44
+ if (options.responseFormat !== undefined) {
45
+ body["response_format"] = toOpenAIResponseFormat(options.responseFormat);
46
+ }
47
+ return {
48
+ url: "/chat/completions",
49
+ headers: {
50
+ "content-type": "application/json",
51
+ authorization: BEARER_CREDENTIAL_SENTINEL,
52
+ },
53
+ body: JSON.stringify(body),
54
+ };
55
+ }
56
+ // Translate the internal `responseFormat` union to OpenAI's
57
+ // `response_format` field. The three kinds map one-to-one to OpenAI's
58
+ // `text` / `json_object` / `json_schema` types; in `json-schema` mode
59
+ // the caller's `name`, `schema`, and (optional) `strict` ride through
60
+ // verbatim. Strict mode is the path that produces structured `refusal`
61
+ // responses when the model declines a request -- the response-side
62
+ // parser handles those refusal chunks below.
63
+ function toOpenAIResponseFormat(format) {
64
+ switch (format.kind) {
65
+ case "text":
66
+ return { type: "text" };
67
+ case "json":
68
+ return { type: "json_object" };
69
+ case "json-schema": {
70
+ const jsonSchema = {
71
+ name: format.name,
72
+ schema: format.schema,
73
+ };
74
+ if (format.strict !== undefined)
75
+ jsonSchema["strict"] = format.strict;
76
+ return { type: "json_schema", json_schema: jsonSchema };
77
+ }
78
+ }
79
+ }
80
+ function toOpenAIMessage(msg) {
81
+ if (msg.role === "system") {
82
+ const text = msg.content
83
+ .filter((b) => b.type === "text")
84
+ .map((b) => b.text)
85
+ .join("\n\n");
86
+ return [{ role: "system", content: text }];
87
+ }
88
+ if (msg.role === "user") {
89
+ // Check if any block is a tool result — if so, emit as tool role messages.
90
+ const toolResults = msg.content.filter((b) => b.type === "tool_result");
91
+ if (toolResults.length > 0) {
92
+ // One tool role message per result. The OpenAI Chat Completions schema
93
+ // for `role: "tool"` only permits role/tool_call_id/content — there is
94
+ // no `is_error` field — so error status is encoded inside `content`.
95
+ return toolResults.map((r) => {
96
+ const text = r.content
97
+ .filter((c) => c.type === "text")
98
+ .map((c) => c.text)
99
+ .join("\n");
100
+ return {
101
+ role: "tool",
102
+ tool_call_id: r.callId,
103
+ content: r.isError ? `<error>\n${text}\n</error>` : text,
104
+ };
105
+ });
106
+ }
107
+ const parts = msg.content.map(toOpenAIContentPart);
108
+ // If all parts are plain strings, collapse to a single string.
109
+ if (parts.every((p) => typeof p === "string")) {
110
+ return [{ role: "user", content: parts.join("") }];
111
+ }
112
+ return [{ role: "user", content: parts }];
113
+ }
114
+ if (msg.role === "assistant") {
115
+ // Detect block types that cannot survive the OpenAI assistant
116
+ // message shape and surface the failure rather than silently
117
+ // dropping them. Code execution blocks are first-class semantic
118
+ // content; their loss would corrupt cross-provider conversations.
119
+ // RefusalBlocks are this adapter's own output (delta.refusal
120
+ // accumulates into one) but the round-trip back through history
121
+ // is not modeled — a silent drop would erase the refusal text on
122
+ // any continuation request, so the marshaling fails loudly
123
+ // alongside code_execution.
124
+ for (const block of msg.content) {
125
+ if (block.type === "code_execution_request" ||
126
+ block.type === "code_execution_result" ||
127
+ block.type === "refusal") {
128
+ throw new Error(`OpenAI adapter does not handle ${block.type} content blocks.`);
129
+ }
130
+ }
131
+ const textBlocks = msg.content.filter((b) => b.type === "text");
132
+ const thinkingBlocks = msg.content.filter((b) => b.type === "thinking");
133
+ const toolCalls = msg.content.filter((b) => b.type === "tool_call");
134
+ const result = { role: "assistant" };
135
+ if (textBlocks.length > 0) {
136
+ result["content"] = textBlocks.map((b) => b.text).join("");
137
+ }
138
+ else {
139
+ result["content"] = null;
140
+ }
141
+ // Some providers (e.g. kimi) require reasoning_content on ALL assistant
142
+ // messages when thinking is enabled. If thinking blocks exist anywhere in
143
+ // the conversation, every assistant message must carry reasoning_content —
144
+ // even if empty for that particular turn.
145
+ result["reasoning_content"] =
146
+ thinkingBlocks.length > 0
147
+ ? thinkingBlocks.map((b) => b.thinking).join("")
148
+ : "";
149
+ if (toolCalls.length > 0) {
150
+ result["tool_calls"] = toolCalls.map((tc) => ({
151
+ id: tc.id,
152
+ type: "function",
153
+ function: {
154
+ name: encodeToolName(tc.name, OPENAI_TOOL_NAME_LIMIT),
155
+ arguments: JSON.stringify(tc.arguments),
156
+ },
157
+ }));
158
+ }
159
+ return [result];
160
+ }
161
+ return [{ role: msg.role, content: "" }];
162
+ }
163
+ function toOpenAIContentPart(block) {
164
+ switch (block.type) {
165
+ case "text":
166
+ return block.text;
167
+ case "image": {
168
+ const source = block.source;
169
+ if (source.kind === "base64") {
170
+ return {
171
+ type: "image_url",
172
+ image_url: {
173
+ url: `data:${source.mimeType};base64,${source.data}`,
174
+ },
175
+ };
176
+ }
177
+ if (source.kind === "url") {
178
+ // OpenAI's image_url accepts a public URL verbatim alongside
179
+ // the data-URL form. The MediaSource's mimeType is not
180
+ // propagated on the wire — OpenAI infers content type from
181
+ // the URL response. The internal mimeType requirement still
182
+ // keeps the caller honest about what they have in hand.
183
+ return {
184
+ type: "image_url",
185
+ image_url: {
186
+ url: source.url,
187
+ },
188
+ };
189
+ }
190
+ if (source.kind === "file-reference") {
191
+ // OpenAI's Chat Completions endpoint accepts images only via
192
+ // `image_url: { url }` (data URL or public URL). It does not
193
+ // accept opaque uploaded-file references the way Anthropic's
194
+ // `{ type: "file", file_id }` does. A `file-reference`
195
+ // handle minted by some other provider (an Anthropic file_id,
196
+ // a Gemini fileUri) is meaningless to OpenAI; the adapter
197
+ // would have to round-trip the bytes through base64 to be
198
+ // useful, which is a caller-level choice, not an adapter one.
199
+ // Surface the constraint loudly with the apparent reference
200
+ // so an operator triaging the failure sees what was sent.
201
+ throw new Error(`OpenAI Chat Completions does not accept file-reference image ` +
202
+ `sources; the API only takes base64 data URLs or public URLs ` +
203
+ `via image_url. Received reference: ${source.reference}`);
204
+ }
205
+ source;
206
+ throw new Error(`unreachable: unknown MediaSource kind`);
207
+ }
208
+ case "audio":
209
+ case "video":
210
+ throw new Error(`OpenAI adapter does not yet handle ${block.type} content blocks.`);
211
+ case "document":
212
+ // OpenAI's Chat Completions added a `file` content type with
213
+ // `file_data`/`file_id` for PDF inputs, but the exact field
214
+ // names and required metadata (filename, content disposition)
215
+ // are version-sensitive and the OpenCode-Zen capture corpus
216
+ // carries no OpenAI document-input fixtures to ground-truth
217
+ // against. Surface the failure with explicit context rather
218
+ // than emitting an unverified wire shape that may 400 or — worse
219
+ // — silently land as malformed input the model ignores.
220
+ throw new Error("OpenAI adapter does not yet emit document content blocks; the " +
221
+ "Chat Completions file-content-type wire shape needs a captured " +
222
+ "fixture before the adapter can be wired against it.");
223
+ case "citation":
224
+ // Citation blocks are server-emitted attribution metadata for
225
+ // content the model already produced; they're not part of the
226
+ // active conversation state the next turn needs to make sense
227
+ // of. OpenAI's Chat Completions has no input wire shape for
228
+ // citations either, so re-uploading them on a follow-up turn
229
+ // would be ignored at best. Drop them when serializing history
230
+ // to OpenAI; a downstream consumer that wants to preserve them
231
+ // across provider switches reads the finalized turn's content[]
232
+ // directly. See INFERENCE.md § Cross-Provider Message
233
+ // Transformation for the general policy on history-drop fields.
234
+ return "";
235
+ case "code_execution_request":
236
+ case "code_execution_result":
237
+ // Code execution blocks are first-class semantic content; silently
238
+ // dropping them would lose the model's tool invocation entirely.
239
+ // OpenAI has no first-class code execution surface today.
240
+ throw new Error(`OpenAI adapter does not handle ${block.type} content blocks.`);
241
+ case "thinking":
242
+ // Thinking blocks are not forwarded to OpenAI endpoints.
243
+ return "";
244
+ case "redacted_thinking":
245
+ // Redacted thinking blocks are opaque by design; the cross-
246
+ // provider mapping is meaningless on OpenAI's surface.
247
+ return "";
248
+ case "tool_call":
249
+ case "tool_result":
250
+ // These are handled separately in toOpenAIMessage.
251
+ return "";
252
+ case "refusal":
253
+ // RefusalBlocks are output-only (delta.refusal accumulates into
254
+ // one). Echoing one back inside a user-role content array has
255
+ // no defined OpenAI wire shape; fail at the marshaling
256
+ // boundary rather than silently emit `null` part bytes that
257
+ // would round-trip as an unrecognized fragment.
258
+ throw new Error("OpenAI adapter does not handle refusal content blocks.");
259
+ }
260
+ }
261
+ // ---------------------------------------------------------------------------
262
+ // Response parsing
263
+ // ---------------------------------------------------------------------------
264
+ const EMPTY_PARTIAL = { text: "" };
265
+ // Fireworks (and likely other OpenAI-compatible deployments) emits
266
+ // `name: null` and `arguments: null` on tool-call delta fragments AFTER
267
+ // the start delta. arktype rejects `null` against `"string"` and would
268
+ // drop the whole chunk silently — taking the argument fragments with
269
+ // it. Accept `string | null` here and treat null the same as the field
270
+ // being absent at the consumer site.
271
+ const OpenAIToolCallDelta = type({
272
+ "index?": "number",
273
+ "id?": "string | null",
274
+ "function?": {
275
+ "name?": "string | null",
276
+ "arguments?": "string | null",
277
+ },
278
+ });
279
+ const OpenAIChunkDelta = type({
280
+ "role?": "string",
281
+ "content?": "string | null",
282
+ "reasoning_content?": "string | null",
283
+ "reasoning?": "string | null",
284
+ // Strict-mode structured-outputs refusal: when the model declines a
285
+ // JSON-schema request on policy grounds, the delta carries the
286
+ // refusal text in this field instead of `content`. Some
287
+ // OpenAI-compatible relays strip it before forwarding; the parser
288
+ // emits refusal events only when the field is present.
289
+ "refusal?": "string | null",
290
+ "tool_calls?": OpenAIToolCallDelta.array(),
291
+ });
292
+ const PromptTokensDetails = type({ "cached_tokens?": "number" }).or("null");
293
+ const CompletionTokensDetails = type({
294
+ "reasoning_tokens?": "number",
295
+ }).or("null");
296
+ const OpenAIChunkUsage = type({
297
+ "prompt_tokens?": "number",
298
+ "completion_tokens?": "number",
299
+ "prompt_tokens_details?": PromptTokensDetails,
300
+ "completion_tokens_details?": CompletionTokensDetails,
301
+ });
302
+ const OpenAIChunk = type({
303
+ "choices?": type({
304
+ "index?": "number",
305
+ delta: OpenAIChunkDelta,
306
+ "finish_reason?": "string | null",
307
+ }).array(),
308
+ "usage?": OpenAIChunkUsage.or("null"),
309
+ });
310
+ function getOrAssignTextIndex(state) {
311
+ if (state.textIndex === null) {
312
+ state.textIndex = state.nextIndex;
313
+ state.nextIndex += 1;
314
+ }
315
+ return state.textIndex;
316
+ }
317
+ function getOrAssignThinkingIndex(state) {
318
+ if (state.thinkingIndex === null) {
319
+ state.thinkingIndex = state.nextIndex;
320
+ state.nextIndex += 1;
321
+ }
322
+ return state.thinkingIndex;
323
+ }
324
+ function getOrAssignRefusalIndex(state) {
325
+ if (state.refusalIndex === null) {
326
+ state.refusalIndex = state.nextIndex;
327
+ state.nextIndex += 1;
328
+ }
329
+ return state.refusalIndex;
330
+ }
331
+ function getOrAssignToolCallIndex(state, toolCallIndex) {
332
+ const existing = state.toolCallBlockIndex.get(toolCallIndex);
333
+ if (existing !== undefined)
334
+ return existing;
335
+ const assigned = state.nextIndex;
336
+ state.nextIndex += 1;
337
+ state.toolCallBlockIndex.set(toolCallIndex, assigned);
338
+ return assigned;
339
+ }
340
+ function parseResponse(sseData, indexer, source) {
341
+ // parseSSE strips the `[DONE]` sentinel before yielding payloads, so
342
+ // anything that reaches us here is supposed to be a JSON chunk. A
343
+ // JSON.parse failure or an arktype rejection means the upstream
344
+ // emitted bytes that violate the OpenAI streaming protocol — a
345
+ // protocol mismatch, not a transport flake. Surface it through the
346
+ // harness's stream-error catch via ProtocolMismatchError so the
347
+ // resulting inference.error carries category "protocol_mismatch"
348
+ // and the offending data in error.raw, instead of silently dropping
349
+ // the chunk and leaving the agent to guess why a tool call arrived
350
+ // with empty arguments.
351
+ let parsed;
352
+ try {
353
+ parsed = JSON.parse(sseData);
354
+ }
355
+ catch (cause) {
356
+ const message = cause instanceof Error ? cause.message : String(cause);
357
+ throw new ProtocolMismatchError(`openai parseResponse: malformed JSON in SSE data payload: ${message}`, sseData);
358
+ }
359
+ const chunk = OpenAIChunk(parsed);
360
+ if (chunk instanceof type.errors) {
361
+ throw new ProtocolMismatchError(`openai parseResponse: SSE chunk failed schema validation: ${chunk.summary}`, parsed);
362
+ }
363
+ const seq = 0;
364
+ const { choices } = chunk;
365
+ if (choices === undefined || choices.length === 0) {
366
+ // Check for usage-only events (some providers send a final event with usage).
367
+ const { usage } = chunk;
368
+ if (usage != null) {
369
+ const tokenUsage = {
370
+ input: usage.prompt_tokens ?? 0,
371
+ output: usage.completion_tokens ?? 0,
372
+ cacheRead: usage.prompt_tokens_details?.cached_tokens ?? 0,
373
+ cacheWrite: 0,
374
+ thinking: usage.completion_tokens_details?.reasoning_tokens ?? 0,
375
+ };
376
+ return [
377
+ { type: "inference.usage", seq, data: { usage: tokenUsage, source } },
378
+ ];
379
+ }
380
+ return [];
381
+ }
382
+ const choice = choices[0];
383
+ if (choice === undefined)
384
+ return [];
385
+ const { delta } = choice;
386
+ const events = [];
387
+ // Providers stream reasoning tokens under different field names:
388
+ // - kimi (via OpenRouter): delta.reasoning
389
+ // - kimi (direct): delta.reasoning_content
390
+ // - DeepSeek / others: delta.reasoning_content
391
+ //
392
+ // OpenAI's Chat Completions ships reasoning_content and content as
393
+ // separate logical content blocks without a wire-level block index.
394
+ // The parser assigns indices on first observation in arrival order
395
+ // via the per-request `indexer`: whichever kind streams first lands
396
+ // at 0, the other (if it appears) at 1. This satisfies the harness's
397
+ // per-index routing contract — distinct kinds get distinct indices
398
+ // and the harness's collision detection between block kinds at the
399
+ // same index never fires from a normal OpenAI response.
400
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
401
+ if (typeof reasoning === "string" && reasoning.length > 0) {
402
+ events.push({
403
+ type: "inference.thinking.delta",
404
+ seq,
405
+ data: {
406
+ token: reasoning,
407
+ partial: EMPTY_PARTIAL,
408
+ index: getOrAssignThinkingIndex(indexer),
409
+ },
410
+ });
411
+ }
412
+ const { content } = delta;
413
+ if (typeof content === "string" && content.length > 0) {
414
+ events.push({
415
+ type: "inference.text.delta",
416
+ seq,
417
+ data: {
418
+ token: content,
419
+ partial: EMPTY_PARTIAL,
420
+ index: getOrAssignTextIndex(indexer),
421
+ },
422
+ });
423
+ }
424
+ // Strict-mode structured-outputs refusal. Allocate a content-block
425
+ // index via the same shared counter that text/thinking/tool_call use
426
+ // so a refusal that arrives interleaved with text (e.g. partial
427
+ // content emitted before the refusal kicks in) lands on its own
428
+ // block index rather than colliding with text.
429
+ const { refusal } = delta;
430
+ if (typeof refusal === "string" && refusal.length > 0) {
431
+ events.push({
432
+ type: "inference.refusal.delta",
433
+ seq,
434
+ data: {
435
+ token: refusal,
436
+ partial: EMPTY_PARTIAL,
437
+ index: getOrAssignRefusalIndex(indexer),
438
+ },
439
+ });
440
+ }
441
+ const { tool_calls: toolCallDeltas } = delta;
442
+ if (toolCallDeltas !== undefined) {
443
+ for (const tcDelta of toolCallDeltas) {
444
+ const toolCallSlot = tcDelta.index ?? 0;
445
+ // The harness's per-index map keys on content-block index, not
446
+ // OpenAI's `tool_calls[]` slot. Map this tool call's slot to a
447
+ // content-block index that doesn't collide with text/thinking:
448
+ // first observation of each unique `tcDelta.index` allocates a
449
+ // fresh content-block index from the shared `nextIndex`
450
+ // counter; subsequent deltas for the same slot reuse it.
451
+ const blockIndex = getOrAssignToolCallIndex(indexer, toolCallSlot);
452
+ // Normalize null → undefined: Fireworks emits literal null on every
453
+ // delta after the first; we treat that the same as the field being
454
+ // absent so the start / fragment branches below remain simple.
455
+ const id = tcDelta.id ?? undefined;
456
+ const fn = tcDelta.function;
457
+ const wireName = fn?.name ?? undefined;
458
+ const name = wireName !== undefined ? decodeToolName(wireName) : undefined;
459
+ const argFragment = fn?.arguments ?? undefined;
460
+ // Different providers shape these deltas differently:
461
+ // - OpenAI emits id + name + empty arguments in the first delta,
462
+ // then arguments-only deltas (no id, no name) for the body.
463
+ // - Fireworks (kimi-k2.6) emits id + index on EVERY delta, with
464
+ // name populated only on the first and arguments fragments on
465
+ // subsequent deltas. The non-first deltas carry name: null
466
+ // (normalized to undefined above) rather than omitting the
467
+ // field outright.
468
+ // Treat the two signals independently. A single delta may legitimately
469
+ // carry both a start signal (id + non-null name) and an argument
470
+ // fragment; both must be emitted.
471
+ //
472
+ // `data.callId` is the OpenAI-provided id when present
473
+ // (`tcDelta.id`); when absent on continuation deltas, the
474
+ // adapter synthesizes a per-stream placeholder from
475
+ // `toolCallSlot` so the harness's id-keyed accumulator can
476
+ // merge fragments until the real id resolves at finalize time.
477
+ // `data.index` is the content-block index allocated above —
478
+ // namespaced into the same counter as text/thinking indices so
479
+ // a tool_call arriving before any text doesn't collide with a
480
+ // later text block at the same numeric index.
481
+ if (id !== undefined && name !== undefined) {
482
+ events.push({
483
+ type: "inference.tool_call.start",
484
+ seq,
485
+ data: {
486
+ callId: id,
487
+ name,
488
+ partial: EMPTY_PARTIAL,
489
+ index: blockIndex,
490
+ },
491
+ });
492
+ }
493
+ if (argFragment !== undefined && argFragment.length > 0) {
494
+ // The delta's `callId` is a per-stream placeholder used by the
495
+ // harness to resolve fragments to the real id minted on the
496
+ // start event. Use `String(blockIndex)` rather than
497
+ // `String(toolCallSlot)` so the placeholder matches the key
498
+ // the harness registers in `indexToCallId` on start —
499
+ // otherwise a non-zero, non-contiguous `tcDelta.index`
500
+ // (single tool at slot 3, or parallel tools at slots 0/3)
501
+ // would land its fragments under a key the harness never
502
+ // registered, and the harness's accumulator would silently
503
+ // drop them.
504
+ events.push({
505
+ type: "inference.tool_call.delta",
506
+ seq,
507
+ data: {
508
+ callId: String(blockIndex),
509
+ argumentFragment: argFragment,
510
+ partial: EMPTY_PARTIAL,
511
+ index: blockIndex,
512
+ },
513
+ });
514
+ }
515
+ }
516
+ }
517
+ // finish_reason is checked but we emit nothing — the harness handles cleanup.
518
+ // (Keeping the reference here documents the field is intentionally unused.)
519
+ void choice.finish_reason;
520
+ // Usage at end of stream (stream_options: { include_usage: true }).
521
+ const usageInChunk = chunk.usage;
522
+ if (usageInChunk != null) {
523
+ const tokenUsage = {
524
+ input: usageInChunk.prompt_tokens ?? 0,
525
+ output: usageInChunk.completion_tokens ?? 0,
526
+ cacheRead: 0,
527
+ cacheWrite: 0,
528
+ thinking: 0,
529
+ };
530
+ events.push({
531
+ type: "inference.usage",
532
+ seq,
533
+ data: { usage: tokenUsage, source },
534
+ });
535
+ }
536
+ return events;
537
+ }
538
+ function extractRetryAfterMs(headers) {
539
+ // OpenAI's non-standard millisecond header takes priority
540
+ const retryMs = headers.get("retry-after-ms");
541
+ if (retryMs !== null) {
542
+ const ms = Number(retryMs);
543
+ if (Number.isFinite(ms) && ms > 0)
544
+ return Math.ceil(ms);
545
+ }
546
+ const raw = headers.get("retry-after");
547
+ if (raw !== null) {
548
+ const seconds = Number(raw);
549
+ if (Number.isFinite(seconds) && seconds > 0) {
550
+ return Math.ceil(seconds * 1000);
551
+ }
552
+ }
553
+ return undefined;
554
+ }
555
+ function extractPacingDelayMs(headers) {
556
+ const remaining = headers.get("x-ratelimit-remaining-requests");
557
+ if (remaining === null)
558
+ return undefined;
559
+ const n = Number(remaining);
560
+ if (!Number.isFinite(n) || n > 0)
561
+ return undefined;
562
+ const reset = headers.get("x-ratelimit-reset-requests");
563
+ if (reset === null)
564
+ return undefined;
565
+ const ms = parseDuration(reset);
566
+ return ms !== undefined && ms > 0 ? ms : undefined;
567
+ }
568
+ function parseDuration(value) {
569
+ let total = 0;
570
+ const pattern = /(\d+(?:\.\d+)?)(ms|s|m|h)/g;
571
+ let match;
572
+ while ((match = pattern.exec(value)) !== null) {
573
+ const num = Number(match[1]);
574
+ switch (match[2]) {
575
+ case "ms":
576
+ total += num;
577
+ break;
578
+ case "s":
579
+ total += num * 1000;
580
+ break;
581
+ case "m":
582
+ total += num * 60_000;
583
+ break;
584
+ case "h":
585
+ total += num * 3_600_000;
586
+ break;
587
+ }
588
+ }
589
+ return total > 0 ? Math.ceil(total) : undefined;
590
+ }
591
+ export function createOpenAIAdapter(source) {
592
+ // Per-request indexer state. Adapter instances are created per
593
+ // request (see `adapter.ts`), so each call to `createOpenAIAdapter`
594
+ // gets a fresh counter for assigning block indices to reasoning vs.
595
+ // content streams in arrival order.
596
+ const indexer = {
597
+ nextIndex: 0,
598
+ textIndex: null,
599
+ thinkingIndex: null,
600
+ refusalIndex: null,
601
+ toolCallBlockIndex: new Map(),
602
+ };
603
+ return {
604
+ buildRequest,
605
+ parseResponse: (sseData) => parseResponse(sseData, indexer, source),
606
+ extractRetryAfterMs,
607
+ extractPacingDelayMs,
608
+ };
609
+ }
@@ -0,0 +1,50 @@
1
+ import type { InboundMessage, InferenceEvent, InferenceSource, ReactorDirector, ContextStore, ToolRunner, AbortReason, BeforeToolExtension, ToolResultTransform, ContextTransform, Compactor } from "@intx/types/runtime";
2
+ import type { Dependencies, InferenceHarnessOptions } from "./harness.js";
3
+ import type { CorrelationValidator } from "./correlation.js";
4
+ export type ReactorEmittedEvent = InferenceEvent | {
5
+ type: "message.received";
6
+ seq: number;
7
+ data: {
8
+ message: InboundMessage;
9
+ };
10
+ };
11
+ export type ReactorConfig = {
12
+ sessionId: string;
13
+ director: ReactorDirector;
14
+ source: InferenceSource;
15
+ /**
16
+ * Fail over `source` to the next entry in the priority-ordered source
17
+ * list, in place, returning false at the end of the list. When omitted the
18
+ * reactor runs the single active source with no failover.
19
+ */
20
+ failOverToNextSource?: () => boolean;
21
+ /** Reset `source` to the most-preferred source, in place. */
22
+ resetToPreferredSource?: () => void;
23
+ toolRunner: ToolRunner;
24
+ contextStore: ContextStore;
25
+ correlationValidator?: CorrelationValidator;
26
+ onEvent: (event: ReactorEmittedEvent) => void;
27
+ deps: Dependencies;
28
+ inferenceRunner?: (opts: InferenceHarnessOptions) => AsyncGenerator<InferenceEvent>;
29
+ beforeToolExtensions?: BeforeToolExtension[];
30
+ toolResultTransforms?: ToolResultTransform[];
31
+ contextTransforms?: ContextTransform[];
32
+ compactors?: Record<string, Compactor>;
33
+ afterCheckpoint?: () => Promise<void>;
34
+ onShutdown?: () => Promise<void>;
35
+ gateTimeout?: number;
36
+ shutdownTimeoutMs?: number;
37
+ };
38
+ export type Reactor = {
39
+ /** Begin processing. Emits reactor.start. Must be called exactly once. */
40
+ start(): void;
41
+ /** Inject an inbound message into the reactor. */
42
+ deliver(message: InboundMessage): void;
43
+ /** Initiate graceful shutdown with a reason. */
44
+ abort(reason: AbortReason): void;
45
+ };
46
+ /**
47
+ * Creates a reactor instance bound to the given configuration.
48
+ * Call `start()` to begin the event loop.
49
+ */
50
+ export declare function createReactor(config: ReactorConfig): Reactor;