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