@dbx-tools/shared-model 0.3.28 → 0.3.29
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 +23 -1
- package/package.json +2 -2
- package/src/openai-responses.ts +116 -89
package/README.md
CHANGED
|
@@ -122,6 +122,27 @@ the Responses event stream, and `readResponsesOutput` for pulling the answer and
|
|
|
122
122
|
its citations back out of a native Responses reply. Pure functions over plain
|
|
123
123
|
JSON, so the same translation runs in a proxy, a server route, or a test.
|
|
124
124
|
|
|
125
|
+
## Sanitize A Replayed Conversation
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { openaiResponses } from "@dbx-tools/shared-model";
|
|
129
|
+
|
|
130
|
+
const body = openaiResponses.sanitizeOpenResponsesRequest(requestBody);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`/open-responses` (the cross-provider path used for Claude and Gemini) rejects
|
|
134
|
+
content parts that its own previous turn produced: an `output_text` part replayed
|
|
135
|
+
as input fails with `Open Responses input content part type 'output_text' is not
|
|
136
|
+
supported`. `sanitizeOpenResponsesRequest` rewrites `output_*` parts back to their
|
|
137
|
+
`input_*` form and drops extended-thinking parts before the body goes out.
|
|
138
|
+
|
|
139
|
+
The thinking-block types live in one exported constant,
|
|
140
|
+
`openaiResponses.REASONING_TYPES`. Both wire sanitizers (this one and the Chat
|
|
141
|
+
Completions sanitizer in [`@dbx-tools/appkit-mastra`](../../node/appkit-mastra))
|
|
142
|
+
must strip exactly the same set: Anthropic signs `redacted_thinking` blocks, so a
|
|
143
|
+
replay in which one path mutates a block the other preserved is rejected
|
|
144
|
+
outright. Import the constant rather than re-listing the types.
|
|
145
|
+
|
|
125
146
|
## Strip Fields Databricks Rejects
|
|
126
147
|
|
|
127
148
|
```ts
|
|
@@ -151,7 +172,8 @@ one-by-one (`openaiResponses.responsesToChat`) already can't leak them. Pass
|
|
|
151
172
|
- `display` - human-readable endpoint labels.
|
|
152
173
|
- `openaiChat` - Chat Completions message / tool-call types,
|
|
153
174
|
`chatContentToText`, and `stripUnsupportedChatFields`.
|
|
154
|
-
- `openaiResponses` - Responses API translation
|
|
175
|
+
- `openaiResponses` - Responses API translation in both directions, plus
|
|
176
|
+
`sanitizeOpenResponsesRequest` and the shared `REASONING_TYPES` constant.
|
|
155
177
|
|
|
156
178
|
Server-side selection, cache, and fuzzy endpoint matching are in
|
|
157
179
|
[`@dbx-tools/model`](../../node/model).
|
package/package.json
CHANGED
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"zod": "^4.3.6",
|
|
15
|
-
"@dbx-tools/shared-core": "0.3.
|
|
15
|
+
"@dbx-tools/shared-core": "0.3.29"
|
|
16
16
|
},
|
|
17
17
|
"main": "index.ts",
|
|
18
18
|
"license": "UNLICENSED",
|
|
19
19
|
"publishConfig": {
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
|
-
"version": "0.3.
|
|
22
|
+
"version": "0.3.29",
|
|
23
23
|
"types": "index.ts",
|
|
24
24
|
"type": "module",
|
|
25
25
|
"exports": {
|
package/src/openai-responses.ts
CHANGED
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
* @module
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
-
import {
|
|
45
|
+
import { string } from "@dbx-tools/shared-core";
|
|
46
|
+
|
|
47
|
+
import { type ChatMessage, type ChatToolCall, chatContentToText } from "./openai-chat";
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* Lower a Responses request body to a chat-completions body. Returns the chat
|
|
@@ -73,16 +75,7 @@ export function responsesToChat(body: Record<string, unknown>): {
|
|
|
73
75
|
messages.push({
|
|
74
76
|
role: "assistant",
|
|
75
77
|
content: null,
|
|
76
|
-
tool_calls: [
|
|
77
|
-
{
|
|
78
|
-
id: String(item.call_id ?? item.id ?? ""),
|
|
79
|
-
type: "function",
|
|
80
|
-
function: {
|
|
81
|
-
name: String(item.name ?? ""),
|
|
82
|
-
arguments: typeof item.arguments === "string" ? item.arguments : "{}",
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
],
|
|
78
|
+
tool_calls: [toChatToolCall(item)],
|
|
86
79
|
});
|
|
87
80
|
continue;
|
|
88
81
|
}
|
|
@@ -118,25 +111,7 @@ export function responsesToChat(body: Record<string, unknown>): {
|
|
|
118
111
|
// only function tools and drop the rest.
|
|
119
112
|
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
120
113
|
const chatTools = body.tools
|
|
121
|
-
.map(
|
|
122
|
-
const tool = t as Record<string, unknown>;
|
|
123
|
-
// Already chat-nested (`function` object present): pass through.
|
|
124
|
-
if (tool.type === "function" && tool.function && typeof tool.function === "object") {
|
|
125
|
-
return tool;
|
|
126
|
-
}
|
|
127
|
-
// Flat Responses function tool: nest it.
|
|
128
|
-
if (tool.type === "function" && typeof tool.name === "string") {
|
|
129
|
-
return {
|
|
130
|
-
type: "function",
|
|
131
|
-
function: {
|
|
132
|
-
name: tool.name,
|
|
133
|
-
...(tool.description ? { description: tool.description } : {}),
|
|
134
|
-
...(tool.parameters ? { parameters: tool.parameters } : {}),
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
return undefined; // non-function built-in tool: unsupported upstream, drop
|
|
139
|
-
})
|
|
114
|
+
.map(toChatTool)
|
|
140
115
|
.filter((t): t is Record<string, unknown> => t !== undefined);
|
|
141
116
|
if (chatTools.length > 0) {
|
|
142
117
|
chat.tools = chatTools;
|
|
@@ -171,7 +146,11 @@ export function chatToResponsesRequest(body: Record<string, unknown>): {
|
|
|
171
146
|
const role = typeof message.role === "string" ? message.role : "user";
|
|
172
147
|
|
|
173
148
|
// Leading system / developer messages become top-level `instructions`.
|
|
174
|
-
if (
|
|
149
|
+
if (
|
|
150
|
+
(role === "system" || role === "developer") &&
|
|
151
|
+
instructions === undefined &&
|
|
152
|
+
input.length === 0
|
|
153
|
+
) {
|
|
175
154
|
const text = chatContentToText(message.content);
|
|
176
155
|
if (text) instructions = text;
|
|
177
156
|
continue;
|
|
@@ -186,17 +165,14 @@ export function chatToResponsesRequest(body: Record<string, unknown>): {
|
|
|
186
165
|
continue;
|
|
187
166
|
}
|
|
188
167
|
|
|
189
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
role === "assistant" &&
|
|
170
|
+
Array.isArray(message.tool_calls) &&
|
|
171
|
+
message.tool_calls.length > 0
|
|
172
|
+
) {
|
|
190
173
|
for (const rawCall of message.tool_calls) {
|
|
191
174
|
if (!rawCall || typeof rawCall !== "object") continue;
|
|
192
|
-
|
|
193
|
-
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
194
|
-
input.push({
|
|
195
|
-
type: "function_call",
|
|
196
|
-
call_id: String(call.id ?? ""),
|
|
197
|
-
name: String(fn.name ?? ""),
|
|
198
|
-
arguments: typeof fn.arguments === "string" ? fn.arguments : "{}",
|
|
199
|
-
});
|
|
175
|
+
input.push(toFunctionCallItem(rawCall as Record<string, unknown>));
|
|
200
176
|
}
|
|
201
177
|
const text = chatContentToText(message.content);
|
|
202
178
|
if (text) {
|
|
@@ -222,22 +198,7 @@ export function chatToResponsesRequest(body: Record<string, unknown>): {
|
|
|
222
198
|
// Chat tools are nested (`function: { name, … }`); Responses wants them flat.
|
|
223
199
|
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
224
200
|
const tools = body.tools
|
|
225
|
-
.map(
|
|
226
|
-
if (!t || typeof t !== "object") return undefined;
|
|
227
|
-
const tool = t as Record<string, unknown>;
|
|
228
|
-
if (tool.type !== "function") return undefined;
|
|
229
|
-
if (tool.function && typeof tool.function === "object") {
|
|
230
|
-
const fn = tool.function as Record<string, unknown>;
|
|
231
|
-
return {
|
|
232
|
-
type: "function",
|
|
233
|
-
name: fn.name,
|
|
234
|
-
...(fn.description ? { description: fn.description } : {}),
|
|
235
|
-
...(fn.parameters ? { parameters: fn.parameters } : {}),
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
if (typeof tool.name === "string") return tool;
|
|
239
|
-
return undefined;
|
|
240
|
-
})
|
|
201
|
+
.map(toResponsesTool)
|
|
241
202
|
.filter((t): t is Record<string, unknown> => t !== undefined);
|
|
242
203
|
if (tools.length > 0) {
|
|
243
204
|
responses.tools = tools;
|
|
@@ -274,14 +235,7 @@ export function chatToResponse(chat: Record<string, unknown>, model: string): un
|
|
|
274
235
|
// Surface any tool calls as Responses `function_call` output items.
|
|
275
236
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
276
237
|
for (const raw of toolCalls) {
|
|
277
|
-
|
|
278
|
-
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
279
|
-
output.push({
|
|
280
|
-
type: "function_call",
|
|
281
|
-
call_id: String(call.id ?? ""),
|
|
282
|
-
name: String(fn.name ?? ""),
|
|
283
|
-
arguments: typeof fn.arguments === "string" ? fn.arguments : "{}",
|
|
284
|
-
});
|
|
238
|
+
output.push(toFunctionCallItem(raw as Record<string, unknown>));
|
|
285
239
|
}
|
|
286
240
|
|
|
287
241
|
const usage = (chat.usage ?? {}) as Record<string, unknown>;
|
|
@@ -316,14 +270,7 @@ export function responseToChatCompletion(
|
|
|
316
270
|
if (!raw || typeof raw !== "object") continue;
|
|
317
271
|
const item = raw as Record<string, unknown>;
|
|
318
272
|
if (item.type !== "function_call") continue;
|
|
319
|
-
toolCalls.push(
|
|
320
|
-
id: String(item.call_id ?? item.id ?? ""),
|
|
321
|
-
type: "function",
|
|
322
|
-
function: {
|
|
323
|
-
name: String(item.name ?? ""),
|
|
324
|
-
arguments: typeof item.arguments === "string" ? item.arguments : "{}",
|
|
325
|
-
},
|
|
326
|
-
});
|
|
273
|
+
toolCalls.push(toChatToolCall(item));
|
|
327
274
|
}
|
|
328
275
|
|
|
329
276
|
const message: Record<string, unknown> = {
|
|
@@ -353,6 +300,83 @@ export function responseToChatCompletion(
|
|
|
353
300
|
};
|
|
354
301
|
}
|
|
355
302
|
|
|
303
|
+
/**
|
|
304
|
+
* A tool call's `arguments` JSON. Both wires carry it as an unparsed string;
|
|
305
|
+
* anything else (absent, an already-parsed object) becomes an empty literal so
|
|
306
|
+
* the receiving side always has valid JSON to parse.
|
|
307
|
+
*/
|
|
308
|
+
function callArguments(value: unknown): string {
|
|
309
|
+
return typeof value === "string" ? value : "{}";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The Responses `function_call` item for a tool call in either spelling: a chat
|
|
314
|
+
* `tool_calls[]` entry (`{id, function:{name, arguments}}`) or an item that is
|
|
315
|
+
* already flat.
|
|
316
|
+
*/
|
|
317
|
+
function toFunctionCallItem(call: Record<string, unknown>): Record<string, unknown> {
|
|
318
|
+
const fn = (call.function ?? call) as Record<string, unknown>;
|
|
319
|
+
return {
|
|
320
|
+
type: "function_call",
|
|
321
|
+
call_id: String(call.call_id ?? call.id ?? ""),
|
|
322
|
+
name: String(fn.name ?? ""),
|
|
323
|
+
arguments: callArguments(fn.arguments),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The chat `tool_calls[]` entry for a Responses `function_call` item. */
|
|
328
|
+
function toChatToolCall(item: Record<string, unknown>): ChatToolCall {
|
|
329
|
+
return {
|
|
330
|
+
id: String(item.call_id ?? item.id ?? ""),
|
|
331
|
+
type: "function",
|
|
332
|
+
function: {
|
|
333
|
+
name: String(item.name ?? ""),
|
|
334
|
+
arguments: callArguments(item.arguments),
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The chat-nested spelling (`{type:"function", function:{name, …}}`) of a tool
|
|
341
|
+
* spec, or `undefined` for a built-in Responses tool (`web_search`,
|
|
342
|
+
* `local_shell`, …) that chat cannot express and Databricks rejects.
|
|
343
|
+
*/
|
|
344
|
+
function toChatTool(value: unknown): Record<string, unknown> | undefined {
|
|
345
|
+
if (!value || typeof value !== "object") return undefined;
|
|
346
|
+
const tool = value as Record<string, unknown>;
|
|
347
|
+
if (tool.type !== "function") return undefined;
|
|
348
|
+
if (tool.function && typeof tool.function === "object") return tool; // already nested
|
|
349
|
+
if (typeof tool.name !== "string") return undefined;
|
|
350
|
+
return {
|
|
351
|
+
type: "function",
|
|
352
|
+
function: {
|
|
353
|
+
name: tool.name,
|
|
354
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
355
|
+
...(tool.parameters ? { parameters: tool.parameters } : {}),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The flat Responses spelling (`{type:"function", name, …}`) of a tool spec.
|
|
362
|
+
* Inverse of {@link toChatTool}; non-function tools are dropped the same way.
|
|
363
|
+
*/
|
|
364
|
+
function toResponsesTool(value: unknown): Record<string, unknown> | undefined {
|
|
365
|
+
if (!value || typeof value !== "object") return undefined;
|
|
366
|
+
const tool = value as Record<string, unknown>;
|
|
367
|
+
if (tool.type !== "function") return undefined;
|
|
368
|
+
if (tool.function && typeof tool.function === "object") {
|
|
369
|
+
const fn = tool.function as Record<string, unknown>;
|
|
370
|
+
return {
|
|
371
|
+
type: "function",
|
|
372
|
+
name: fn.name,
|
|
373
|
+
...(fn.description ? { description: fn.description } : {}),
|
|
374
|
+
...(fn.parameters ? { parameters: fn.parameters } : {}),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return typeof tool.name === "string" ? tool : undefined; // already flat
|
|
378
|
+
}
|
|
379
|
+
|
|
356
380
|
/** One Server-Sent Event, ready to write to the client. */
|
|
357
381
|
function sse(event: string, data: unknown): string {
|
|
358
382
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
@@ -591,27 +615,22 @@ export function readResponsesOutput(payload: Record<string, unknown>): Responses
|
|
|
591
615
|
for (const rawPart of content) {
|
|
592
616
|
if (!rawPart || typeof rawPart !== "object") continue;
|
|
593
617
|
const part = rawPart as Record<string, unknown>;
|
|
594
|
-
const text =
|
|
618
|
+
const text = string.trimToEmpty(part.text);
|
|
595
619
|
if (text) texts.push(text);
|
|
596
620
|
const annotations = Array.isArray(part.annotations) ? part.annotations : [];
|
|
597
621
|
for (const rawAnn of annotations) {
|
|
598
622
|
if (!rawAnn || typeof rawAnn !== "object") continue;
|
|
599
623
|
const ann = rawAnn as Record<string, unknown>;
|
|
600
|
-
const url =
|
|
624
|
+
const url = string.trimToEmpty(ann.url);
|
|
601
625
|
if (!url || seen.has(url)) continue;
|
|
602
626
|
seen.add(url);
|
|
603
|
-
const title =
|
|
627
|
+
const title = string.trimToEmpty(ann.title);
|
|
604
628
|
citations.push({ url, ...(title ? { title } : {}) });
|
|
605
629
|
}
|
|
606
630
|
}
|
|
607
631
|
}
|
|
608
632
|
|
|
609
|
-
return { text:
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** Coerce an unknown JSON value to a trimmed string, or `""` when it isn't one. */
|
|
613
|
-
function str(value: unknown): string {
|
|
614
|
-
return typeof value === "string" ? value.trim() : "";
|
|
633
|
+
return { text: string.trimToEmpty(payload.output_text) || texts.join("\n").trim(), citations };
|
|
615
634
|
}
|
|
616
635
|
|
|
617
636
|
/**
|
|
@@ -628,9 +647,7 @@ function str(value: unknown): string {
|
|
|
628
647
|
* If filtering empties the list, `tools` / `tool_choice` / `parallel_tool_calls`
|
|
629
648
|
* are dropped so we never send an empty `tools: []`.
|
|
630
649
|
*/
|
|
631
|
-
export function sanitizeResponsesTools(
|
|
632
|
-
body: Record<string, unknown>,
|
|
633
|
-
): Record<string, unknown> {
|
|
650
|
+
export function sanitizeResponsesTools(body: Record<string, unknown>): Record<string, unknown> {
|
|
634
651
|
if (!Array.isArray(body.tools) || body.tools.length === 0) return body;
|
|
635
652
|
const fnTools = body.tools.filter(
|
|
636
653
|
(t) => t && typeof t === "object" && (t as Record<string, unknown>).type === "function",
|
|
@@ -647,8 +664,20 @@ export function sanitizeResponsesTools(
|
|
|
647
664
|
return next;
|
|
648
665
|
}
|
|
649
666
|
|
|
650
|
-
/**
|
|
651
|
-
|
|
667
|
+
/**
|
|
668
|
+
* Content-part / input-item types Claude extended thinking uses on the wire.
|
|
669
|
+
*
|
|
670
|
+
* Shared so both wire surfaces strip the same set: the Responses path here
|
|
671
|
+
* ({@link sanitizeOpenResponsesInput}) and the Chat Completions path in
|
|
672
|
+
* `@dbx-tools/appkit-mastra`'s serving sanitize. Anthropic signs these blocks,
|
|
673
|
+
* so a replay that mutates one is rejected outright - both paths must agree on
|
|
674
|
+
* what counts as a reasoning block.
|
|
675
|
+
*/
|
|
676
|
+
export const REASONING_TYPES: ReadonlySet<string> = new Set([
|
|
677
|
+
"reasoning",
|
|
678
|
+
"thinking",
|
|
679
|
+
"redacted_thinking",
|
|
680
|
+
]);
|
|
652
681
|
|
|
653
682
|
/**
|
|
654
683
|
* Rewrite Responses `input` so Open Responses will accept it.
|
|
@@ -669,9 +698,7 @@ const REASONING_TYPES = new Set(["reasoning", "thinking", "redacted_thinking"]);
|
|
|
669
698
|
*
|
|
670
699
|
* Returns a shallow copy when anything changes; otherwise the input body.
|
|
671
700
|
*/
|
|
672
|
-
export function sanitizeOpenResponsesInput(
|
|
673
|
-
body: Record<string, unknown>,
|
|
674
|
-
): Record<string, unknown> {
|
|
701
|
+
export function sanitizeOpenResponsesInput(body: Record<string, unknown>): Record<string, unknown> {
|
|
675
702
|
if (!Array.isArray(body.input) || body.input.length === 0) return body;
|
|
676
703
|
|
|
677
704
|
let changed = false;
|