@intx/inference 0.2.2 → 0.4.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.
@@ -1,9 +1,9 @@
1
1
  import type { AdapterRegistry } from "../adapter.js";
2
2
  import { type Dependencies } from "../harness.js";
3
3
  import type { AdapterManifest, ModuleImporter } from "../manifest.js";
4
- export { createAnthropicAdapter } from "./anthropic.js";
5
- export { createGoogleGenAIAdapter } from "./google-genai.js";
6
- export { createOpenAIAdapter } from "./openai.js";
4
+ export { createAnthropicAdapter, AnthropicQuirks, ADAPTIVE_THINKING_MODELS, ADAPTIVE_THINKING_EFFORT, } from "./anthropic.js";
5
+ export { createGoogleGenAIAdapter, GoogleGenAIQuirks } from "./google-genai.js";
6
+ export { createOpenAIAdapter, OpenAIQuirks } from "./openai.js";
7
7
  /**
8
8
  * Builds a registry of the adapters this package ships with, statically linked
9
9
  * and resolved synchronously. The per-call factory invariant (a fresh adapter
@@ -4,9 +4,9 @@ import { loadAdapterFactories } from "../manifest.js";
4
4
  import { createAnthropicAdapter } from "./anthropic.js";
5
5
  import { createGoogleGenAIAdapter } from "./google-genai.js";
6
6
  import { createOpenAIAdapter } from "./openai.js";
7
- export { createAnthropicAdapter } from "./anthropic.js";
8
- export { createGoogleGenAIAdapter } from "./google-genai.js";
9
- export { createOpenAIAdapter } from "./openai.js";
7
+ export { createAnthropicAdapter, AnthropicQuirks, ADAPTIVE_THINKING_MODELS, ADAPTIVE_THINKING_EFFORT, } from "./anthropic.js";
8
+ export { createGoogleGenAIAdapter, GoogleGenAIQuirks } from "./google-genai.js";
9
+ export { createOpenAIAdapter, OpenAIQuirks } from "./openai.js";
10
10
  function builtinFactories() {
11
11
  return {
12
12
  anthropic: createAnthropicAdapter,
@@ -1,3 +1,9 @@
1
1
  import type { LastCycleSource } from "@intx/types/runtime";
2
2
  import type { ProviderAdapter } from "../adapter.js";
3
- export declare function createOpenAIAdapter(source: LastCycleSource): ProviderAdapter;
3
+ export declare const OpenAIQuirks: import("arktype/internal/variants/object.ts").ObjectType<{
4
+ forceAssistantReasoningContent?: boolean;
5
+ reasoningFieldNames?: ("reasoning" | "reasoning_content")[];
6
+ maxTokensField?: "max_tokens" | "max_completion_tokens";
7
+ }, {}>;
8
+ export type OpenAIQuirks = typeof OpenAIQuirks.infer;
9
+ export declare function createOpenAIAdapter(source: LastCycleSource, quirks?: unknown): ProviderAdapter;
@@ -1,4 +1,5 @@
1
1
  import { type } from "arktype";
2
+ import { formatSafetyRatingText } from "@intx/types/runtime";
2
3
  import { BEARER_CREDENTIAL_SENTINEL } from "../auth.js";
3
4
  import { ProtocolMismatchError } from "../errors.js";
4
5
  import { decodeToolName, encodeToolName, } from "../tool-name.js";
@@ -9,14 +10,41 @@ const OPENAI_TOOL_NAME_LIMIT = {
9
10
  provider: "openai",
10
11
  maxLength: 64,
11
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
+ ];
12
40
  // ---------------------------------------------------------------------------
13
41
  // Request building
14
42
  // ---------------------------------------------------------------------------
15
- function buildRequest(messages, model, options) {
16
- const convertedMessages = messages.flatMap(toOpenAIMessage);
43
+ function buildRequest(messages, model, options, quirks) {
44
+ const convertedMessages = messages.flatMap((msg) => toOpenAIMessage(msg, quirks.forceAssistantReasoningContent));
17
45
  const body = {
18
46
  model,
19
- max_tokens: options.maxTokens ?? 4096,
47
+ [quirks.maxTokensField]: options.maxTokens ?? 4096,
20
48
  messages: convertedMessages,
21
49
  stream: true,
22
50
  };
@@ -32,6 +60,15 @@ function buildRequest(messages, model, options) {
32
60
  parameters: t.inputSchema,
33
61
  },
34
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
+ }
35
72
  }
36
73
  if (options.systemPrompt) {
37
74
  // Prepend a system message if provided via options (takes priority over
@@ -77,7 +114,7 @@ function toOpenAIResponseFormat(format) {
77
114
  }
78
115
  }
79
116
  }
80
- function toOpenAIMessage(msg) {
117
+ function toOpenAIMessage(msg, forceAssistantReasoningContent) {
81
118
  if (msg.role === "system") {
82
119
  const text = msg.content
83
120
  .filter((b) => b.type === "text")
@@ -109,7 +146,15 @@ function toOpenAIMessage(msg) {
109
146
  if (parts.every((p) => typeof p === "string")) {
110
147
  return [{ role: "user", content: parts.join("") }];
111
148
  }
112
- return [{ role: "user", content: parts }];
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
+ ];
113
158
  }
114
159
  if (msg.role === "assistant") {
115
160
  // Detect block types that cannot survive the OpenAI assistant
@@ -129,23 +174,39 @@ function toOpenAIMessage(msg) {
129
174
  }
130
175
  }
131
176
  const textBlocks = msg.content.filter((b) => b.type === "text");
177
+ const safetyBlocks = msg.content.filter((b) => b.type === "safety_rating");
132
178
  const thinkingBlocks = msg.content.filter((b) => b.type === "thinking");
133
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
+ }
134
193
  const result = { role: "assistant" };
135
- if (textBlocks.length > 0) {
136
- result["content"] = textBlocks.map((b) => b.text).join("");
194
+ if (textContent.length > 0) {
195
+ result["content"] = textContent;
137
196
  }
138
197
  else {
139
198
  result["content"] = null;
140
199
  }
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
- : "";
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
+ }
149
210
  if (toolCalls.length > 0) {
150
211
  result["tool_calls"] = toolCalls.map((tc) => ({
151
212
  id: tc.id,
@@ -160,6 +221,12 @@ function toOpenAIMessage(msg) {
160
221
  }
161
222
  return [{ role: msg.role, content: "" }];
162
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
+ }
163
230
  function toOpenAIContentPart(block) {
164
231
  switch (block.type) {
165
232
  case "text":
@@ -208,18 +275,39 @@ function toOpenAIContentPart(block) {
208
275
  case "audio":
209
276
  case "video":
210
277
  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.");
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
+ }
223
311
  case "citation":
224
312
  // Citation blocks are server-emitted attribution metadata for
225
313
  // content the model already produced; they're not part of the
@@ -232,6 +320,13 @@ function toOpenAIContentPart(block) {
232
320
  // directly. See INFERENCE.md § Cross-Provider Message
233
321
  // Transformation for the general policy on history-drop fields.
234
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 "";
235
330
  case "code_execution_request":
236
331
  case "code_execution_result":
237
332
  // Code execution blocks are first-class semantic content; silently
@@ -337,7 +432,21 @@ function getOrAssignToolCallIndex(state, toolCallIndex) {
337
432
  state.toolCallBlockIndex.set(toolCallIndex, assigned);
338
433
  return assigned;
339
434
  }
340
- function parseResponse(sseData, indexer, source) {
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) {
341
450
  // parseSSE strips the `[DONE]` sentinel before yielding payloads, so
342
451
  // anything that reaches us here is supposed to be a JSON chunk. A
343
452
  // JSON.parse failure or an arktype rejection means the upstream
@@ -366,15 +475,12 @@ function parseResponse(sseData, indexer, source) {
366
475
  // Check for usage-only events (some providers send a final event with usage).
367
476
  const { usage } = chunk;
368
477
  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
478
  return [
377
- { type: "inference.usage", seq, data: { usage: tokenUsage, source } },
479
+ {
480
+ type: "inference.usage",
481
+ seq,
482
+ data: { usage: toInferenceUsage(usage), source },
483
+ },
378
484
  ];
379
485
  }
380
486
  return [];
@@ -385,19 +491,29 @@ function parseResponse(sseData, indexer, source) {
385
491
  const { delta } = choice;
386
492
  const events = [];
387
493
  // 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
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.
391
500
  //
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;
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
+ }
401
517
  if (typeof reasoning === "string" && reasoning.length > 0) {
402
518
  events.push({
403
519
  type: "inference.thinking.delta",
@@ -520,19 +636,187 @@ function parseResponse(sseData, indexer, source) {
520
636
  // Usage at end of stream (stream_options: { include_usage: true }).
521
637
  const usageInChunk = chunk.usage;
522
638
  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
639
  events.push({
531
640
  type: "inference.usage",
532
641
  seq,
533
- data: { usage: tokenUsage, source },
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
+ },
534
753
  });
535
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
+ });
536
820
  return events;
537
821
  }
538
822
  function extractRetryAfterMs(headers) {
@@ -588,7 +872,16 @@ function parseDuration(value) {
588
872
  }
589
873
  return total > 0 ? Math.ceil(total) : undefined;
590
874
  }
591
- export function createOpenAIAdapter(source) {
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
+ };
592
885
  // Per-request indexer state. Adapter instances are created per
593
886
  // request (see `adapter.ts`), so each call to `createOpenAIAdapter`
594
887
  // gets a fresh counter for assigning block indices to reasoning vs.
@@ -601,8 +894,9 @@ export function createOpenAIAdapter(source) {
601
894
  toolCallBlockIndex: new Map(),
602
895
  };
603
896
  return {
604
- buildRequest,
605
- parseResponse: (sseData) => parseResponse(sseData, indexer, source),
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),
606
900
  extractRetryAfterMs,
607
901
  extractPacingDelayMs,
608
902
  };
package/dist/reactor.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { InboundMessage, InferenceEvent, InferenceSource, ReactorDirector, ContextStore, ToolRunner, AbortReason, BeforeToolExtension, ToolResultTransform, ContextTransform, Compactor } from "@intx/types/runtime";
2
+ import type { CredentialMaterialResolver } from "@intx/types";
2
3
  import type { Dependencies, InferenceHarnessOptions } from "./harness.js";
3
4
  import type { CorrelationValidator } from "./correlation.js";
4
5
  export type ReactorEmittedEvent = InferenceEvent | {
@@ -20,6 +21,13 @@ export type ReactorConfig = {
20
21
  failOverToNextSource?: () => boolean;
21
22
  /** Reset `source` to the most-preferred source, in place. */
22
23
  resetToPreferredSource?: () => void;
24
+ /**
25
+ * Resolves the active source's credential secret by `credentialId` from the
26
+ * run's credential cell at send time. Read live per attempt, so a failover to
27
+ * a source with a different `credentialId` resolves that source's credential.
28
+ * Optional: the harness installs a fail-closed default when it is omitted.
29
+ */
30
+ readMaterial?: CredentialMaterialResolver;
23
31
  toolRunner: ToolRunner;
24
32
  contextStore: ContextStore;
25
33
  correlationValidator?: CorrelationValidator;
@@ -34,6 +42,16 @@ export type ReactorConfig = {
34
42
  onShutdown?: () => Promise<void>;
35
43
  gateTimeout?: number;
36
44
  shutdownTimeoutMs?: number;
45
+ /**
46
+ * Number of consecutive identical tool-call turns that trips doom-loop
47
+ * detection. A turn's identity is its batch of executed tool calls; a
48
+ * runaway model repeating the same call burns inference cost with no
49
+ * progress. On the Nth consecutive identical turn the reactor emits a fatal
50
+ * `reactor.error` and shuts the run down. Must be a positive integer.
51
+ * Pass `false` to disable doom-loop detection entirely. Defaults to
52
+ * `DEFAULT_DOOM_LOOP_THRESHOLD`.
53
+ */
54
+ doomLoopThreshold?: number | false;
37
55
  };
38
56
  export type Reactor = {
39
57
  /** Begin processing. Emits reactor.start. Must be called exactly once. */