@intx/inference 0.1.2 → 0.3.0

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 +40 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +75 -0
  7. package/dist/assembly.js +133 -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 +46 -0
  13. package/dist/authz-extension.js +184 -0
  14. package/dist/correlation.d.ts +26 -0
  15. package/dist/correlation.js +39 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +228 -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 +28 -0
  23. package/dist/gates.js +103 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1407 -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 +37 -0
  31. package/dist/providers/anthropic.js +917 -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 +5 -0
  35. package/dist/providers/google-genai.js +1205 -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 +9 -0
  39. package/dist/providers/openai.js +903 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +1233 -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 +132 -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 +22 -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,903 @@
1
+ import { type } from "arktype";
2
+ import { formatSafetyRatingText } from "@intx/types/runtime";
3
+ import { BEARER_CREDENTIAL_SENTINEL } from "../auth.js";
4
+ import { ProtocolMismatchError } from "../errors.js";
5
+ import { decodeToolName, encodeToolName, } from "../tool-name.js";
6
+ // OpenAI's function-name constraint is `^[a-zA-Z0-9_-]{1,64}$`; the
7
+ // OpenAI-compatible backends this adapter also serves (DeepSeek, Kimi) share
8
+ // that charset and reject the raw package-qualified names outright.
9
+ const OPENAI_TOOL_NAME_LIMIT = {
10
+ provider: "openai",
11
+ maxLength: 64,
12
+ };
13
+ // Per-source accommodations for the OpenAI-compatible backends this adapter
14
+ // serves. Every field is optional; an absent field resolves to the strict
15
+ // protocol default, so a source that supplies no quirks gets no accommodation
16
+ // and must opt into lenient behavior explicitly.
17
+ export const OpenAIQuirks = type({
18
+ // When true, emit `reasoning_content` on every assistant message even when
19
+ // the turn carried no thinking (kimi requires it whenever thinking is
20
+ // enabled). Defaults to false: the field is emitted only on turns that
21
+ // actually have thinking.
22
+ "forceAssistantReasoningContent?": "boolean",
23
+ // Which delta fields to read reasoning tokens from, in precedence order.
24
+ // Constrained to the fields the chunk schema declares so the type cannot
25
+ // promise a field the parser would drop before reading.
26
+ "reasoningFieldNames?": "('reasoning_content' | 'reasoning')[]",
27
+ // Which field carries the output-token cap. First-party OpenAI gpt-5.x
28
+ // rejects `max_tokens` and requires `max_completion_tokens`; relays served
29
+ // through the same adapter (e.g. OpenCode Zen) still take `max_tokens`.
30
+ // Defaults to `max_tokens` so every existing deployment is unchanged.
31
+ "maxTokensField?": "'max_tokens' | 'max_completion_tokens'",
32
+ // Reject unknown keys so a mistyped quirk name fails loudly at construction
33
+ // rather than being silently ignored and running with default behavior.
34
+ "+": "reject",
35
+ });
36
+ const DEFAULT_REASONING_FIELDS = [
37
+ "reasoning_content",
38
+ "reasoning",
39
+ ];
40
+ // ---------------------------------------------------------------------------
41
+ // Request building
42
+ // ---------------------------------------------------------------------------
43
+ function buildRequest(messages, model, options, quirks) {
44
+ const convertedMessages = messages.flatMap((msg) => toOpenAIMessage(msg, quirks.forceAssistantReasoningContent));
45
+ const body = {
46
+ model,
47
+ [quirks.maxTokensField]: options.maxTokens ?? 4096,
48
+ messages: convertedMessages,
49
+ stream: true,
50
+ };
51
+ if (options.temperature !== undefined) {
52
+ body["temperature"] = options.temperature;
53
+ }
54
+ if (options.tools !== undefined && options.tools.length > 0) {
55
+ body["tools"] = options.tools.map((t) => ({
56
+ type: "function",
57
+ function: {
58
+ name: encodeToolName(t.name, OPENAI_TOOL_NAME_LIMIT),
59
+ description: t.description,
60
+ parameters: t.inputSchema,
61
+ },
62
+ }));
63
+ // gpt-5.6 Chat Completions rejects function tools unless
64
+ // reasoning_effort is explicitly "none" (reasoned tool use is on
65
+ // the Responses API). Keep this list aligned with the discovery
66
+ // protocol builder's TOOL_CALL_REASONING_NONE_MODELS set.
67
+ if (model === "gpt-5.6-sol" ||
68
+ model === "gpt-5.6-terra" ||
69
+ model === "gpt-5.6-luna") {
70
+ body["reasoning_effort"] = "none";
71
+ }
72
+ }
73
+ if (options.systemPrompt) {
74
+ // Prepend a system message if provided via options (takes priority over
75
+ // any system messages already in the history).
76
+ body["messages"] = [
77
+ { role: "system", content: options.systemPrompt },
78
+ ...convertedMessages,
79
+ ];
80
+ }
81
+ if (options.responseFormat !== undefined) {
82
+ body["response_format"] = toOpenAIResponseFormat(options.responseFormat);
83
+ }
84
+ return {
85
+ url: "/chat/completions",
86
+ headers: {
87
+ "content-type": "application/json",
88
+ authorization: BEARER_CREDENTIAL_SENTINEL,
89
+ },
90
+ body: JSON.stringify(body),
91
+ };
92
+ }
93
+ // Translate the internal `responseFormat` union to OpenAI's
94
+ // `response_format` field. The three kinds map one-to-one to OpenAI's
95
+ // `text` / `json_object` / `json_schema` types; in `json-schema` mode
96
+ // the caller's `name`, `schema`, and (optional) `strict` ride through
97
+ // verbatim. Strict mode is the path that produces structured `refusal`
98
+ // responses when the model declines a request -- the response-side
99
+ // parser handles those refusal chunks below.
100
+ function toOpenAIResponseFormat(format) {
101
+ switch (format.kind) {
102
+ case "text":
103
+ return { type: "text" };
104
+ case "json":
105
+ return { type: "json_object" };
106
+ case "json-schema": {
107
+ const jsonSchema = {
108
+ name: format.name,
109
+ schema: format.schema,
110
+ };
111
+ if (format.strict !== undefined)
112
+ jsonSchema["strict"] = format.strict;
113
+ return { type: "json_schema", json_schema: jsonSchema };
114
+ }
115
+ }
116
+ }
117
+ function toOpenAIMessage(msg, forceAssistantReasoningContent) {
118
+ if (msg.role === "system") {
119
+ const text = msg.content
120
+ .filter((b) => b.type === "text")
121
+ .map((b) => b.text)
122
+ .join("\n\n");
123
+ return [{ role: "system", content: text }];
124
+ }
125
+ if (msg.role === "user") {
126
+ // Check if any block is a tool result — if so, emit as tool role messages.
127
+ const toolResults = msg.content.filter((b) => b.type === "tool_result");
128
+ if (toolResults.length > 0) {
129
+ // One tool role message per result. The OpenAI Chat Completions schema
130
+ // for `role: "tool"` only permits role/tool_call_id/content — there is
131
+ // no `is_error` field — so error status is encoded inside `content`.
132
+ return toolResults.map((r) => {
133
+ const text = r.content
134
+ .filter((c) => c.type === "text")
135
+ .map((c) => c.text)
136
+ .join("\n");
137
+ return {
138
+ role: "tool",
139
+ tool_call_id: r.callId,
140
+ content: r.isError ? `<error>\n${text}\n</error>` : text,
141
+ };
142
+ });
143
+ }
144
+ const parts = msg.content.map(toOpenAIContentPart);
145
+ // If all parts are plain strings, collapse to a single string.
146
+ if (parts.every((p) => typeof p === "string")) {
147
+ return [{ role: "user", content: parts.join("") }];
148
+ }
149
+ // Multimodal messages must use typed content parts. Bare strings next
150
+ // to image_url / file parts are not the Chat Completions wire shape
151
+ // (live vision and document captures use { type: "text", text }).
152
+ return [
153
+ {
154
+ role: "user",
155
+ content: parts.map((p) => typeof p === "string" ? { type: "text", text: p } : p),
156
+ },
157
+ ];
158
+ }
159
+ if (msg.role === "assistant") {
160
+ // Detect block types that cannot survive the OpenAI assistant
161
+ // message shape and surface the failure rather than silently
162
+ // dropping them. Code execution blocks are first-class semantic
163
+ // content; their loss would corrupt cross-provider conversations.
164
+ // RefusalBlocks are this adapter's own output (delta.refusal
165
+ // accumulates into one) but the round-trip back through history
166
+ // is not modeled — a silent drop would erase the refusal text on
167
+ // any continuation request, so the marshaling fails loudly
168
+ // alongside code_execution.
169
+ for (const block of msg.content) {
170
+ if (block.type === "code_execution_request" ||
171
+ block.type === "code_execution_result" ||
172
+ block.type === "refusal") {
173
+ throw new Error(`OpenAI adapter does not handle ${block.type} content blocks.`);
174
+ }
175
+ }
176
+ const textBlocks = msg.content.filter((b) => b.type === "text");
177
+ const safetyBlocks = msg.content.filter((b) => b.type === "safety_rating");
178
+ const thinkingBlocks = msg.content.filter((b) => b.type === "thinking");
179
+ const toolCalls = msg.content.filter((b) => b.type === "tool_call");
180
+ // safety_rating-only assistant turns become a textual content
181
+ // string so the turn is not a hollow `{content: null}` message
182
+ // that confuses multi-turn Chat Completions history.
183
+ const textContent = [
184
+ ...textBlocks.map((b) => b.text),
185
+ ...safetyBlocks.map((b) => formatSafetyRatingText(b)),
186
+ ].join("");
187
+ // Skip empty assistant turns that only carried dropped metadata.
188
+ if (textContent.length === 0 &&
189
+ toolCalls.length === 0 &&
190
+ thinkingBlocks.length === 0) {
191
+ return [];
192
+ }
193
+ const result = { role: "assistant" };
194
+ if (textContent.length > 0) {
195
+ result["content"] = textContent;
196
+ }
197
+ else {
198
+ result["content"] = null;
199
+ }
200
+ // kimi requires reasoning_content on every assistant message once thinking
201
+ // is enabled anywhere in the conversation, even on turns that carried no
202
+ // thinking of their own. A source serving such a backend sets
203
+ // forceAssistantReasoningContent true, which keeps the field always
204
+ // present, empty on a turn with no thinking. The default is false: the
205
+ // field is emitted only on turns that actually have thinking.
206
+ const reasoning = thinkingBlocks.map((b) => b.thinking).join("");
207
+ if (forceAssistantReasoningContent || thinkingBlocks.length > 0) {
208
+ result["reasoning_content"] = reasoning;
209
+ }
210
+ if (toolCalls.length > 0) {
211
+ result["tool_calls"] = toolCalls.map((tc) => ({
212
+ id: tc.id,
213
+ type: "function",
214
+ function: {
215
+ name: encodeToolName(tc.name, OPENAI_TOOL_NAME_LIMIT),
216
+ arguments: JSON.stringify(tc.arguments),
217
+ },
218
+ }));
219
+ }
220
+ return [result];
221
+ }
222
+ return [{ role: msg.role, content: "" }];
223
+ }
224
+ function filenameForDocumentMime(mimeType) {
225
+ if (mimeType === "application/pdf")
226
+ return "document.pdf";
227
+ throw new Error(`OpenAI Chat Completions document input currently supports ` +
228
+ `application/pdf only; received mimeType: ${mimeType}`);
229
+ }
230
+ function toOpenAIContentPart(block) {
231
+ switch (block.type) {
232
+ case "text":
233
+ return block.text;
234
+ case "image": {
235
+ const source = block.source;
236
+ if (source.kind === "base64") {
237
+ return {
238
+ type: "image_url",
239
+ image_url: {
240
+ url: `data:${source.mimeType};base64,${source.data}`,
241
+ },
242
+ };
243
+ }
244
+ if (source.kind === "url") {
245
+ // OpenAI's image_url accepts a public URL verbatim alongside
246
+ // the data-URL form. The MediaSource's mimeType is not
247
+ // propagated on the wire — OpenAI infers content type from
248
+ // the URL response. The internal mimeType requirement still
249
+ // keeps the caller honest about what they have in hand.
250
+ return {
251
+ type: "image_url",
252
+ image_url: {
253
+ url: source.url,
254
+ },
255
+ };
256
+ }
257
+ if (source.kind === "file-reference") {
258
+ // OpenAI's Chat Completions endpoint accepts images only via
259
+ // `image_url: { url }` (data URL or public URL). It does not
260
+ // accept opaque uploaded-file references the way Anthropic's
261
+ // `{ type: "file", file_id }` does. A `file-reference`
262
+ // handle minted by some other provider (an Anthropic file_id,
263
+ // a Gemini fileUri) is meaningless to OpenAI; the adapter
264
+ // would have to round-trip the bytes through base64 to be
265
+ // useful, which is a caller-level choice, not an adapter one.
266
+ // Surface the constraint loudly with the apparent reference
267
+ // so an operator triaging the failure sees what was sent.
268
+ throw new Error(`OpenAI Chat Completions does not accept file-reference image ` +
269
+ `sources; the API only takes base64 data URLs or public URLs ` +
270
+ `via image_url. Received reference: ${source.reference}`);
271
+ }
272
+ source;
273
+ throw new Error(`unreachable: unknown MediaSource kind`);
274
+ }
275
+ case "audio":
276
+ case "video":
277
+ throw new Error(`OpenAI adapter does not yet handle ${block.type} content blocks.`);
278
+ case "document": {
279
+ // Grounded on packages/inference-discovery-openai/sessions/openai/
280
+ // gpt-5.5/document-input/exchanges/0: Chat Completions takes
281
+ // { type: "file", file: { filename, file_data } } with file_data
282
+ // as a data URI. MediaSource has no filename field, so base64
283
+ // inputs synthesize a deterministic name from mimeType.
284
+ const source = block.source;
285
+ if (source.kind === "base64") {
286
+ return {
287
+ type: "file",
288
+ file: {
289
+ filename: filenameForDocumentMime(source.mimeType),
290
+ file_data: `data:${source.mimeType};base64,${source.data}`,
291
+ },
292
+ };
293
+ }
294
+ if (source.kind === "file-reference") {
295
+ // Only meaningful when `reference` is an OpenAI Files API
296
+ // file_id. Handles minted by other providers will 400; that
297
+ // is correct — the adapter does not translate across providers.
298
+ return {
299
+ type: "file",
300
+ file: { file_id: source.reference },
301
+ };
302
+ }
303
+ if (source.kind === "url") {
304
+ throw new Error(`OpenAI Chat Completions does not accept url document sources; ` +
305
+ `the file content type only takes base64 data URIs (file_data) ` +
306
+ `or uploaded file_id handles. Received url: ${source.url}`);
307
+ }
308
+ source;
309
+ throw new Error(`unreachable: unknown MediaSource kind`);
310
+ }
311
+ case "citation":
312
+ // Citation blocks are server-emitted attribution metadata for
313
+ // content the model already produced; they're not part of the
314
+ // active conversation state the next turn needs to make sense
315
+ // of. OpenAI's Chat Completions has no input wire shape for
316
+ // citations either, so re-uploading them on a follow-up turn
317
+ // would be ignored at best. Drop them when serializing history
318
+ // to OpenAI; a downstream consumer that wants to preserve them
319
+ // across provider switches reads the finalized turn's content[]
320
+ // directly. See INFERENCE.md § Cross-Provider Message
321
+ // Transformation for the general policy on history-drop fields.
322
+ return "";
323
+ case "safety_rating":
324
+ // Assistant history rewrites safety_rating via
325
+ // formatSafetyRatingText before this multimodal path. A
326
+ // safety_rating on a user multimodal turn has no input wire
327
+ // shape; return empty rather than throw so mixed user content
328
+ // can still marshal (same silent skip as citation).
329
+ return "";
330
+ case "code_execution_request":
331
+ case "code_execution_result":
332
+ // Code execution blocks are first-class semantic content; silently
333
+ // dropping them would lose the model's tool invocation entirely.
334
+ // OpenAI has no first-class code execution surface today.
335
+ throw new Error(`OpenAI adapter does not handle ${block.type} content blocks.`);
336
+ case "thinking":
337
+ // Thinking blocks are not forwarded to OpenAI endpoints.
338
+ return "";
339
+ case "redacted_thinking":
340
+ // Redacted thinking blocks are opaque by design; the cross-
341
+ // provider mapping is meaningless on OpenAI's surface.
342
+ return "";
343
+ case "tool_call":
344
+ case "tool_result":
345
+ // These are handled separately in toOpenAIMessage.
346
+ return "";
347
+ case "refusal":
348
+ // RefusalBlocks are output-only (delta.refusal accumulates into
349
+ // one). Echoing one back inside a user-role content array has
350
+ // no defined OpenAI wire shape; fail at the marshaling
351
+ // boundary rather than silently emit `null` part bytes that
352
+ // would round-trip as an unrecognized fragment.
353
+ throw new Error("OpenAI adapter does not handle refusal content blocks.");
354
+ }
355
+ }
356
+ // ---------------------------------------------------------------------------
357
+ // Response parsing
358
+ // ---------------------------------------------------------------------------
359
+ const EMPTY_PARTIAL = { text: "" };
360
+ // Fireworks (and likely other OpenAI-compatible deployments) emits
361
+ // `name: null` and `arguments: null` on tool-call delta fragments AFTER
362
+ // the start delta. arktype rejects `null` against `"string"` and would
363
+ // drop the whole chunk silently — taking the argument fragments with
364
+ // it. Accept `string | null` here and treat null the same as the field
365
+ // being absent at the consumer site.
366
+ const OpenAIToolCallDelta = type({
367
+ "index?": "number",
368
+ "id?": "string | null",
369
+ "function?": {
370
+ "name?": "string | null",
371
+ "arguments?": "string | null",
372
+ },
373
+ });
374
+ const OpenAIChunkDelta = type({
375
+ "role?": "string",
376
+ "content?": "string | null",
377
+ "reasoning_content?": "string | null",
378
+ "reasoning?": "string | null",
379
+ // Strict-mode structured-outputs refusal: when the model declines a
380
+ // JSON-schema request on policy grounds, the delta carries the
381
+ // refusal text in this field instead of `content`. Some
382
+ // OpenAI-compatible relays strip it before forwarding; the parser
383
+ // emits refusal events only when the field is present.
384
+ "refusal?": "string | null",
385
+ "tool_calls?": OpenAIToolCallDelta.array(),
386
+ });
387
+ const PromptTokensDetails = type({ "cached_tokens?": "number" }).or("null");
388
+ const CompletionTokensDetails = type({
389
+ "reasoning_tokens?": "number",
390
+ }).or("null");
391
+ const OpenAIChunkUsage = type({
392
+ "prompt_tokens?": "number",
393
+ "completion_tokens?": "number",
394
+ "prompt_tokens_details?": PromptTokensDetails,
395
+ "completion_tokens_details?": CompletionTokensDetails,
396
+ });
397
+ const OpenAIChunk = type({
398
+ "choices?": type({
399
+ "index?": "number",
400
+ delta: OpenAIChunkDelta,
401
+ "finish_reason?": "string | null",
402
+ }).array(),
403
+ "usage?": OpenAIChunkUsage.or("null"),
404
+ });
405
+ function getOrAssignTextIndex(state) {
406
+ if (state.textIndex === null) {
407
+ state.textIndex = state.nextIndex;
408
+ state.nextIndex += 1;
409
+ }
410
+ return state.textIndex;
411
+ }
412
+ function getOrAssignThinkingIndex(state) {
413
+ if (state.thinkingIndex === null) {
414
+ state.thinkingIndex = state.nextIndex;
415
+ state.nextIndex += 1;
416
+ }
417
+ return state.thinkingIndex;
418
+ }
419
+ function getOrAssignRefusalIndex(state) {
420
+ if (state.refusalIndex === null) {
421
+ state.refusalIndex = state.nextIndex;
422
+ state.nextIndex += 1;
423
+ }
424
+ return state.refusalIndex;
425
+ }
426
+ function getOrAssignToolCallIndex(state, toolCallIndex) {
427
+ const existing = state.toolCallBlockIndex.get(toolCallIndex);
428
+ if (existing !== undefined)
429
+ return existing;
430
+ const assigned = state.nextIndex;
431
+ state.nextIndex += 1;
432
+ state.toolCallBlockIndex.set(toolCallIndex, assigned);
433
+ return assigned;
434
+ }
435
+ // Maps OpenAI's wire usage object onto the internal TokenUsage, reading the
436
+ // cached-token and reasoning-token detail sub-objects. Shared by both
437
+ // streaming usage branches (usage on a choices-empty chunk and usage riding a
438
+ // choice-bearing chunk) and the non-streaming parseJSONResponse, whose usage
439
+ // objects carry the same field names.
440
+ function toInferenceUsage(usage) {
441
+ return {
442
+ input: usage.prompt_tokens ?? 0,
443
+ output: usage.completion_tokens ?? 0,
444
+ cacheRead: usage.prompt_tokens_details?.cached_tokens ?? 0,
445
+ cacheWrite: 0,
446
+ thinking: usage.completion_tokens_details?.reasoning_tokens ?? 0,
447
+ };
448
+ }
449
+ function parseResponse(sseData, indexer, source, reasoningFieldNames) {
450
+ // parseSSE strips the `[DONE]` sentinel before yielding payloads, so
451
+ // anything that reaches us here is supposed to be a JSON chunk. A
452
+ // JSON.parse failure or an arktype rejection means the upstream
453
+ // emitted bytes that violate the OpenAI streaming protocol — a
454
+ // protocol mismatch, not a transport flake. Surface it through the
455
+ // harness's stream-error catch via ProtocolMismatchError so the
456
+ // resulting inference.error carries category "protocol_mismatch"
457
+ // and the offending data in error.raw, instead of silently dropping
458
+ // the chunk and leaving the agent to guess why a tool call arrived
459
+ // with empty arguments.
460
+ let parsed;
461
+ try {
462
+ parsed = JSON.parse(sseData);
463
+ }
464
+ catch (cause) {
465
+ const message = cause instanceof Error ? cause.message : String(cause);
466
+ throw new ProtocolMismatchError(`openai parseResponse: malformed JSON in SSE data payload: ${message}`, sseData);
467
+ }
468
+ const chunk = OpenAIChunk(parsed);
469
+ if (chunk instanceof type.errors) {
470
+ throw new ProtocolMismatchError(`openai parseResponse: SSE chunk failed schema validation: ${chunk.summary}`, parsed);
471
+ }
472
+ const seq = 0;
473
+ const { choices } = chunk;
474
+ if (choices === undefined || choices.length === 0) {
475
+ // Check for usage-only events (some providers send a final event with usage).
476
+ const { usage } = chunk;
477
+ if (usage != null) {
478
+ return [
479
+ {
480
+ type: "inference.usage",
481
+ seq,
482
+ data: { usage: toInferenceUsage(usage), source },
483
+ },
484
+ ];
485
+ }
486
+ return [];
487
+ }
488
+ const choice = choices[0];
489
+ if (choice === undefined)
490
+ return [];
491
+ const { delta } = choice;
492
+ const events = [];
493
+ // Providers stream reasoning tokens under different field names:
494
+ // reasoning_content (kimi direct, DeepSeek) or reasoning (kimi via
495
+ // OpenRouter). `reasoningFieldNames` gives the fields to read and their
496
+ // precedence; the first field carrying a non-null value wins. An
497
+ // empty-string value still claims its slot (matching the prior
498
+ // `reasoning_content ?? reasoning` short-circuit) and is filtered by the
499
+ // length gate below.
500
+ //
501
+ // OpenAI's Chat Completions ships reasoning and content as separate
502
+ // logical content blocks without a wire-level block index. The parser
503
+ // assigns indices on first observation in arrival order via the
504
+ // per-request `indexer`: whichever kind streams first lands at 0, the
505
+ // other (if it appears) at 1. This satisfies the harness's per-index
506
+ // routing contract — distinct kinds get distinct indices and the
507
+ // harness's collision detection between block kinds at the same index
508
+ // never fires from a normal OpenAI response.
509
+ let reasoning;
510
+ for (const field of reasoningFieldNames) {
511
+ const value = field === "reasoning_content" ? delta.reasoning_content : delta.reasoning;
512
+ if (value !== undefined && value !== null) {
513
+ reasoning = value;
514
+ break;
515
+ }
516
+ }
517
+ if (typeof reasoning === "string" && reasoning.length > 0) {
518
+ events.push({
519
+ type: "inference.thinking.delta",
520
+ seq,
521
+ data: {
522
+ token: reasoning,
523
+ partial: EMPTY_PARTIAL,
524
+ index: getOrAssignThinkingIndex(indexer),
525
+ },
526
+ });
527
+ }
528
+ const { content } = delta;
529
+ if (typeof content === "string" && content.length > 0) {
530
+ events.push({
531
+ type: "inference.text.delta",
532
+ seq,
533
+ data: {
534
+ token: content,
535
+ partial: EMPTY_PARTIAL,
536
+ index: getOrAssignTextIndex(indexer),
537
+ },
538
+ });
539
+ }
540
+ // Strict-mode structured-outputs refusal. Allocate a content-block
541
+ // index via the same shared counter that text/thinking/tool_call use
542
+ // so a refusal that arrives interleaved with text (e.g. partial
543
+ // content emitted before the refusal kicks in) lands on its own
544
+ // block index rather than colliding with text.
545
+ const { refusal } = delta;
546
+ if (typeof refusal === "string" && refusal.length > 0) {
547
+ events.push({
548
+ type: "inference.refusal.delta",
549
+ seq,
550
+ data: {
551
+ token: refusal,
552
+ partial: EMPTY_PARTIAL,
553
+ index: getOrAssignRefusalIndex(indexer),
554
+ },
555
+ });
556
+ }
557
+ const { tool_calls: toolCallDeltas } = delta;
558
+ if (toolCallDeltas !== undefined) {
559
+ for (const tcDelta of toolCallDeltas) {
560
+ const toolCallSlot = tcDelta.index ?? 0;
561
+ // The harness's per-index map keys on content-block index, not
562
+ // OpenAI's `tool_calls[]` slot. Map this tool call's slot to a
563
+ // content-block index that doesn't collide with text/thinking:
564
+ // first observation of each unique `tcDelta.index` allocates a
565
+ // fresh content-block index from the shared `nextIndex`
566
+ // counter; subsequent deltas for the same slot reuse it.
567
+ const blockIndex = getOrAssignToolCallIndex(indexer, toolCallSlot);
568
+ // Normalize null → undefined: Fireworks emits literal null on every
569
+ // delta after the first; we treat that the same as the field being
570
+ // absent so the start / fragment branches below remain simple.
571
+ const id = tcDelta.id ?? undefined;
572
+ const fn = tcDelta.function;
573
+ const wireName = fn?.name ?? undefined;
574
+ const name = wireName !== undefined ? decodeToolName(wireName) : undefined;
575
+ const argFragment = fn?.arguments ?? undefined;
576
+ // Different providers shape these deltas differently:
577
+ // - OpenAI emits id + name + empty arguments in the first delta,
578
+ // then arguments-only deltas (no id, no name) for the body.
579
+ // - Fireworks (kimi-k2.6) emits id + index on EVERY delta, with
580
+ // name populated only on the first and arguments fragments on
581
+ // subsequent deltas. The non-first deltas carry name: null
582
+ // (normalized to undefined above) rather than omitting the
583
+ // field outright.
584
+ // Treat the two signals independently. A single delta may legitimately
585
+ // carry both a start signal (id + non-null name) and an argument
586
+ // fragment; both must be emitted.
587
+ //
588
+ // `data.callId` is the OpenAI-provided id when present
589
+ // (`tcDelta.id`); when absent on continuation deltas, the
590
+ // adapter synthesizes a per-stream placeholder from
591
+ // `toolCallSlot` so the harness's id-keyed accumulator can
592
+ // merge fragments until the real id resolves at finalize time.
593
+ // `data.index` is the content-block index allocated above —
594
+ // namespaced into the same counter as text/thinking indices so
595
+ // a tool_call arriving before any text doesn't collide with a
596
+ // later text block at the same numeric index.
597
+ if (id !== undefined && name !== undefined) {
598
+ events.push({
599
+ type: "inference.tool_call.start",
600
+ seq,
601
+ data: {
602
+ callId: id,
603
+ name,
604
+ partial: EMPTY_PARTIAL,
605
+ index: blockIndex,
606
+ },
607
+ });
608
+ }
609
+ if (argFragment !== undefined && argFragment.length > 0) {
610
+ // The delta's `callId` is a per-stream placeholder used by the
611
+ // harness to resolve fragments to the real id minted on the
612
+ // start event. Use `String(blockIndex)` rather than
613
+ // `String(toolCallSlot)` so the placeholder matches the key
614
+ // the harness registers in `indexToCallId` on start —
615
+ // otherwise a non-zero, non-contiguous `tcDelta.index`
616
+ // (single tool at slot 3, or parallel tools at slots 0/3)
617
+ // would land its fragments under a key the harness never
618
+ // registered, and the harness's accumulator would silently
619
+ // drop them.
620
+ events.push({
621
+ type: "inference.tool_call.delta",
622
+ seq,
623
+ data: {
624
+ callId: String(blockIndex),
625
+ argumentFragment: argFragment,
626
+ partial: EMPTY_PARTIAL,
627
+ index: blockIndex,
628
+ },
629
+ });
630
+ }
631
+ }
632
+ }
633
+ // finish_reason is checked but we emit nothing — the harness handles cleanup.
634
+ // (Keeping the reference here documents the field is intentionally unused.)
635
+ void choice.finish_reason;
636
+ // Usage at end of stream (stream_options: { include_usage: true }).
637
+ const usageInChunk = chunk.usage;
638
+ if (usageInChunk != null) {
639
+ events.push({
640
+ type: "inference.usage",
641
+ seq,
642
+ data: { usage: toInferenceUsage(usageInChunk), source },
643
+ });
644
+ }
645
+ return events;
646
+ }
647
+ // ---------------------------------------------------------------------------
648
+ // Non-streaming response parsing
649
+ //
650
+ // The non-streaming Chat Completions endpoint returns the whole assistant
651
+ // message in one JSON body. parseJSONResponse re-expresses it as the same
652
+ // InferenceEvent vocabulary parseResponse emits from the stream, so a
653
+ // replayed non-streaming capture feeds the harness accumulator identically to
654
+ // its streaming sibling. See parseResponse for the streaming counterpart.
655
+ // ---------------------------------------------------------------------------
656
+ // A complete non-streaming tool call carries its id, type, and function name
657
+ // and arguments in full — unlike a streaming delta, where these arrive
658
+ // incrementally and are optional per chunk. Require them: a complete body
659
+ // missing them is malformed and should fail loudly at the boundary rather
660
+ // than decode into a tool call with a synthesized id or empty name.
661
+ const NonStreamingToolCall = type({
662
+ "index?": "number",
663
+ id: "string",
664
+ type: "string",
665
+ function: {
666
+ name: "string",
667
+ arguments: "string",
668
+ },
669
+ });
670
+ const NonStreamingMessage = type({
671
+ "role?": "string",
672
+ "content?": "string | null",
673
+ "reasoning_content?": "string | null",
674
+ "reasoning?": "string | null",
675
+ "refusal?": "string | null",
676
+ "tool_calls?": NonStreamingToolCall.array(),
677
+ });
678
+ const NonStreamingCompletion = type({
679
+ object: "'chat.completion'",
680
+ choices: type({
681
+ "index?": "number",
682
+ message: NonStreamingMessage,
683
+ "finish_reason?": "string | null",
684
+ }).array(),
685
+ usage: OpenAIChunkUsage,
686
+ });
687
+ function parseJSONResponse(body, source, reasoningFieldNames) {
688
+ let parsed;
689
+ try {
690
+ parsed = JSON.parse(body);
691
+ }
692
+ catch (cause) {
693
+ const message = cause instanceof Error ? cause.message : String(cause);
694
+ throw new ProtocolMismatchError(`openai parseJSONResponse: malformed JSON response body: ${message}`, body);
695
+ }
696
+ const completion = NonStreamingCompletion(parsed);
697
+ if (completion instanceof type.errors) {
698
+ throw new ProtocolMismatchError(`openai parseJSONResponse: response failed schema validation: ${completion.summary}`, parsed);
699
+ }
700
+ const seq = 0;
701
+ const choice = completion.choices[0];
702
+ if (choice === undefined) {
703
+ // No choices: emit only usage, mirroring a usage-only streaming chunk.
704
+ return [
705
+ {
706
+ type: "inference.usage",
707
+ seq,
708
+ data: { usage: toInferenceUsage(completion.usage), source },
709
+ },
710
+ ];
711
+ }
712
+ const { message } = choice;
713
+ // A fresh indexer per body. Content-block indices are synthesized on first
714
+ // observation, so this must not share the adapter-instance counter the
715
+ // streaming parser advances.
716
+ const indexer = {
717
+ nextIndex: 0,
718
+ textIndex: null,
719
+ thinkingIndex: null,
720
+ refusalIndex: null,
721
+ toolCallBlockIndex: new Map(),
722
+ };
723
+ const events = [];
724
+ // Walk the message fields in the SAME order the streaming parser processes a
725
+ // delta chunk (reasoning -> content -> refusal -> tool_calls) through the
726
+ // same getOrAssign* helpers. For OpenAI this reproduces the streaming
727
+ // arrival-order index assignment: reasoning models flush reasoning before
728
+ // answer text, refusal is exclusive with content, and text/thinking/refusal
729
+ // each collapse to a single cached slot — so a complete message's field
730
+ // order matches the order the stream would have assigned indices. Empty
731
+ // fields must NOT claim an index (every getOrAssign call stays behind a
732
+ // non-empty gate, as on the streaming path), or the decoded turn would carry
733
+ // a phantom block the stream never produced.
734
+ let reasoning;
735
+ for (const field of reasoningFieldNames) {
736
+ const value = field === "reasoning_content"
737
+ ? message.reasoning_content
738
+ : message.reasoning;
739
+ if (value !== undefined && value !== null) {
740
+ reasoning = value;
741
+ break;
742
+ }
743
+ }
744
+ if (typeof reasoning === "string" && reasoning.length > 0) {
745
+ events.push({
746
+ type: "inference.thinking.delta",
747
+ seq,
748
+ data: {
749
+ token: reasoning,
750
+ partial: EMPTY_PARTIAL,
751
+ index: getOrAssignThinkingIndex(indexer),
752
+ },
753
+ });
754
+ }
755
+ const { content } = message;
756
+ if (typeof content === "string" && content.length > 0) {
757
+ events.push({
758
+ type: "inference.text.delta",
759
+ seq,
760
+ data: {
761
+ token: content,
762
+ partial: EMPTY_PARTIAL,
763
+ index: getOrAssignTextIndex(indexer),
764
+ },
765
+ });
766
+ }
767
+ const { refusal } = message;
768
+ if (typeof refusal === "string" && refusal.length > 0) {
769
+ events.push({
770
+ type: "inference.refusal.delta",
771
+ seq,
772
+ data: {
773
+ token: refusal,
774
+ partial: EMPTY_PARTIAL,
775
+ index: getOrAssignRefusalIndex(indexer),
776
+ },
777
+ });
778
+ }
779
+ for (const [position, toolCall] of (message.tool_calls ?? []).entries()) {
780
+ // Genuine OpenAI non-streaming responses omit `index` on tool_calls[]
781
+ // (only the streaming deltas carry it, and the opencode-zen backends
782
+ // include it on the array too). Key the block-index slot on the array
783
+ // position when the wire index is absent, so parallel tool calls get
784
+ // distinct slots instead of all collapsing onto slot 0 and colliding in
785
+ // the harness's per-index accumulator.
786
+ const blockIndex = getOrAssignToolCallIndex(indexer, toolCall.index ?? position);
787
+ // Mirror the streaming convention exactly: the start carries the real id
788
+ // and the block index; the args delta carries String(blockIndex) as its
789
+ // callId placeholder, which the harness resolves via the indexToCallId
790
+ // mapping it registers from the start event's index. Start must precede
791
+ // the delta, or the harness silently drops the fragment.
792
+ events.push({
793
+ type: "inference.tool_call.start",
794
+ seq,
795
+ data: {
796
+ callId: toolCall.id,
797
+ name: decodeToolName(toolCall.function.name),
798
+ partial: EMPTY_PARTIAL,
799
+ index: blockIndex,
800
+ },
801
+ });
802
+ if (toolCall.function.arguments.length > 0) {
803
+ events.push({
804
+ type: "inference.tool_call.delta",
805
+ seq,
806
+ data: {
807
+ callId: String(blockIndex),
808
+ argumentFragment: toolCall.function.arguments,
809
+ partial: EMPTY_PARTIAL,
810
+ index: blockIndex,
811
+ },
812
+ });
813
+ }
814
+ }
815
+ events.push({
816
+ type: "inference.usage",
817
+ seq,
818
+ data: { usage: toInferenceUsage(completion.usage), source },
819
+ });
820
+ return events;
821
+ }
822
+ function extractRetryAfterMs(headers) {
823
+ // OpenAI's non-standard millisecond header takes priority
824
+ const retryMs = headers.get("retry-after-ms");
825
+ if (retryMs !== null) {
826
+ const ms = Number(retryMs);
827
+ if (Number.isFinite(ms) && ms > 0)
828
+ return Math.ceil(ms);
829
+ }
830
+ const raw = headers.get("retry-after");
831
+ if (raw !== null) {
832
+ const seconds = Number(raw);
833
+ if (Number.isFinite(seconds) && seconds > 0) {
834
+ return Math.ceil(seconds * 1000);
835
+ }
836
+ }
837
+ return undefined;
838
+ }
839
+ function extractPacingDelayMs(headers) {
840
+ const remaining = headers.get("x-ratelimit-remaining-requests");
841
+ if (remaining === null)
842
+ return undefined;
843
+ const n = Number(remaining);
844
+ if (!Number.isFinite(n) || n > 0)
845
+ return undefined;
846
+ const reset = headers.get("x-ratelimit-reset-requests");
847
+ if (reset === null)
848
+ return undefined;
849
+ const ms = parseDuration(reset);
850
+ return ms !== undefined && ms > 0 ? ms : undefined;
851
+ }
852
+ function parseDuration(value) {
853
+ let total = 0;
854
+ const pattern = /(\d+(?:\.\d+)?)(ms|s|m|h)/g;
855
+ let match;
856
+ while ((match = pattern.exec(value)) !== null) {
857
+ const num = Number(match[1]);
858
+ switch (match[2]) {
859
+ case "ms":
860
+ total += num;
861
+ break;
862
+ case "s":
863
+ total += num * 1000;
864
+ break;
865
+ case "m":
866
+ total += num * 60_000;
867
+ break;
868
+ case "h":
869
+ total += num * 3_600_000;
870
+ break;
871
+ }
872
+ }
873
+ return total > 0 ? Math.ceil(total) : undefined;
874
+ }
875
+ export function createOpenAIAdapter(source, quirks) {
876
+ const parsedQuirks = OpenAIQuirks(quirks ?? {});
877
+ if (parsedQuirks instanceof type.errors) {
878
+ throw new Error(`openai adapter: invalid quirks: ${parsedQuirks.summary}`);
879
+ }
880
+ const resolvedQuirks = {
881
+ forceAssistantReasoningContent: parsedQuirks.forceAssistantReasoningContent ?? false,
882
+ reasoningFieldNames: parsedQuirks.reasoningFieldNames ?? DEFAULT_REASONING_FIELDS,
883
+ maxTokensField: parsedQuirks.maxTokensField ?? "max_tokens",
884
+ };
885
+ // Per-request indexer state. Adapter instances are created per
886
+ // request (see `adapter.ts`), so each call to `createOpenAIAdapter`
887
+ // gets a fresh counter for assigning block indices to reasoning vs.
888
+ // content streams in arrival order.
889
+ const indexer = {
890
+ nextIndex: 0,
891
+ textIndex: null,
892
+ thinkingIndex: null,
893
+ refusalIndex: null,
894
+ toolCallBlockIndex: new Map(),
895
+ };
896
+ return {
897
+ buildRequest: (messages, model, options) => buildRequest(messages, model, options, resolvedQuirks),
898
+ parseResponse: (sseData) => parseResponse(sseData, indexer, source, resolvedQuirks.reasoningFieldNames),
899
+ parseJSONResponse: (body) => parseJSONResponse(body, source, resolvedQuirks.reasoningFieldNames),
900
+ extractRetryAfterMs,
901
+ extractPacingDelayMs,
902
+ };
903
+ }