@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/README.md +13 -0
- package/index.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -2
- package/lib/src/config.d.ts +7 -0
- package/lib/src/config.js +5 -1
- package/lib/src/genie.js +20 -13
- package/lib/src/model.js +19 -15
- package/lib/src/serving-sanitize.d.ts +21 -8
- package/lib/src/serving-sanitize.js +74 -19
- package/package.json +13 -13
- package/src/config.ts +12 -0
- package/src/genie.ts +25 -13
- package/src/model.ts +25 -14
- package/src/serving-sanitize.ts +83 -18
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 {
|
|
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
|
|
144
|
-
*
|
|
145
|
-
* the AI SDK's OpenAI schema rejects
|
|
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
|
|
183
|
-
*
|
|
185
|
+
* Rewrite a serving response whose body needs repair, leaving unsupported
|
|
186
|
+
* content types byte-identical.
|
|
184
187
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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(
|
|
216
|
+
return new Response(body, {
|
|
206
217
|
status: response.status,
|
|
207
218
|
statusText: response.statusText,
|
|
208
219
|
headers,
|
package/src/serving-sanitize.ts
CHANGED
|
@@ -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}
|
|
11
|
-
*
|
|
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
|
-
|
|
101
|
-
|
|
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 !==
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
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,
|
|
230
|
-
* nothing, matching {@link openaiChat.chatContentToText},
|
|
231
|
-
* message flattens to `""` rather than being dropped, so
|
|
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
|
|
240
|
-
if (!
|
|
241
|
-
const target =
|
|
242
|
-
|
|
243
|
-
|
|
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;
|