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