@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 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.28"
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.28",
22
+ "version": "0.3.29",
23
23
  "types": "index.ts",
24
24
  "type": "module",
25
25
  "exports": {
@@ -42,7 +42,9 @@
42
42
  * @module
43
43
  */
44
44
 
45
- 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";
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((t) => {
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 ((role === "system" || role === "developer") && instructions === undefined && input.length === 0) {
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 (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
+ ) {
190
173
  for (const rawCall of message.tool_calls) {
191
174
  if (!rawCall || typeof rawCall !== "object") continue;
192
- const call = rawCall as Record<string, unknown>;
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((t) => {
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
- const call = raw as Record<string, unknown>;
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 = str(part.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 = str(ann.url);
624
+ const url = string.trimToEmpty(ann.url);
601
625
  if (!url || seen.has(url)) continue;
602
626
  seen.add(url);
603
- const title = str(ann.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: str(payload.output_text) || texts.join("\n").trim(), citations };
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
- /** Content part / input item types Claude extended-thinking uses on the wire. */
651
- const REASONING_TYPES = new Set(["reasoning", "thinking", "redacted_thinking"]);
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;