@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.
- package/dist/adapter.d.ts +4 -2
- package/dist/adapter.js +2 -2
- package/dist/assembly.d.ts +16 -1
- package/dist/assembly.js +4 -1
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +14 -5
- package/dist/authz-extension.d.ts +15 -1
- package/dist/authz-extension.js +93 -9
- package/dist/correlation.d.ts +1 -0
- package/dist/correlation.js +8 -1
- package/dist/default-director.d.ts +1 -1
- package/dist/default-director.js +37 -8
- package/dist/gates.d.ts +1 -0
- package/dist/gates.js +24 -1
- package/dist/harness.d.ts +2 -0
- package/dist/harness.js +118 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/providers/anthropic.d.ts +5 -1
- package/dist/providers/anthropic.js +265 -23
- package/dist/providers/google-genai.d.ts +3 -1
- package/dist/providers/google-genai.js +224 -215
- package/dist/providers/index.d.ts +3 -3
- package/dist/providers/index.js +3 -3
- package/dist/providers/openai.d.ts +7 -1
- package/dist/providers/openai.js +353 -59
- package/dist/reactor.d.ts +18 -0
- package/dist/reactor.js +510 -116
- package/dist/transform.d.ts +1 -1
- package/dist/transform.js +19 -4
- package/package.json +5 -4
|
@@ -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
|
package/dist/providers/index.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/providers/openai.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
136
|
-
result["content"] =
|
|
194
|
+
if (textContent.length > 0) {
|
|
195
|
+
result["content"] = textContent;
|
|
137
196
|
}
|
|
138
197
|
else {
|
|
139
198
|
result["content"] = null;
|
|
140
199
|
}
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
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:
|
|
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. */
|