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