@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 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, in both directions.
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.27"
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.27",
22
+ "version": "0.3.29",
23
23
  "types": "index.ts",
24
24
  "type": "module",
25
25
  "exports": {
@@ -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_*` (Open Responses rejects `output_text` in history).
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 { type ChatMessage, chatContentToText } from "./openai-chat";
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((t) => {
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 ((role === "system" || role === "developer") && instructions === undefined && input.length === 0) {
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 (role === "assistant" && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
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
- const call = rawCall as Record<string, unknown>;
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((t) => {
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
- const call = raw as Record<string, unknown>;
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 = str(part.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 = str(ann.url);
624
+ const url = string.trimToEmpty(ann.url);
600
625
  if (!url || seen.has(url)) continue;
601
626
  seen.add(url);
602
- const title = str(ann.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: str(payload.output_text) || texts.join("\n").trim(), citations };
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
- * Rewrite Responses `input` content parts so Open Responses will accept them.
668
+ * Content-part / input-item types Claude extended thinking uses on the wire.
651
669
  *
652
- * OpenAI `/responses` (and Codex) replay prior assistant turns with
653
- * `output_text` / `output_*` content part types. Databricks Open Responses
654
- * (Claude, Gemini, …) only allows input-side types:
655
- * "Open Responses input content part type 'output_text' is not supported.
656
- * Supported: input_text, input_image, input_file, input_audio."
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 function sanitizeOpenResponsesInput(
662
- body: Record<string, unknown>,
663
- ): Record<string, unknown> {
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 = body.input.map((raw) => {
668
- if (!raw || typeof raw !== "object") return raw;
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
- if (!Array.isArray(item.content)) return raw;
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 = item.content.map((part) => {
674
- if (!part || typeof part !== "object") return part;
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 !== "string" || !p.type.startsWith("output_")) return part;
677
- partChanged = true;
678
- return { ...p, type: `input_${p.type.slice("output_".length)}` };
679
- });
680
- if (!partChanged) return raw;
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
- return { ...item, content };
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 and rewrite
690
- * `output_*` input content parts. Safe no-op when nothing needs changing.
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
+ });