@dbx-tools/shared-model 0.9.5 → 0.9.6
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/README.md +26 -132
- package/index.ts +3 -8
- package/lib/index.d.ts +3 -8
- package/lib/index.js +3 -6
- package/lib/src/display.js +4 -4
- package/lib/src/generated/_contracts.d.ts +142 -0
- package/lib/src/generated/_contracts.js +17 -0
- package/lib/src/generated/_schemas.d.ts +67 -0
- package/lib/src/generated/_schemas.js +130 -0
- package/lib/src/model.d.ts +110 -129
- package/lib/src/model.js +26 -150
- package/lib/src/openai-chat.d.ts +2 -2
- package/lib/src/openai-chat.js +3 -3
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/display.ts +3 -3
- package/src/generated/_contracts.ts +145 -0
- package/src/generated/_schemas.ts +136 -0
- package/src/model.ts +58 -183
- package/src/openai-chat.ts +2 -2
- package/lib/src/classify.d.ts +0 -122
- package/lib/src/classify.js +0 -305
- package/lib/src/openai-responses.d.ts +0 -210
- package/lib/src/openai-responses.js +0 -754
- package/src/classify.ts +0 -345
- package/src/openai-responses.ts +0 -841
package/src/openai-responses.ts
DELETED
|
@@ -1,841 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenAI Responses API <-> Chat Completions translation.
|
|
3
|
-
*
|
|
4
|
-
* Some clients speak only the Responses API (`POST /v1/responses`) - the Codex
|
|
5
|
-
* CLI is the motivating one. Databricks now also exposes native Responses
|
|
6
|
-
* surfaces (`/serving-endpoints/responses` and `/serving-endpoints/open-responses`),
|
|
7
|
-
* while most endpoints still speak Chat Completions at `/invocations`. This
|
|
8
|
-
* module bridges the two, in both directions:
|
|
9
|
-
*
|
|
10
|
-
* - {@link responsesToChat} lowers a Responses request body to a chat
|
|
11
|
-
* completions body (instructions -> system message, typed `input` items ->
|
|
12
|
-
* `messages`, `tools`/`tool_choice` carried through, function-call outputs
|
|
13
|
-
* folded back into the transcript).
|
|
14
|
-
* - {@link chatToResponsesRequest} raises a chat-completions request into a
|
|
15
|
-
* Responses request (the inverse of {@link responsesToChat}), for routing a
|
|
16
|
-
* chat client at a Responses-only model like Codex.
|
|
17
|
-
* - {@link chatToResponse} lifts a non-streaming chat completion back into a
|
|
18
|
-
* Responses `response` object.
|
|
19
|
-
* - {@link responseToChatCompletion} lifts a native Responses `response`
|
|
20
|
-
* back into a chat-completions body (the inverse of {@link chatToResponse}).
|
|
21
|
-
* - {@link createResponsesStreamTranslator} lifts a streaming chat completion
|
|
22
|
-
* (an OpenAI SSE `chat.completion.chunk` stream) into the Responses SSE
|
|
23
|
-
* event stream those clients consume (`response.created`,
|
|
24
|
-
* `response.output_text.delta`, function-call argument deltas,
|
|
25
|
-
* `response.completed`).
|
|
26
|
-
* - {@link readResponsesOutput} reads the other direction: pull the answer
|
|
27
|
-
* text and citations out of a `response` object returned by an endpoint
|
|
28
|
-
* that speaks Responses natively (the Databricks native web-search tool).
|
|
29
|
-
* - {@link sanitizeResponsesTools} keeps only `function` tools on a Responses
|
|
30
|
-
* request body (for `/open-responses` / Anthropic, which reject Codex
|
|
31
|
-
* built-ins like `web_search`).
|
|
32
|
-
* - {@link sanitizeOpenResponsesInput} rewrites `output_*` content part types
|
|
33
|
-
* in `input` to `input_*`, and drops Claude thinking / reasoning blocks
|
|
34
|
-
* that Open Responses rejects on replay (`redacted_thinking`, …).
|
|
35
|
-
* - {@link repairTrailingAssistantInput} drops a trailing assistant turn that
|
|
36
|
-
* Anthropic reads as a prefill request, the Responses-side counterpart to
|
|
37
|
-
* `@dbx-tools/appkit-mastra`'s `repairAssistantPrefill`.
|
|
38
|
-
*
|
|
39
|
-
* Only the surface real clients exercise is translated; unknown fields are
|
|
40
|
-
* ignored rather than rejected, so a newer client degrades instead of breaking.
|
|
41
|
-
*
|
|
42
|
-
* Browser-safe: pure functions over plain JSON, no transport and no Node
|
|
43
|
-
* built-ins, so the same translation runs in a proxy, a server route, or a test.
|
|
44
|
-
*
|
|
45
|
-
* @module
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
import { string } from "@dbx-tools/shared-core";
|
|
49
|
-
|
|
50
|
-
import {
|
|
51
|
-
type ChatMessage,
|
|
52
|
-
type ChatToolCall,
|
|
53
|
-
chatContentParts,
|
|
54
|
-
chatContentToText,
|
|
55
|
-
} from "./openai-chat.ts";
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Lower a Responses request body to a chat-completions body. Returns the chat
|
|
59
|
-
* body plus whether the caller asked for streaming (the server needs it to pick
|
|
60
|
-
* the upstream `accept` header and the translation path).
|
|
61
|
-
*/
|
|
62
|
-
export function responsesToChat(body: Record<string, unknown>): {
|
|
63
|
-
chat: Record<string, unknown>;
|
|
64
|
-
stream: boolean;
|
|
65
|
-
} {
|
|
66
|
-
const messages: ChatMessage[] = [];
|
|
67
|
-
|
|
68
|
-
// `instructions` becomes the leading system message.
|
|
69
|
-
if (typeof body.instructions === "string" && body.instructions.length > 0) {
|
|
70
|
-
messages.push({ role: "system", content: body.instructions });
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// `input` is an array of typed items: messages, function_call, and
|
|
74
|
-
// function_call_output. Fold each into the chat transcript.
|
|
75
|
-
const input = Array.isArray(body.input) ? body.input : [];
|
|
76
|
-
for (const raw of input) {
|
|
77
|
-
if (!raw || typeof raw !== "object") continue;
|
|
78
|
-
const item = raw as Record<string, unknown>;
|
|
79
|
-
const type = item.type;
|
|
80
|
-
|
|
81
|
-
if (type === "function_call") {
|
|
82
|
-
// A prior tool call the model made; re-attach it to an assistant turn.
|
|
83
|
-
messages.push({
|
|
84
|
-
role: "assistant",
|
|
85
|
-
content: null,
|
|
86
|
-
tool_calls: [toChatToolCall(item)],
|
|
87
|
-
});
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (type === "function_call_output") {
|
|
92
|
-
// The tool's result, keyed back to the call it answers.
|
|
93
|
-
messages.push({
|
|
94
|
-
role: "tool",
|
|
95
|
-
tool_call_id: String(item.call_id ?? item.id ?? ""),
|
|
96
|
-
content: typeof item.output === "string" ? item.output : chatContentToText(item.output),
|
|
97
|
-
});
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Default: a message item with a role and typed content.
|
|
102
|
-
const role = typeof item.role === "string" ? item.role : "user";
|
|
103
|
-
// Chat has no "developer" role; treat it as system (its intent here).
|
|
104
|
-
const chatRole = role === "developer" ? "system" : role;
|
|
105
|
-
messages.push({ role: chatRole, content: chatContentToText(item.content) });
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const chat: Record<string, unknown> = {
|
|
109
|
-
model: body.model,
|
|
110
|
-
messages,
|
|
111
|
-
stream: body.stream === true,
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
// Carry through function-calling config when present. Responses function
|
|
115
|
-
// tools are FLAT (`{type:"function", name, description, parameters}`); chat
|
|
116
|
-
// wants them NESTED (`{type:"function", function:{name, …}}`). Clients also
|
|
117
|
-
// send built-in tool types (local_shell, web_search, …) that Databricks chat
|
|
118
|
-
// rejects ("Missing 'function' in the tool specification"), so we translate
|
|
119
|
-
// only function tools and drop the rest.
|
|
120
|
-
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
121
|
-
const chatTools = body.tools
|
|
122
|
-
.map(toChatTool)
|
|
123
|
-
.filter((t): t is Record<string, unknown> => t !== undefined);
|
|
124
|
-
if (chatTools.length > 0) {
|
|
125
|
-
chat.tools = chatTools;
|
|
126
|
-
if (body.tool_choice !== undefined) chat.tool_choice = body.tool_choice;
|
|
127
|
-
// NOTE: do NOT forward `parallel_tool_calls`. Clients send it, but
|
|
128
|
-
// Databricks Model Serving's chat API rejects unknown fields
|
|
129
|
-
// ("parallel_tool_calls: Extra inputs are not permitted"), which would
|
|
130
|
-
// fail the whole turn. Drop it.
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return { chat, stream: body.stream === true };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Raise a chat-completions request into a Responses request body. Inverse of
|
|
139
|
-
* {@link responsesToChat}: used when a chat client hits a Responses-only
|
|
140
|
-
* Databricks model (e.g. Codex) so the proxy can POST to
|
|
141
|
-
* `/serving-endpoints/responses` without the client knowing.
|
|
142
|
-
*/
|
|
143
|
-
export function chatToResponsesRequest(body: Record<string, unknown>): {
|
|
144
|
-
responses: Record<string, unknown>;
|
|
145
|
-
stream: boolean;
|
|
146
|
-
} {
|
|
147
|
-
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
148
|
-
const input: unknown[] = [];
|
|
149
|
-
let instructions: string | undefined;
|
|
150
|
-
|
|
151
|
-
for (const raw of messages) {
|
|
152
|
-
if (!raw || typeof raw !== "object") continue;
|
|
153
|
-
const message = raw as Record<string, unknown>;
|
|
154
|
-
const role = typeof message.role === "string" ? message.role : "user";
|
|
155
|
-
|
|
156
|
-
// Leading system / developer messages become top-level `instructions`.
|
|
157
|
-
if (
|
|
158
|
-
(role === "system" || role === "developer") &&
|
|
159
|
-
instructions === undefined &&
|
|
160
|
-
input.length === 0
|
|
161
|
-
) {
|
|
162
|
-
const text = chatContentToText(message.content);
|
|
163
|
-
if (text) instructions = text;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (role === "tool") {
|
|
168
|
-
input.push({
|
|
169
|
-
type: "function_call_output",
|
|
170
|
-
call_id: String(message.tool_call_id ?? ""),
|
|
171
|
-
output: chatContentToText(message.content),
|
|
172
|
-
});
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
role === "assistant" &&
|
|
178
|
-
Array.isArray(message.tool_calls) &&
|
|
179
|
-
message.tool_calls.length > 0
|
|
180
|
-
) {
|
|
181
|
-
for (const rawCall of message.tool_calls) {
|
|
182
|
-
if (!rawCall || typeof rawCall !== "object") continue;
|
|
183
|
-
input.push(toFunctionCallItem(rawCall as Record<string, unknown>));
|
|
184
|
-
}
|
|
185
|
-
const text = chatContentToText(message.content);
|
|
186
|
-
if (text) {
|
|
187
|
-
input.push({ type: "message", role: "assistant", content: text });
|
|
188
|
-
}
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
input.push({
|
|
193
|
-
type: "message",
|
|
194
|
-
role,
|
|
195
|
-
content: chatContentToText(message.content),
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const responses: Record<string, unknown> = {
|
|
200
|
-
model: body.model,
|
|
201
|
-
input,
|
|
202
|
-
stream: body.stream === true,
|
|
203
|
-
};
|
|
204
|
-
if (instructions) responses.instructions = instructions;
|
|
205
|
-
|
|
206
|
-
// Chat tools are nested (`function: { name, … }`); Responses wants them flat.
|
|
207
|
-
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
208
|
-
const tools = body.tools
|
|
209
|
-
.map(toResponsesTool)
|
|
210
|
-
.filter((t): t is Record<string, unknown> => t !== undefined);
|
|
211
|
-
if (tools.length > 0) {
|
|
212
|
-
responses.tools = tools;
|
|
213
|
-
if (body.tool_choice !== undefined) responses.tool_choice = body.tool_choice;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Prefer Responses' max_output_tokens naming; fall back from chat max_tokens.
|
|
218
|
-
if (typeof body.max_output_tokens === "number") {
|
|
219
|
-
responses.max_output_tokens = body.max_output_tokens;
|
|
220
|
-
} else if (typeof body.max_tokens === "number") {
|
|
221
|
-
responses.max_output_tokens = body.max_tokens;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return { responses, stream: body.stream === true };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Lift a non-streaming chat completion JSON into a Responses `response` object. */
|
|
228
|
-
export function chatToResponse(chat: Record<string, unknown>, model: string): unknown {
|
|
229
|
-
const choices = Array.isArray(chat.choices) ? chat.choices : [];
|
|
230
|
-
const first = (choices[0] ?? {}) as Record<string, unknown>;
|
|
231
|
-
const message = (first.message ?? {}) as Record<string, unknown>;
|
|
232
|
-
const output: unknown[] = [];
|
|
233
|
-
|
|
234
|
-
const text = typeof message.content === "string" ? message.content : "";
|
|
235
|
-
if (text) {
|
|
236
|
-
output.push({
|
|
237
|
-
type: "message",
|
|
238
|
-
role: "assistant",
|
|
239
|
-
content: [{ type: "output_text", text }],
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Surface any tool calls as Responses `function_call` output items.
|
|
244
|
-
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
245
|
-
for (const raw of toolCalls) {
|
|
246
|
-
output.push(toFunctionCallItem(raw as Record<string, unknown>));
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const usage = (chat.usage ?? {}) as Record<string, unknown>;
|
|
250
|
-
return {
|
|
251
|
-
id: String(chat.id ?? "resp"),
|
|
252
|
-
object: "response",
|
|
253
|
-
created_at: typeof chat.created === "number" ? chat.created : 0,
|
|
254
|
-
model,
|
|
255
|
-
status: "completed",
|
|
256
|
-
output,
|
|
257
|
-
usage: {
|
|
258
|
-
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
259
|
-
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
260
|
-
total_tokens: Number(usage.total_tokens ?? 0),
|
|
261
|
-
},
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Lift a native Responses `response` object into a chat-completions body.
|
|
267
|
-
* Inverse of {@link chatToResponse}: used after a Responses-only upstream call
|
|
268
|
-
* when the client spoke Chat Completions.
|
|
269
|
-
*/
|
|
270
|
-
export function responseToChatCompletion(
|
|
271
|
-
response: Record<string, unknown>,
|
|
272
|
-
model: string,
|
|
273
|
-
): Record<string, unknown> {
|
|
274
|
-
const { text } = readResponsesOutput(response);
|
|
275
|
-
const toolCalls: unknown[] = [];
|
|
276
|
-
const output = Array.isArray(response.output) ? response.output : [];
|
|
277
|
-
for (const raw of output) {
|
|
278
|
-
if (!raw || typeof raw !== "object") continue;
|
|
279
|
-
const item = raw as Record<string, unknown>;
|
|
280
|
-
if (item.type !== "function_call") continue;
|
|
281
|
-
toolCalls.push(toChatToolCall(item));
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const message: Record<string, unknown> = {
|
|
285
|
-
role: "assistant",
|
|
286
|
-
content: text || null,
|
|
287
|
-
};
|
|
288
|
-
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
289
|
-
|
|
290
|
-
const usage = (response.usage ?? {}) as Record<string, unknown>;
|
|
291
|
-
return {
|
|
292
|
-
id: String(response.id ?? "chatcmpl"),
|
|
293
|
-
object: "chat.completion",
|
|
294
|
-
created: typeof response.created_at === "number" ? response.created_at : 0,
|
|
295
|
-
model,
|
|
296
|
-
choices: [
|
|
297
|
-
{
|
|
298
|
-
index: 0,
|
|
299
|
-
message,
|
|
300
|
-
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop",
|
|
301
|
-
},
|
|
302
|
-
],
|
|
303
|
-
usage: {
|
|
304
|
-
prompt_tokens: Number(usage.input_tokens ?? 0),
|
|
305
|
-
completion_tokens: Number(usage.output_tokens ?? 0),
|
|
306
|
-
total_tokens: Number(usage.total_tokens ?? 0),
|
|
307
|
-
},
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* A tool call's `arguments` JSON. Both wires carry it as an unparsed string;
|
|
313
|
-
* anything else (absent, an already-parsed object) becomes an empty literal so
|
|
314
|
-
* the receiving side always has valid JSON to parse.
|
|
315
|
-
*/
|
|
316
|
-
function callArguments(value: unknown): string {
|
|
317
|
-
return typeof value === "string" ? value : "{}";
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* The Responses `function_call` item for a tool call in either spelling: a chat
|
|
322
|
-
* `tool_calls[]` entry (`{id, function:{name, arguments}}`) or an item that is
|
|
323
|
-
* already flat.
|
|
324
|
-
*/
|
|
325
|
-
function toFunctionCallItem(call: Record<string, unknown>): Record<string, unknown> {
|
|
326
|
-
const fn = (call.function ?? call) as Record<string, unknown>;
|
|
327
|
-
return {
|
|
328
|
-
type: "function_call",
|
|
329
|
-
call_id: String(call.call_id ?? call.id ?? ""),
|
|
330
|
-
name: String(fn.name ?? ""),
|
|
331
|
-
arguments: callArguments(fn.arguments),
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** The chat `tool_calls[]` entry for a Responses `function_call` item. */
|
|
336
|
-
function toChatToolCall(item: Record<string, unknown>): ChatToolCall {
|
|
337
|
-
return {
|
|
338
|
-
id: String(item.call_id ?? item.id ?? ""),
|
|
339
|
-
type: "function",
|
|
340
|
-
function: {
|
|
341
|
-
name: String(item.name ?? ""),
|
|
342
|
-
arguments: callArguments(item.arguments),
|
|
343
|
-
},
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* The chat-nested spelling (`{type:"function", function:{name, …}}`) of a tool
|
|
349
|
-
* spec, or `undefined` for a built-in Responses tool (`web_search`,
|
|
350
|
-
* `local_shell`, …) that chat cannot express and Databricks rejects.
|
|
351
|
-
*/
|
|
352
|
-
function toChatTool(value: unknown): Record<string, unknown> | undefined {
|
|
353
|
-
if (!value || typeof value !== "object") return undefined;
|
|
354
|
-
const tool = value as Record<string, unknown>;
|
|
355
|
-
if (tool.type !== "function") return undefined;
|
|
356
|
-
if (tool.function && typeof tool.function === "object") return tool; // already nested
|
|
357
|
-
if (typeof tool.name !== "string") return undefined;
|
|
358
|
-
return {
|
|
359
|
-
type: "function",
|
|
360
|
-
function: {
|
|
361
|
-
name: tool.name,
|
|
362
|
-
...(tool.description ? { description: tool.description } : {}),
|
|
363
|
-
...(tool.parameters ? { parameters: tool.parameters } : {}),
|
|
364
|
-
},
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* The flat Responses spelling (`{type:"function", name, …}`) of a tool spec.
|
|
370
|
-
* Inverse of {@link toChatTool}; non-function tools are dropped the same way.
|
|
371
|
-
*/
|
|
372
|
-
function toResponsesTool(value: unknown): Record<string, unknown> | undefined {
|
|
373
|
-
if (!value || typeof value !== "object") return undefined;
|
|
374
|
-
const tool = value as Record<string, unknown>;
|
|
375
|
-
if (tool.type !== "function") return undefined;
|
|
376
|
-
if (tool.function && typeof tool.function === "object") {
|
|
377
|
-
const fn = tool.function as Record<string, unknown>;
|
|
378
|
-
return {
|
|
379
|
-
type: "function",
|
|
380
|
-
name: fn.name,
|
|
381
|
-
...(fn.description ? { description: fn.description } : {}),
|
|
382
|
-
...(fn.parameters ? { parameters: fn.parameters } : {}),
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
return typeof tool.name === "string" ? tool : undefined; // already flat
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** One Server-Sent Event, ready to write to the client. */
|
|
389
|
-
function sse(event: string, data: unknown): string {
|
|
390
|
-
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Incremental chat-chunk -> Responses-SSE translator. `feed` and `finish` both
|
|
395
|
-
* return the SSE bytes to forward, or `""` when a chunk produced no event.
|
|
396
|
-
*/
|
|
397
|
-
export interface ResponsesStreamTranslator {
|
|
398
|
-
/** Translate one upstream `chat.completion.chunk` object. */
|
|
399
|
-
feed(chunk: Record<string, unknown>): string;
|
|
400
|
-
/** Close any open items and emit `response.completed`. Call once, at end of stream. */
|
|
401
|
-
finish(): string;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Translate an upstream chat-completions SSE stream into the Responses SSE
|
|
406
|
-
* event stream a Responses client consumes.
|
|
407
|
-
*
|
|
408
|
-
* Upstream chunks are `chat.completion.chunk` objects whose `choices[0].delta`
|
|
409
|
-
* carries incremental `content` (assistant text) and/or `tool_calls` (function
|
|
410
|
-
* calls, streamed by `index` with partial `arguments`). We emit the Responses
|
|
411
|
-
* lifecycle around them:
|
|
412
|
-
*
|
|
413
|
-
* response.created
|
|
414
|
-
* -> per text run: output_item.added → output_text.delta* → output_item.done
|
|
415
|
-
* -> per tool call: output_item.added → function_call_arguments.delta*
|
|
416
|
-
* → function_call_arguments.done → output_item.done
|
|
417
|
-
* response.completed (with the assembled final `response` object)
|
|
418
|
-
*
|
|
419
|
-
* The translator is intentionally tolerant: malformed/keepalive lines yield
|
|
420
|
-
* nothing.
|
|
421
|
-
*/
|
|
422
|
-
export function createResponsesStreamTranslator(
|
|
423
|
-
model: string,
|
|
424
|
-
responseId: string,
|
|
425
|
-
): ResponsesStreamTranslator {
|
|
426
|
-
let started = false;
|
|
427
|
-
let outputIndex = 0;
|
|
428
|
-
|
|
429
|
-
// Text run state: whether a message item is currently open, and its buffer.
|
|
430
|
-
let textOpen = false;
|
|
431
|
-
let textBuffer = "";
|
|
432
|
-
let textItemId = "";
|
|
433
|
-
|
|
434
|
-
// Tool-call state, keyed by the upstream `tool_calls[].index`.
|
|
435
|
-
interface ToolState {
|
|
436
|
-
outputIndex: number;
|
|
437
|
-
callId: string;
|
|
438
|
-
name: string;
|
|
439
|
-
args: string;
|
|
440
|
-
}
|
|
441
|
-
const tools = new Map<number, ToolState>();
|
|
442
|
-
|
|
443
|
-
const created = () =>
|
|
444
|
-
sse("response.created", {
|
|
445
|
-
type: "response.created",
|
|
446
|
-
response: { id: responseId, object: "response", status: "in_progress", model, output: [] },
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
function openText(): string {
|
|
450
|
-
textOpen = true;
|
|
451
|
-
textItemId = `${responseId}-msg-${outputIndex}`;
|
|
452
|
-
const ev = sse("response.output_item.added", {
|
|
453
|
-
type: "response.output_item.added",
|
|
454
|
-
output_index: outputIndex,
|
|
455
|
-
item: { id: textItemId, type: "message", role: "assistant", content: [] },
|
|
456
|
-
});
|
|
457
|
-
return ev;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function closeText(): string {
|
|
461
|
-
if (!textOpen) return "";
|
|
462
|
-
const item = {
|
|
463
|
-
id: textItemId,
|
|
464
|
-
type: "message",
|
|
465
|
-
role: "assistant",
|
|
466
|
-
content: [{ type: "output_text", text: textBuffer }],
|
|
467
|
-
};
|
|
468
|
-
const ev =
|
|
469
|
-
sse("response.output_text.done", {
|
|
470
|
-
type: "response.output_text.done",
|
|
471
|
-
item_id: textItemId,
|
|
472
|
-
output_index: outputIndex,
|
|
473
|
-
content_index: 0,
|
|
474
|
-
text: textBuffer,
|
|
475
|
-
}) +
|
|
476
|
-
sse("response.output_item.done", {
|
|
477
|
-
type: "response.output_item.done",
|
|
478
|
-
output_index: outputIndex,
|
|
479
|
-
item,
|
|
480
|
-
});
|
|
481
|
-
textOpen = false;
|
|
482
|
-
textBuffer = "";
|
|
483
|
-
outputIndex += 1;
|
|
484
|
-
return ev;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function feed(chunk: Record<string, unknown>): string {
|
|
488
|
-
let out = "";
|
|
489
|
-
if (!started) {
|
|
490
|
-
started = true;
|
|
491
|
-
out += created();
|
|
492
|
-
}
|
|
493
|
-
const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
|
|
494
|
-
const choice = (choices[0] ?? {}) as Record<string, unknown>;
|
|
495
|
-
const delta = (choice.delta ?? {}) as Record<string, unknown>;
|
|
496
|
-
|
|
497
|
-
// Assistant text deltas. Compatibility layers may emit one content part,
|
|
498
|
-
// a parts array, or the OpenAI string form.
|
|
499
|
-
const deltaText = chatContentToText(delta.content, { types: ["text", "output_text"] });
|
|
500
|
-
if (deltaText.length > 0) {
|
|
501
|
-
if (!textOpen) out += openText();
|
|
502
|
-
textBuffer += deltaText;
|
|
503
|
-
out += sse("response.output_text.delta", {
|
|
504
|
-
type: "response.output_text.delta",
|
|
505
|
-
item_id: textItemId,
|
|
506
|
-
output_index: outputIndex,
|
|
507
|
-
content_index: 0,
|
|
508
|
-
delta: deltaText,
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Tool-call deltas: each is keyed by `index`; `arguments` arrives in pieces.
|
|
513
|
-
const toolDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
514
|
-
for (const rawTc of toolDeltas) {
|
|
515
|
-
const tc = rawTc as Record<string, unknown>;
|
|
516
|
-
const idx = Number(tc.index ?? 0);
|
|
517
|
-
const fn = (tc.function ?? {}) as Record<string, unknown>;
|
|
518
|
-
let state = tools.get(idx);
|
|
519
|
-
if (!state) {
|
|
520
|
-
// First fragment for this call: a text run (if open) must close first so
|
|
521
|
-
// output ordering stays monotonic, then we open a function_call item.
|
|
522
|
-
out += closeText();
|
|
523
|
-
state = {
|
|
524
|
-
outputIndex,
|
|
525
|
-
callId: String(tc.id ?? `${responseId}-call-${idx}`),
|
|
526
|
-
name: String(fn.name ?? ""),
|
|
527
|
-
args: "",
|
|
528
|
-
};
|
|
529
|
-
tools.set(idx, state);
|
|
530
|
-
out += sse("response.output_item.added", {
|
|
531
|
-
type: "response.output_item.added",
|
|
532
|
-
output_index: state.outputIndex,
|
|
533
|
-
item: {
|
|
534
|
-
id: state.callId,
|
|
535
|
-
type: "function_call",
|
|
536
|
-
call_id: state.callId,
|
|
537
|
-
name: state.name,
|
|
538
|
-
arguments: "",
|
|
539
|
-
},
|
|
540
|
-
});
|
|
541
|
-
outputIndex += 1;
|
|
542
|
-
}
|
|
543
|
-
if (typeof fn.name === "string" && fn.name && !state.name) state.name = fn.name;
|
|
544
|
-
if (typeof fn.arguments === "string" && fn.arguments.length > 0) {
|
|
545
|
-
state.args += fn.arguments;
|
|
546
|
-
out += sse("response.function_call_arguments.delta", {
|
|
547
|
-
type: "response.function_call_arguments.delta",
|
|
548
|
-
item_id: state.callId,
|
|
549
|
-
output_index: state.outputIndex,
|
|
550
|
-
delta: fn.arguments,
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
return out;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
function finish(): string {
|
|
558
|
-
let out = closeText();
|
|
559
|
-
// Close any open tool calls, emitting their assembled arguments.
|
|
560
|
-
for (const state of tools.values()) {
|
|
561
|
-
out += sse("response.function_call_arguments.done", {
|
|
562
|
-
type: "response.function_call_arguments.done",
|
|
563
|
-
item_id: state.callId,
|
|
564
|
-
output_index: state.outputIndex,
|
|
565
|
-
arguments: state.args,
|
|
566
|
-
});
|
|
567
|
-
out += sse("response.output_item.done", {
|
|
568
|
-
type: "response.output_item.done",
|
|
569
|
-
output_index: state.outputIndex,
|
|
570
|
-
item: {
|
|
571
|
-
id: state.callId,
|
|
572
|
-
type: "function_call",
|
|
573
|
-
call_id: state.callId,
|
|
574
|
-
name: state.name,
|
|
575
|
-
arguments: state.args,
|
|
576
|
-
},
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
out += sse("response.completed", {
|
|
580
|
-
type: "response.completed",
|
|
581
|
-
response: { id: responseId, object: "response", status: "completed", model },
|
|
582
|
-
});
|
|
583
|
-
return out;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
return { feed, finish };
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** A source the model cited, as carried by a Responses content-part annotation. */
|
|
590
|
-
export interface ResponsesCitation {
|
|
591
|
-
url: string;
|
|
592
|
-
title?: string;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/** What {@link readResponsesOutput} pulls out of a `response` object. */
|
|
596
|
-
export interface ResponsesOutput {
|
|
597
|
-
/** The assistant's answer, with the output items concatenated. */
|
|
598
|
-
text: string;
|
|
599
|
-
/** Cited sources in first-seen order, deduplicated by URL. */
|
|
600
|
-
citations: ResponsesCitation[];
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Read the answer text and cited sources out of a Responses `response` payload.
|
|
605
|
-
*
|
|
606
|
-
* This is the inverse of {@link chatToResponse}: it consumes what an endpoint
|
|
607
|
-
* speaking Responses natively returns, rather than producing it. The Databricks
|
|
608
|
-
* native web-search tool answers this way, with each source attached as a
|
|
609
|
-
* `url_citation` annotation on the content part that used it.
|
|
610
|
-
*
|
|
611
|
-
* Prefers the flattened `output_text` when the payload carries it, and falls
|
|
612
|
-
* back to walking `output[].content[].text`. Every field is optional upstream,
|
|
613
|
-
* so a payload missing any of it yields empty results rather than throwing.
|
|
614
|
-
*/
|
|
615
|
-
export function readResponsesOutput(payload: Record<string, unknown>): ResponsesOutput {
|
|
616
|
-
const output = Array.isArray(payload.output) ? (payload.output as unknown[]) : [];
|
|
617
|
-
const texts: string[] = [];
|
|
618
|
-
const citations: ResponsesCitation[] = [];
|
|
619
|
-
const seen = new Set<string>();
|
|
620
|
-
|
|
621
|
-
for (const rawItem of output) {
|
|
622
|
-
if (!rawItem || typeof rawItem !== "object") continue;
|
|
623
|
-
const content = (rawItem as Record<string, unknown>).content;
|
|
624
|
-
const parts = chatContentParts(content);
|
|
625
|
-
if (!parts) continue;
|
|
626
|
-
for (const rawPart of parts) {
|
|
627
|
-
if (!rawPart || typeof rawPart !== "object") continue;
|
|
628
|
-
const part = rawPart as Record<string, unknown>;
|
|
629
|
-
const text = string.trimToEmpty(part.text);
|
|
630
|
-
if (text) texts.push(text);
|
|
631
|
-
const annotations = Array.isArray(part.annotations) ? part.annotations : [];
|
|
632
|
-
for (const rawAnn of annotations) {
|
|
633
|
-
if (!rawAnn || typeof rawAnn !== "object") continue;
|
|
634
|
-
const ann = rawAnn as Record<string, unknown>;
|
|
635
|
-
const url = string.trimToEmpty(ann.url);
|
|
636
|
-
if (!url || seen.has(url)) continue;
|
|
637
|
-
seen.add(url);
|
|
638
|
-
const title = string.trimToEmpty(ann.title);
|
|
639
|
-
citations.push({ url, ...(title ? { title } : {}) });
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
return { text: string.trimToEmpty(payload.output_text) || texts.join("\n").trim(), citations };
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Strip tool types the upstream model doesn't support from a Responses request
|
|
649
|
-
* body, returning a shallow copy safe to forward (the input is not mutated).
|
|
650
|
-
*
|
|
651
|
-
* Clients like the Codex CLI include built-in Responses tools (`web_search`,
|
|
652
|
-
* `local_shell`, `custom`, ...) alongside their `function` tools. Databricks'
|
|
653
|
-
* Open Responses surface for Anthropic/Claude (and other non-OpenAI providers)
|
|
654
|
-
* only accepts `function` tools and hard-errors otherwise:
|
|
655
|
-
* "Anthropic does not support tool type 'web_search'. Only 'function' is supported."
|
|
656
|
-
* Call this before forwarding to `/serving-endpoints/open-responses`. The OpenAI
|
|
657
|
-
* `/serving-endpoints/responses` path should keep built-ins (GPT supports them).
|
|
658
|
-
* If filtering empties the list, `tools` / `tool_choice` / `parallel_tool_calls`
|
|
659
|
-
* are dropped so we never send an empty `tools: []`.
|
|
660
|
-
*/
|
|
661
|
-
export function sanitizeResponsesTools(body: Record<string, unknown>): Record<string, unknown> {
|
|
662
|
-
if (!Array.isArray(body.tools) || body.tools.length === 0) return body;
|
|
663
|
-
const fnTools = body.tools.filter(
|
|
664
|
-
(t) => t && typeof t === "object" && (t as Record<string, unknown>).type === "function",
|
|
665
|
-
);
|
|
666
|
-
if (fnTools.length === body.tools.length) return body; // nothing to strip
|
|
667
|
-
const next: Record<string, unknown> = { ...body };
|
|
668
|
-
if (fnTools.length > 0) {
|
|
669
|
-
next.tools = fnTools;
|
|
670
|
-
} else {
|
|
671
|
-
delete next.tools;
|
|
672
|
-
delete next.tool_choice;
|
|
673
|
-
delete next.parallel_tool_calls;
|
|
674
|
-
}
|
|
675
|
-
return next;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Content-part / input-item types Claude extended thinking uses on the wire.
|
|
680
|
-
*
|
|
681
|
-
* Shared so both wire surfaces strip the same set: the Responses path here
|
|
682
|
-
* ({@link sanitizeOpenResponsesInput}) and the Chat Completions path in
|
|
683
|
-
* `@dbx-tools/appkit-mastra`'s serving sanitize. Anthropic signs these blocks,
|
|
684
|
-
* so a replay that mutates one is rejected outright - both paths must agree on
|
|
685
|
-
* what counts as a reasoning block.
|
|
686
|
-
*/
|
|
687
|
-
export const REASONING_TYPES: ReadonlySet<string> = new Set([
|
|
688
|
-
"reasoning",
|
|
689
|
-
"thinking",
|
|
690
|
-
"redacted_thinking",
|
|
691
|
-
]);
|
|
692
|
-
|
|
693
|
-
/**
|
|
694
|
-
* Rewrite Responses `input` so Open Responses will accept it.
|
|
695
|
-
*
|
|
696
|
-
* Two OpenAI / Codex shapes that Databricks Open Responses (Claude, Gemini, …)
|
|
697
|
-
* reject:
|
|
698
|
-
*
|
|
699
|
-
* 1. Prior assistant turns with `output_text` / `output_*` content parts —
|
|
700
|
-
* Open Responses only allows `input_text`, `input_image`, `input_file`,
|
|
701
|
-
* `input_audio`. Map every `output_<kind>` part to `input_<kind>`.
|
|
702
|
-
* 2. Replayed extended-thinking blocks (`thinking`, `redacted_thinking`,
|
|
703
|
-
* `reasoning` content parts, and top-level `reasoning` input items). Claude
|
|
704
|
-
* signs those; any client round-trip that mutates them (or a cross-provider
|
|
705
|
-
* replay) fails with e.g.
|
|
706
|
-
* "messages.N.content.M: Invalid `data` in `redacted_thinking` block"
|
|
707
|
-
* Strip them — the UI/client already showed the thinking; replay does not
|
|
708
|
-
* need the signed blob. Same policy as appkit-mastra's serving sanitize.
|
|
709
|
-
*
|
|
710
|
-
* Returns a shallow copy when anything changes; otherwise the input body.
|
|
711
|
-
*/
|
|
712
|
-
export function sanitizeOpenResponsesInput(body: Record<string, unknown>): Record<string, unknown> {
|
|
713
|
-
if (!Array.isArray(body.input) || body.input.length === 0) return body;
|
|
714
|
-
|
|
715
|
-
let changed = false;
|
|
716
|
-
const input: unknown[] = [];
|
|
717
|
-
|
|
718
|
-
for (const raw of body.input) {
|
|
719
|
-
if (!raw || typeof raw !== "object") {
|
|
720
|
-
input.push(raw);
|
|
721
|
-
continue;
|
|
722
|
-
}
|
|
723
|
-
const item = raw as Record<string, unknown>;
|
|
724
|
-
|
|
725
|
-
// Top-level Responses reasoning items (not message content parts).
|
|
726
|
-
if (typeof item.type === "string" && REASONING_TYPES.has(item.type)) {
|
|
727
|
-
changed = true;
|
|
728
|
-
continue;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
const parts = chatContentParts(item.content);
|
|
732
|
-
if (!parts) {
|
|
733
|
-
input.push(raw);
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
let partChanged = !Array.isArray(item.content);
|
|
738
|
-
const content: unknown[] = [];
|
|
739
|
-
for (const part of parts) {
|
|
740
|
-
if (!part || typeof part !== "object") {
|
|
741
|
-
content.push(part);
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
const p = part as Record<string, unknown>;
|
|
745
|
-
if (typeof p.type === "string" && REASONING_TYPES.has(p.type)) {
|
|
746
|
-
partChanged = true;
|
|
747
|
-
continue;
|
|
748
|
-
}
|
|
749
|
-
if (typeof p.type === "string" && p.type.startsWith("output_")) {
|
|
750
|
-
partChanged = true;
|
|
751
|
-
content.push({ ...p, type: `input_${p.type.slice("output_".length)}` });
|
|
752
|
-
continue;
|
|
753
|
-
}
|
|
754
|
-
content.push(part);
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (!partChanged) {
|
|
758
|
-
input.push(raw);
|
|
759
|
-
continue;
|
|
760
|
-
}
|
|
761
|
-
changed = true;
|
|
762
|
-
// Drop assistant turns that only carried thinking (nothing left to replay).
|
|
763
|
-
if (content.length === 0 && item.role === "assistant") continue;
|
|
764
|
-
input.push({ ...item, content });
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
return changed ? { ...body, input } : body;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
/**
|
|
771
|
-
* Drop trailing assistant turns so the transcript ends on something Anthropic
|
|
772
|
-
* will continue from.
|
|
773
|
-
*
|
|
774
|
-
* Databricks Open Responses rejects a body whose last item is an assistant
|
|
775
|
-
* message with `"This model does not support assistant message prefill. The
|
|
776
|
-
* conversation must end with a user message."` (the upstream Bedrock route
|
|
777
|
-
* disallows prefill). Two ways a real client hits it:
|
|
778
|
-
*
|
|
779
|
-
* 1. The client replays its own last answer. The Codex CLI does this when a turn
|
|
780
|
-
* ends without a tool call and the next request carries the full transcript.
|
|
781
|
-
* 2. {@link sanitizeOpenResponsesInput} CREATES the shape: a turn that ended in a
|
|
782
|
-
* `reasoning` item is stripped for replay compatibility, promoting the
|
|
783
|
-
* assistant message before it to last. Running this repair after that strip
|
|
784
|
-
* is what keeps one fix from causing the other failure.
|
|
785
|
-
*
|
|
786
|
-
* Dropping is correct rather than lossy: a trailing assistant turn carries text
|
|
787
|
-
* the model itself just produced, so it is context the provider does not need
|
|
788
|
-
* repeated in order to continue. Appending a synthetic `"Continue."` user turn
|
|
789
|
-
* also satisfies the provider, but it puts words in the user's mouth that show
|
|
790
|
-
* up in the model's context, so this takes the honest option.
|
|
791
|
-
*
|
|
792
|
-
* A trailing `function_call` is left ALONE. It is also an assistant-side item,
|
|
793
|
-
* but Anthropic rejects an unanswered `tool_use` on a different rule
|
|
794
|
-
* (`tool_use` ids without `tool_result`), and dropping it would silently discard
|
|
795
|
-
* a tool call the client is about to answer. That is the client's bug to fix,
|
|
796
|
-
* not something to paper over here.
|
|
797
|
-
*
|
|
798
|
-
* Returns a shallow copy when anything changes; otherwise the input body.
|
|
799
|
-
*/
|
|
800
|
-
export function repairTrailingAssistantInput(
|
|
801
|
-
body: Record<string, unknown>,
|
|
802
|
-
): Record<string, unknown> {
|
|
803
|
-
if (!Array.isArray(body.input) || body.input.length === 0) return body;
|
|
804
|
-
|
|
805
|
-
const input = [...body.input];
|
|
806
|
-
let changed = false;
|
|
807
|
-
// A loop, not one check: stripping reasoning can leave several assistant
|
|
808
|
-
// turns in a row at the end.
|
|
809
|
-
while (input.length > 0 && isAssistantMessageItem(input[input.length - 1])) {
|
|
810
|
-
input.pop();
|
|
811
|
-
changed = true;
|
|
812
|
-
}
|
|
813
|
-
// Never send an empty `input`: an all-assistant transcript is not something
|
|
814
|
-
// this repair can rescue, so leave the body for the provider to reject with
|
|
815
|
-
// its own message rather than inventing a request.
|
|
816
|
-
if (!changed || input.length === 0) return body;
|
|
817
|
-
return { ...body, input };
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* Whether an `input` item is an assistant MESSAGE (the prefill trigger), as
|
|
822
|
-
* opposed to a `function_call` or any other assistant-side item type.
|
|
823
|
-
*/
|
|
824
|
-
function isAssistantMessageItem(value: unknown): boolean {
|
|
825
|
-
if (!value || typeof value !== "object") return false;
|
|
826
|
-
const item = value as Record<string, unknown>;
|
|
827
|
-
if (item.role !== "assistant") return false;
|
|
828
|
-
return item.type === undefined || item.type === "message";
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/**
|
|
832
|
-
* Full Open Responses request sanitizer: strip non-`function` tools, rewrite
|
|
833
|
-
* `output_*` content parts, drop thinking / reasoning blocks, and drop a
|
|
834
|
-
* trailing assistant turn the provider would read as a prefill. Safe no-op
|
|
835
|
-
* when nothing needs changing.
|
|
836
|
-
*/
|
|
837
|
-
export function sanitizeOpenResponsesRequest(
|
|
838
|
-
body: Record<string, unknown>,
|
|
839
|
-
): Record<string, unknown> {
|
|
840
|
-
return repairTrailingAssistantInput(sanitizeOpenResponsesInput(sanitizeResponsesTools(body)));
|
|
841
|
-
}
|