@dbx-tools/shared-model 0.3.27 → 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 +182 -111
- package/test/openai-responses.test.ts +70 -0
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
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
* request body (for `/open-responses` / Anthropic, which reject Codex
|
|
31
31
|
* built-ins like `web_search`).
|
|
32
32
|
* - {@link sanitizeOpenResponsesInput} rewrites `output_*` content part types
|
|
33
|
-
* in `input` to `input_
|
|
33
|
+
* in `input` to `input_*`, and drops Claude thinking / reasoning blocks
|
|
34
|
+
* that Open Responses rejects on replay (`redacted_thinking`, …).
|
|
34
35
|
*
|
|
35
36
|
* Only the surface real clients exercise is translated; unknown fields are
|
|
36
37
|
* ignored rather than rejected, so a newer client degrades instead of breaking.
|
|
@@ -41,7 +42,9 @@
|
|
|
41
42
|
* @module
|
|
42
43
|
*/
|
|
43
44
|
|
|
44
|
-
import {
|
|
45
|
+
import { string } from "@dbx-tools/shared-core";
|
|
46
|
+
|
|
47
|
+
import { type ChatMessage, type ChatToolCall, chatContentToText } from "./openai-chat";
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* Lower a Responses request body to a chat-completions body. Returns the chat
|
|
@@ -72,16 +75,7 @@ export function responsesToChat(body: Record<string, unknown>): {
|
|
|
72
75
|
messages.push({
|
|
73
76
|
role: "assistant",
|
|
74
77
|
content: null,
|
|
75
|
-
tool_calls: [
|
|
76
|
-
{
|
|
77
|
-
id: String(item.call_id ?? item.id ?? ""),
|
|
78
|
-
type: "function",
|
|
79
|
-
function: {
|
|
80
|
-
name: String(item.name ?? ""),
|
|
81
|
-
arguments: typeof item.arguments === "string" ? item.arguments : "{}",
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
],
|
|
78
|
+
tool_calls: [toChatToolCall(item)],
|
|
85
79
|
});
|
|
86
80
|
continue;
|
|
87
81
|
}
|
|
@@ -117,25 +111,7 @@ export function responsesToChat(body: Record<string, unknown>): {
|
|
|
117
111
|
// only function tools and drop the rest.
|
|
118
112
|
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
119
113
|
const chatTools = body.tools
|
|
120
|
-
.map(
|
|
121
|
-
const tool = t as Record<string, unknown>;
|
|
122
|
-
// Already chat-nested (`function` object present): pass through.
|
|
123
|
-
if (tool.type === "function" && tool.function && typeof tool.function === "object") {
|
|
124
|
-
return tool;
|
|
125
|
-
}
|
|
126
|
-
// Flat Responses function tool: nest it.
|
|
127
|
-
if (tool.type === "function" && typeof tool.name === "string") {
|
|
128
|
-
return {
|
|
129
|
-
type: "function",
|
|
130
|
-
function: {
|
|
131
|
-
name: tool.name,
|
|
132
|
-
...(tool.description ? { description: tool.description } : {}),
|
|
133
|
-
...(tool.parameters ? { parameters: tool.parameters } : {}),
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
return undefined; // non-function built-in tool: unsupported upstream, drop
|
|
138
|
-
})
|
|
114
|
+
.map(toChatTool)
|
|
139
115
|
.filter((t): t is Record<string, unknown> => t !== undefined);
|
|
140
116
|
if (chatTools.length > 0) {
|
|
141
117
|
chat.tools = chatTools;
|
|
@@ -170,7 +146,11 @@ export function chatToResponsesRequest(body: Record<string, unknown>): {
|
|
|
170
146
|
const role = typeof message.role === "string" ? message.role : "user";
|
|
171
147
|
|
|
172
148
|
// Leading system / developer messages become top-level `instructions`.
|
|
173
|
-
if (
|
|
149
|
+
if (
|
|
150
|
+
(role === "system" || role === "developer") &&
|
|
151
|
+
instructions === undefined &&
|
|
152
|
+
input.length === 0
|
|
153
|
+
) {
|
|
174
154
|
const text = chatContentToText(message.content);
|
|
175
155
|
if (text) instructions = text;
|
|
176
156
|
continue;
|
|
@@ -185,17 +165,14 @@ export function chatToResponsesRequest(body: Record<string, unknown>): {
|
|
|
185
165
|
continue;
|
|
186
166
|
}
|
|
187
167
|
|
|
188
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
role === "assistant" &&
|
|
170
|
+
Array.isArray(message.tool_calls) &&
|
|
171
|
+
message.tool_calls.length > 0
|
|
172
|
+
) {
|
|
189
173
|
for (const rawCall of message.tool_calls) {
|
|
190
174
|
if (!rawCall || typeof rawCall !== "object") continue;
|
|
191
|
-
|
|
192
|
-
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
193
|
-
input.push({
|
|
194
|
-
type: "function_call",
|
|
195
|
-
call_id: String(call.id ?? ""),
|
|
196
|
-
name: String(fn.name ?? ""),
|
|
197
|
-
arguments: typeof fn.arguments === "string" ? fn.arguments : "{}",
|
|
198
|
-
});
|
|
175
|
+
input.push(toFunctionCallItem(rawCall as Record<string, unknown>));
|
|
199
176
|
}
|
|
200
177
|
const text = chatContentToText(message.content);
|
|
201
178
|
if (text) {
|
|
@@ -221,22 +198,7 @@ export function chatToResponsesRequest(body: Record<string, unknown>): {
|
|
|
221
198
|
// Chat tools are nested (`function: { name, … }`); Responses wants them flat.
|
|
222
199
|
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
223
200
|
const tools = body.tools
|
|
224
|
-
.map(
|
|
225
|
-
if (!t || typeof t !== "object") return undefined;
|
|
226
|
-
const tool = t as Record<string, unknown>;
|
|
227
|
-
if (tool.type !== "function") return undefined;
|
|
228
|
-
if (tool.function && typeof tool.function === "object") {
|
|
229
|
-
const fn = tool.function as Record<string, unknown>;
|
|
230
|
-
return {
|
|
231
|
-
type: "function",
|
|
232
|
-
name: fn.name,
|
|
233
|
-
...(fn.description ? { description: fn.description } : {}),
|
|
234
|
-
...(fn.parameters ? { parameters: fn.parameters } : {}),
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
if (typeof tool.name === "string") return tool;
|
|
238
|
-
return undefined;
|
|
239
|
-
})
|
|
201
|
+
.map(toResponsesTool)
|
|
240
202
|
.filter((t): t is Record<string, unknown> => t !== undefined);
|
|
241
203
|
if (tools.length > 0) {
|
|
242
204
|
responses.tools = tools;
|
|
@@ -273,14 +235,7 @@ export function chatToResponse(chat: Record<string, unknown>, model: string): un
|
|
|
273
235
|
// Surface any tool calls as Responses `function_call` output items.
|
|
274
236
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
275
237
|
for (const raw of toolCalls) {
|
|
276
|
-
|
|
277
|
-
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
278
|
-
output.push({
|
|
279
|
-
type: "function_call",
|
|
280
|
-
call_id: String(call.id ?? ""),
|
|
281
|
-
name: String(fn.name ?? ""),
|
|
282
|
-
arguments: typeof fn.arguments === "string" ? fn.arguments : "{}",
|
|
283
|
-
});
|
|
238
|
+
output.push(toFunctionCallItem(raw as Record<string, unknown>));
|
|
284
239
|
}
|
|
285
240
|
|
|
286
241
|
const usage = (chat.usage ?? {}) as Record<string, unknown>;
|
|
@@ -315,14 +270,7 @@ export function responseToChatCompletion(
|
|
|
315
270
|
if (!raw || typeof raw !== "object") continue;
|
|
316
271
|
const item = raw as Record<string, unknown>;
|
|
317
272
|
if (item.type !== "function_call") continue;
|
|
318
|
-
toolCalls.push(
|
|
319
|
-
id: String(item.call_id ?? item.id ?? ""),
|
|
320
|
-
type: "function",
|
|
321
|
-
function: {
|
|
322
|
-
name: String(item.name ?? ""),
|
|
323
|
-
arguments: typeof item.arguments === "string" ? item.arguments : "{}",
|
|
324
|
-
},
|
|
325
|
-
});
|
|
273
|
+
toolCalls.push(toChatToolCall(item));
|
|
326
274
|
}
|
|
327
275
|
|
|
328
276
|
const message: Record<string, unknown> = {
|
|
@@ -352,6 +300,83 @@ export function responseToChatCompletion(
|
|
|
352
300
|
};
|
|
353
301
|
}
|
|
354
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
|
+
|
|
355
380
|
/** One Server-Sent Event, ready to write to the client. */
|
|
356
381
|
function sse(event: string, data: unknown): string {
|
|
357
382
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
@@ -590,27 +615,22 @@ export function readResponsesOutput(payload: Record<string, unknown>): Responses
|
|
|
590
615
|
for (const rawPart of content) {
|
|
591
616
|
if (!rawPart || typeof rawPart !== "object") continue;
|
|
592
617
|
const part = rawPart as Record<string, unknown>;
|
|
593
|
-
const text =
|
|
618
|
+
const text = string.trimToEmpty(part.text);
|
|
594
619
|
if (text) texts.push(text);
|
|
595
620
|
const annotations = Array.isArray(part.annotations) ? part.annotations : [];
|
|
596
621
|
for (const rawAnn of annotations) {
|
|
597
622
|
if (!rawAnn || typeof rawAnn !== "object") continue;
|
|
598
623
|
const ann = rawAnn as Record<string, unknown>;
|
|
599
|
-
const url =
|
|
624
|
+
const url = string.trimToEmpty(ann.url);
|
|
600
625
|
if (!url || seen.has(url)) continue;
|
|
601
626
|
seen.add(url);
|
|
602
|
-
const title =
|
|
627
|
+
const title = string.trimToEmpty(ann.title);
|
|
603
628
|
citations.push({ url, ...(title ? { title } : {}) });
|
|
604
629
|
}
|
|
605
630
|
}
|
|
606
631
|
}
|
|
607
632
|
|
|
608
|
-
return { text:
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Coerce an unknown JSON value to a trimmed string, or `""` when it isn't one. */
|
|
612
|
-
function str(value: unknown): string {
|
|
613
|
-
return typeof value === "string" ? value.trim() : "";
|
|
633
|
+
return { text: string.trimToEmpty(payload.output_text) || texts.join("\n").trim(), citations };
|
|
614
634
|
}
|
|
615
635
|
|
|
616
636
|
/**
|
|
@@ -627,9 +647,7 @@ function str(value: unknown): string {
|
|
|
627
647
|
* If filtering empties the list, `tools` / `tool_choice` / `parallel_tool_calls`
|
|
628
648
|
* are dropped so we never send an empty `tools: []`.
|
|
629
649
|
*/
|
|
630
|
-
export function sanitizeResponsesTools(
|
|
631
|
-
body: Record<string, unknown>,
|
|
632
|
-
): Record<string, unknown> {
|
|
650
|
+
export function sanitizeResponsesTools(body: Record<string, unknown>): Record<string, unknown> {
|
|
633
651
|
if (!Array.isArray(body.tools) || body.tools.length === 0) return body;
|
|
634
652
|
const fnTools = body.tools.filter(
|
|
635
653
|
(t) => t && typeof t === "object" && (t as Record<string, unknown>).type === "function",
|
|
@@ -647,47 +665,100 @@ export function sanitizeResponsesTools(
|
|
|
647
665
|
}
|
|
648
666
|
|
|
649
667
|
/**
|
|
650
|
-
*
|
|
668
|
+
* Content-part / input-item types Claude extended thinking uses on the wire.
|
|
651
669
|
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* Map every `output_<kind>` part to `input_<kind>` (shallow copy; no mutation).
|
|
658
|
-
* Call together with {@link sanitizeResponsesTools} before forwarding to
|
|
659
|
-
* `/serving-endpoints/open-responses`.
|
|
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.
|
|
660
675
|
*/
|
|
661
|
-
export
|
|
662
|
-
|
|
663
|
-
|
|
676
|
+
export const REASONING_TYPES: ReadonlySet<string> = new Set([
|
|
677
|
+
"reasoning",
|
|
678
|
+
"thinking",
|
|
679
|
+
"redacted_thinking",
|
|
680
|
+
]);
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Rewrite Responses `input` so Open Responses will accept it.
|
|
684
|
+
*
|
|
685
|
+
* Two OpenAI / Codex shapes that Databricks Open Responses (Claude, Gemini, …)
|
|
686
|
+
* reject:
|
|
687
|
+
*
|
|
688
|
+
* 1. Prior assistant turns with `output_text` / `output_*` content parts —
|
|
689
|
+
* Open Responses only allows `input_text`, `input_image`, `input_file`,
|
|
690
|
+
* `input_audio`. Map every `output_<kind>` part to `input_<kind>`.
|
|
691
|
+
* 2. Replayed extended-thinking blocks (`thinking`, `redacted_thinking`,
|
|
692
|
+
* `reasoning` content parts, and top-level `reasoning` input items). Claude
|
|
693
|
+
* signs those; any client round-trip that mutates them (or a cross-provider
|
|
694
|
+
* replay) fails with e.g.
|
|
695
|
+
* "messages.N.content.M: Invalid `data` in `redacted_thinking` block"
|
|
696
|
+
* Strip them — the UI/client already showed the thinking; replay does not
|
|
697
|
+
* need the signed blob. Same policy as appkit-mastra's serving sanitize.
|
|
698
|
+
*
|
|
699
|
+
* Returns a shallow copy when anything changes; otherwise the input body.
|
|
700
|
+
*/
|
|
701
|
+
export function sanitizeOpenResponsesInput(body: Record<string, unknown>): Record<string, unknown> {
|
|
664
702
|
if (!Array.isArray(body.input) || body.input.length === 0) return body;
|
|
665
703
|
|
|
666
704
|
let changed = false;
|
|
667
|
-
const input =
|
|
668
|
-
|
|
705
|
+
const input: unknown[] = [];
|
|
706
|
+
|
|
707
|
+
for (const raw of body.input) {
|
|
708
|
+
if (!raw || typeof raw !== "object") {
|
|
709
|
+
input.push(raw);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
669
712
|
const item = raw as Record<string, unknown>;
|
|
670
|
-
|
|
713
|
+
|
|
714
|
+
// Top-level Responses reasoning items (not message content parts).
|
|
715
|
+
if (typeof item.type === "string" && REASONING_TYPES.has(item.type)) {
|
|
716
|
+
changed = true;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (!Array.isArray(item.content)) {
|
|
721
|
+
input.push(raw);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
671
724
|
|
|
672
725
|
let partChanged = false;
|
|
673
|
-
const content =
|
|
674
|
-
|
|
726
|
+
const content: unknown[] = [];
|
|
727
|
+
for (const part of item.content) {
|
|
728
|
+
if (!part || typeof part !== "object") {
|
|
729
|
+
content.push(part);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
675
732
|
const p = part as Record<string, unknown>;
|
|
676
|
-
if (typeof p.type
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
733
|
+
if (typeof p.type === "string" && REASONING_TYPES.has(p.type)) {
|
|
734
|
+
partChanged = true;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (typeof p.type === "string" && p.type.startsWith("output_")) {
|
|
738
|
+
partChanged = true;
|
|
739
|
+
content.push({ ...p, type: `input_${p.type.slice("output_".length)}` });
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
content.push(part);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!partChanged) {
|
|
746
|
+
input.push(raw);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
681
749
|
changed = true;
|
|
682
|
-
|
|
683
|
-
|
|
750
|
+
// Drop assistant turns that only carried thinking (nothing left to replay).
|
|
751
|
+
if (content.length === 0 && item.role === "assistant") continue;
|
|
752
|
+
input.push({ ...item, content });
|
|
753
|
+
}
|
|
684
754
|
|
|
685
755
|
return changed ? { ...body, input } : body;
|
|
686
756
|
}
|
|
687
757
|
|
|
688
758
|
/**
|
|
689
|
-
* Full Open Responses request sanitizer: strip non-`function` tools
|
|
690
|
-
* `output_*`
|
|
759
|
+
* Full Open Responses request sanitizer: strip non-`function` tools, rewrite
|
|
760
|
+
* `output_*` content parts, and drop thinking / reasoning blocks. Safe no-op
|
|
761
|
+
* when nothing needs changing.
|
|
691
762
|
*/
|
|
692
763
|
export function sanitizeOpenResponsesRequest(
|
|
693
764
|
body: Record<string, unknown>,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createResponsesStreamTranslator,
|
|
7
7
|
readResponsesOutput,
|
|
8
8
|
responsesToChat,
|
|
9
|
+
sanitizeOpenResponsesRequest,
|
|
9
10
|
} from "../src/openai-responses";
|
|
10
11
|
|
|
11
12
|
/** The `messages` a lowered Responses body produced, typed for assertions. */
|
|
@@ -240,3 +241,72 @@ describe("readResponsesOutput", () => {
|
|
|
240
241
|
assert.deepEqual(readResponsesOutput({}), { text: "", citations: [] });
|
|
241
242
|
});
|
|
242
243
|
});
|
|
244
|
+
|
|
245
|
+
describe("sanitizeOpenResponsesRequest", () => {
|
|
246
|
+
it("rewrites output_text content parts to input_text", () => {
|
|
247
|
+
const out = sanitizeOpenResponsesRequest({
|
|
248
|
+
input: [
|
|
249
|
+
{
|
|
250
|
+
type: "message",
|
|
251
|
+
role: "assistant",
|
|
252
|
+
content: [{ type: "output_text", text: "hi" }],
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
assert.deepEqual(out.input, [
|
|
257
|
+
{
|
|
258
|
+
type: "message",
|
|
259
|
+
role: "assistant",
|
|
260
|
+
content: [{ type: "input_text", text: "hi" }],
|
|
261
|
+
},
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("strips redacted_thinking / thinking content parts and reasoning items", () => {
|
|
266
|
+
const out = sanitizeOpenResponsesRequest({
|
|
267
|
+
input: [
|
|
268
|
+
{ type: "reasoning", summary: [{ type: "summary_text", text: "plan" }] },
|
|
269
|
+
{
|
|
270
|
+
type: "message",
|
|
271
|
+
role: "assistant",
|
|
272
|
+
content: [
|
|
273
|
+
{ type: "redacted_thinking", data: "not-a-valid-blob" },
|
|
274
|
+
{ type: "thinking", thinking: "secret" },
|
|
275
|
+
{ type: "output_text", text: "visible" },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
assert.deepEqual(out.input, [
|
|
281
|
+
{
|
|
282
|
+
type: "message",
|
|
283
|
+
role: "assistant",
|
|
284
|
+
content: [{ type: "input_text", text: "visible" }],
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("drops assistant turns that only carried thinking", () => {
|
|
290
|
+
const out = sanitizeOpenResponsesRequest({
|
|
291
|
+
input: [
|
|
292
|
+
{
|
|
293
|
+
type: "message",
|
|
294
|
+
role: "assistant",
|
|
295
|
+
content: [{ type: "redacted_thinking", data: "x" }],
|
|
296
|
+
},
|
|
297
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "next" }] },
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
assert.deepEqual(out.input, [
|
|
301
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "next" }] },
|
|
302
|
+
]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("strips non-function tools", () => {
|
|
306
|
+
const out = sanitizeOpenResponsesRequest({
|
|
307
|
+
input: [],
|
|
308
|
+
tools: [{ type: "web_search" }, { type: "function", name: "f", parameters: {} }],
|
|
309
|
+
});
|
|
310
|
+
assert.deepEqual(out.tools, [{ type: "function", name: "f", parameters: {} }]);
|
|
311
|
+
});
|
|
312
|
+
});
|