@dbx-tools/appkit-mastra 0.6.212 → 0.6.214

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/src/model.ts CHANGED
@@ -31,7 +31,11 @@ import type { MastraModelConfig } from "@mastra/core/llm";
31
31
  import type { RequestContext } from "@mastra/core/request-context";
32
32
 
33
33
  import { MASTRA_USER_KEY, type MastraPluginConfig, type User } from "./config.ts";
34
- import { rewriteServingBody, rewriteServingResponseBody } from "./serving-sanitize.ts";
34
+ import {
35
+ rewriteServingBody,
36
+ rewriteServingResponseBody,
37
+ rewriteServingResponseStream,
38
+ } from "./serving-sanitize.ts";
35
39
  import { MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving.ts";
36
40
 
37
41
  type ModelClass = model.ModelClass;
@@ -140,10 +144,9 @@ const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
140
144
  * 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
141
145
  * 4xx debugging doesn't have to fight AI SDK's `[Array]`
142
146
  * formatter.
143
- * 3. Repairs the non-streaming JSON response, where Databricks-hosted
144
- * Gemini returns `choices[].message.content` as a parts array that
145
- * the AI SDK's OpenAI schema rejects (see
146
- * {@link rewriteServingResponseBody}).
147
+ * 3. Repairs buffered and streaming responses where a provider returns
148
+ * `choices[].message.content` or `choices[].delta.content` as a parts
149
+ * array that the AI SDK's OpenAI schema rejects.
147
150
  *
148
151
  * Safe to call from any hot path: {@link functionModule.memoize} ensures
149
152
  * the wrapper is installed at most once per process, so subsequent
@@ -179,30 +182,38 @@ const setupFetchInterceptor = functionModule.memoize((): void => {
179
182
  });
180
183
 
181
184
  /**
182
- * Rewrite a non-streaming serving response whose body needs repair, leaving
183
- * everything else byte-identical.
185
+ * Rewrite a serving response whose body needs repair, leaving unsupported
186
+ * content types byte-identical.
184
187
  *
185
- * Streaming turns are passed straight through: an SSE body must stay a live
186
- * stream (buffering it to a string would defeat streaming and break
187
- * `text/event-stream` parsing), and the delta frames already carry string
188
- * content, so they never hit the array-shaped `content` bug. Likewise a
189
- * non-JSON or error body is returned untouched, so the caller still sees the
190
- * original status and headers.
188
+ * SSE remains a live stream: a TransformStream repairs complete `data:` lines
189
+ * as bytes arrive and preserves partial lines across network chunks. JSON is
190
+ * buffered through the existing response-body repair. A non-JSON/non-SSE body
191
+ * is returned untouched so the caller still sees the original response.
191
192
  */
192
193
  async function repairServingResponse(response: Response): Promise<Response> {
193
194
  const contentType = response.headers.get("content-type") ?? "";
195
+ if (contentType.includes("text/event-stream") && response.body) {
196
+ return rebuildServingResponse(response, rewriteServingResponseStream(response.body));
197
+ }
194
198
  if (!contentType.includes("application/json")) return response;
195
199
 
196
200
  const body = await response.clone().text();
197
201
  const rewritten = rewriteServingResponseBody(body);
198
202
  if (rewritten === body) return response;
203
+ return rebuildServingResponse(response, rewritten);
204
+ }
199
205
 
206
+ /** Rebuild a transformed response without stale byte-length/encoding headers. */
207
+ function rebuildServingResponse(
208
+ response: Response,
209
+ body: string | ReadableStream<Uint8Array>,
210
+ ): Response {
200
211
  // `content-length` / `content-encoding` describe the ORIGINAL bytes, so they
201
212
  // are dropped: the rewritten body is a different length and already decoded.
202
213
  const headers = new Headers(response.headers);
203
214
  headers.delete("content-length");
204
215
  headers.delete("content-encoding");
205
- return new Response(rewritten, {
216
+ return new Response(body, {
206
217
  status: response.status,
207
218
  statusText: response.statusText,
208
219
  headers,
@@ -7,8 +7,9 @@
7
7
  * Databricks-hosted Claude rejects replayed extended-thinking blocks and reads
8
8
  * a trailing assistant message as a prefill request.
9
9
  *
10
- * Inbound ({@link rewriteServingResponseBody}), because Databricks-hosted
11
- * Gemini answers with its native content-parts array where the OpenAI contract
10
+ * Inbound ({@link rewriteServingResponseBody} and
11
+ * {@link rewriteServingResponseStream}), because Databricks-hosted Gemini and
12
+ * Claude can answer with native content-parts arrays where the OpenAI contract
12
13
  * (and therefore the AI SDK's response schema) requires a plain string.
13
14
  *
14
15
  * Every repair here is a provider quirk rather than a schema violation, so all
@@ -97,8 +98,9 @@ export function stripReasoningFromServingMessages(messages: ServingChatMessage[]
97
98
  delete msg.reasoning_content;
98
99
  changed = true;
99
100
  }
100
- if (!Array.isArray(msg.content)) continue;
101
- const filtered = msg.content.filter((part) => {
101
+ const parts = openaiChat.chatContentParts(msg.content);
102
+ if (!parts) continue;
103
+ const filtered = parts.filter((part) => {
102
104
  const type = part?.type;
103
105
  if (typeof type === "string" && REASONING_PART_TYPES.has(type)) {
104
106
  changed = true;
@@ -106,7 +108,7 @@ export function stripReasoningFromServingMessages(messages: ServingChatMessage[]
106
108
  }
107
109
  return true;
108
110
  });
109
- if (filtered.length !== msg.content.length) {
111
+ if (filtered.length !== parts.length) {
110
112
  msg.content = filtered;
111
113
  }
112
114
  const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
@@ -184,8 +186,9 @@ function textFromServingContent(content: ServingChatMessage["content"]): string
184
186
  function isEmptyServingContent(content: ServingChatMessage["content"]): boolean {
185
187
  if (content === undefined) return true;
186
188
  if (typeof content === "string") return content.trim().length === 0;
187
- if (!Array.isArray(content)) return true;
188
- return content.every((part) => {
189
+ const parts = openaiChat.chatContentParts(content);
190
+ if (!parts) return true;
191
+ return parts.every((part) => {
189
192
  if (part?.type === "text") {
190
193
  return typeof part.text !== "string" || part.text.trim().length === 0;
191
194
  }
@@ -205,6 +208,51 @@ export function rewriteServingResponseBody(body: string): string {
205
208
  return flattenChoiceMessageContent(parsed) ? JSON.stringify(parsed) : body;
206
209
  }
207
210
 
211
+ /**
212
+ * Repair array-valued `choices[].delta.content` inside an OpenAI SSE stream
213
+ * without buffering the response. The decoder retains partial lines across
214
+ * arbitrary network chunk boundaries; each complete `data:` line is parsed and
215
+ * re-encoded only when its content needs normalization.
216
+ */
217
+ export function rewriteServingResponseStream(
218
+ body: ReadableStream<Uint8Array>,
219
+ ): ReadableStream<Uint8Array> {
220
+ const decoder = new TextDecoder();
221
+ const encoder = new TextEncoder();
222
+ let buffer = "";
223
+
224
+ return body.pipeThrough(
225
+ new TransformStream<Uint8Array, Uint8Array>({
226
+ transform(chunk, controller) {
227
+ buffer += decoder.decode(chunk, { stream: true });
228
+ let newline = buffer.indexOf("\n");
229
+ while (newline >= 0) {
230
+ const line = buffer.slice(0, newline + 1);
231
+ buffer = buffer.slice(newline + 1);
232
+ controller.enqueue(encoder.encode(rewriteServingResponseStreamLine(line)));
233
+ newline = buffer.indexOf("\n");
234
+ }
235
+ },
236
+ flush(controller) {
237
+ buffer += decoder.decode();
238
+ if (buffer) controller.enqueue(encoder.encode(rewriteServingResponseStreamLine(buffer)));
239
+ },
240
+ }),
241
+ );
242
+ }
243
+
244
+ /** Normalize one SSE line while preserving its `data:` spacing and line ending. */
245
+ function rewriteServingResponseStreamLine(line: string): string {
246
+ const lineEnding = line.endsWith("\r\n") ? "\r\n" : line.endsWith("\n") ? "\n" : "";
247
+ const content = lineEnding ? line.slice(0, -lineEnding.length) : line;
248
+ const match = /^(data:\s*)(.*)$/.exec(content);
249
+ if (!match || match[2] === "[DONE]") return line;
250
+
251
+ const parsed = json.parseRecord(match[2]);
252
+ if (!parsed || !flattenChoiceDeltaContent(parsed)) return line;
253
+ return `${match[1]}${JSON.stringify(parsed)}${lineEnding}`;
254
+ }
255
+
208
256
  /**
209
257
  * Collapse a structured `choices[].message.content` array to the plain string
210
258
  * the OpenAI Chat Completions contract specifies.
@@ -223,24 +271,41 @@ export function rewriteServingResponseBody(body: string): string {
223
271
  * uses `doGenerate` for its side calls, so the visible symptom is
224
272
  * `Error generating title` - every thread keeps its placeholder name.
225
273
  *
226
- * Flattening on the wire keeps the repair in one place: the streaming path is
227
- * unaffected (deltas already carry string content), and neither the agent's
274
+ * Flattening on the wire keeps the repair in one place: neither the agent's
228
275
  * stored transcript nor the UI has to know the provider emitted parts. Any
229
- * non-text part (a `thoughtSignature`-only entry, an inline image) contributes
230
- * nothing, matching {@link openaiChat.chatContentToText}, and an all-parts-empty
231
- * message flattens to `""` rather than being dropped, so `finish_reason` and
232
- * `usage` still round-trip.
276
+ * non-text part (a reasoning block, `thoughtSignature`-only entry, or inline
277
+ * image) contributes nothing, matching {@link openaiChat.chatContentToText},
278
+ * and an all-parts-empty message flattens to `""` rather than being dropped, so
279
+ * `finish_reason` and `usage` still round-trip.
233
280
  */
234
281
  export function flattenChoiceMessageContent(payload: Record<string, unknown>): boolean {
282
+ return flattenChoiceContent(payload, "message");
283
+ }
284
+
285
+ /**
286
+ * Collapse structured streaming `choices[].delta.content` arrays to strings.
287
+ * Claude reasoning-only chunks become an empty content delta while text parts
288
+ * are concatenated in order.
289
+ */
290
+ export function flattenChoiceDeltaContent(payload: Record<string, unknown>): boolean {
291
+ return flattenChoiceContent(payload, "delta");
292
+ }
293
+
294
+ /** Shared choice walker for buffered `message` and streaming `delta` payloads. */
295
+ function flattenChoiceContent(
296
+ payload: Record<string, unknown>,
297
+ field: "message" | "delta",
298
+ ): boolean {
235
299
  if (!Array.isArray(payload.choices)) return false;
236
300
  let changed = false;
237
301
  for (const choice of payload.choices) {
238
302
  if (!choice || typeof choice !== "object") continue;
239
- const message = (choice as { message?: unknown }).message;
240
- if (!message || typeof message !== "object") continue;
241
- const target = message as { content?: unknown };
242
- if (!Array.isArray(target.content)) continue;
243
- target.content = openaiChat.chatContentToText(target.content, { types: ["text"] });
303
+ const container = (choice as Record<string, unknown>)[field];
304
+ if (!container || typeof container !== "object") continue;
305
+ const target = container as { content?: unknown };
306
+ const parts = openaiChat.chatContentParts(target.content);
307
+ if (!parts) continue;
308
+ target.content = openaiChat.chatContentToText(parts, { types: ["text"] });
244
309
  changed = true;
245
310
  }
246
311
  return changed;