@dbx-tools/appkit-mastra 0.3.36 → 0.3.37

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
@@ -468,7 +468,9 @@ client that talks to these routes.
468
468
  defaults, and approval-gated tool inspection.
469
469
  - `config` - plugin config types and RequestContext key constants.
470
470
  - `model` / `serving` / `servingSanitize` - Mastra model config, request
471
- overrides, serving-endpoint config, and request-body cleanup.
471
+ overrides, serving-endpoint config, and the on-the-wire request/response
472
+ cleanup that keeps provider-specific payload quirks (Claude's replayed
473
+ thinking blocks, Gemini's content-parts responses) from failing a turn.
472
474
  - `genie` - Genie prompt, space normalization, Genie toolkits, and suggestions.
473
475
  - `chart` / `statement` / `writer` - chart cache, statement row fetches, and
474
476
  safe writer events.
package/package.json CHANGED
@@ -28,21 +28,21 @@
28
28
  "express": "^5.1.0",
29
29
  "pg": "^8.22.0",
30
30
  "zod": "4.3.6",
31
- "@dbx-tools/appkit": "0.3.36",
32
- "@dbx-tools/core": "0.3.36",
33
- "@dbx-tools/genie": "0.3.36",
34
- "@dbx-tools/model": "0.3.36",
35
- "@dbx-tools/shared-core": "0.3.36",
36
- "@dbx-tools/shared-genie": "0.3.36",
37
- "@dbx-tools/shared-mastra": "0.3.36",
38
- "@dbx-tools/shared-model": "0.3.36"
31
+ "@dbx-tools/appkit": "0.3.37",
32
+ "@dbx-tools/genie": "0.3.37",
33
+ "@dbx-tools/model": "0.3.37",
34
+ "@dbx-tools/shared-core": "0.3.37",
35
+ "@dbx-tools/core": "0.3.37",
36
+ "@dbx-tools/shared-genie": "0.3.37",
37
+ "@dbx-tools/shared-model": "0.3.37",
38
+ "@dbx-tools/shared-mastra": "0.3.37"
39
39
  },
40
40
  "main": "index.ts",
41
41
  "license": "UNLICENSED",
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "version": "0.3.36",
45
+ "version": "0.3.37",
46
46
  "types": "index.ts",
47
47
  "type": "module",
48
48
  "exports": {
package/src/model.ts CHANGED
@@ -32,7 +32,7 @@ import type { RequestContext } from "@mastra/core/request-context";
32
32
 
33
33
  import { MASTRA_USER_KEY, type MastraPluginConfig, type User } from "./config";
34
34
  import { MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving";
35
- import { rewriteServingBody } from "./serving-sanitize";
35
+ import { rewriteServingBody, rewriteServingResponseBody } from "./serving-sanitize";
36
36
 
37
37
  type ModelClass = model.ModelClass;
38
38
  const { parseModelClass } = classes;
@@ -140,6 +140,10 @@ const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
140
140
  * 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
141
141
  * 4xx debugging doesn't have to fight AI SDK's `[Array]`
142
142
  * 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}).
143
147
  *
144
148
  * Safe to call from any hot path: {@link functionModule.memoize} ensures
145
149
  * the wrapper is installed at most once per process, so subsequent
@@ -169,6 +173,38 @@ const setupFetchInterceptor = functionModule.memoize((): void => {
169
173
  ? { url: url.toString(), bodyType: "non-JSON" }
170
174
  : { url: url.toString(), body: parsed },
171
175
  );
172
- return original(input, init);
176
+ const response = await original(input, init);
177
+ return repairServingResponse(response);
173
178
  }) as typeof globalThis.fetch;
174
179
  });
180
+
181
+ /**
182
+ * Rewrite a non-streaming serving response whose body needs repair, leaving
183
+ * everything else byte-identical.
184
+ *
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.
191
+ */
192
+ async function repairServingResponse(response: Response): Promise<Response> {
193
+ const contentType = response.headers.get("content-type") ?? "";
194
+ if (!contentType.includes("application/json")) return response;
195
+
196
+ const body = await response.clone().text();
197
+ const rewritten = rewriteServingResponseBody(body);
198
+ if (rewritten === body) return response;
199
+
200
+ // `content-length` / `content-encoding` describe the ORIGINAL bytes, so they
201
+ // are dropped: the rewritten body is a different length and already decoded.
202
+ const headers = new Headers(response.headers);
203
+ headers.delete("content-length");
204
+ headers.delete("content-encoding");
205
+ return new Response(rewritten, {
206
+ status: response.status,
207
+ statusText: response.statusText,
208
+ headers,
209
+ });
210
+ }
@@ -1,14 +1,20 @@
1
1
  /**
2
- * Repairs Mastra / AI SDK message replays sent to Databricks Model
3
- * Serving before they hit the OpenAI-compatible `/chat/completions`
4
- * route.
2
+ * Repairs traffic between Mastra / the AI SDK and Databricks Model Serving's
3
+ * OpenAI-compatible `/chat/completions` route, in both directions.
5
4
  *
6
- * Exists because the transcript Mastra persists is not always a transcript the
7
- * provider will accept back: Databricks-hosted Claude rejects replayed
8
- * extended-thinking blocks and reads a trailing assistant message as a prefill
9
- * request. Both repairs are provider quirks, not schema violations, so they are
10
- * applied on the wire (see the `globalThis.fetch` wrapper in `model.ts`) rather
11
- * than by changing what the agent stores or what the UI shows.
5
+ * Outbound ({@link rewriteServingBody}), because the transcript Mastra
6
+ * persists is not always a transcript the provider will accept back:
7
+ * Databricks-hosted Claude rejects replayed extended-thinking blocks and reads
8
+ * a trailing assistant message as a prefill request.
9
+ *
10
+ * Inbound ({@link rewriteServingResponseBody}), because Databricks-hosted
11
+ * Gemini answers with its native content-parts array where the OpenAI contract
12
+ * (and therefore the AI SDK's response schema) requires a plain string.
13
+ *
14
+ * Every repair here is a provider quirk rather than a schema violation, so all
15
+ * of them are applied on the wire (see the `globalThis.fetch` wrapper in
16
+ * `model.ts`) rather than by changing what the agent stores or what the UI
17
+ * shows.
12
18
  *
13
19
  * @module
14
20
  */
@@ -186,3 +192,56 @@ function isEmptyServingContent(content: ServingChatMessage["content"]): boolean
186
192
  return false;
187
193
  });
188
194
  }
195
+
196
+ /**
197
+ * Parse, sanitize, and re-serialize a `/serving-endpoints/...` non-streaming
198
+ * JSON RESPONSE body. Returns the original string verbatim when the body is
199
+ * not JSON or no rewrite was needed, mirroring
200
+ * {@link rewriteServingBody} on the request side.
201
+ */
202
+ export function rewriteServingResponseBody(body: string): string {
203
+ const parsed = json.parseRecord(body);
204
+ if (!parsed) return body;
205
+ return flattenChoiceMessageContent(parsed) ? JSON.stringify(parsed) : body;
206
+ }
207
+
208
+ /**
209
+ * Collapse a structured `choices[].message.content` array to the plain string
210
+ * the OpenAI Chat Completions contract specifies.
211
+ *
212
+ * Databricks-hosted Gemini answers a non-streaming `/chat/completions` call
213
+ * with the Gemini-native parts shape:
214
+ *
215
+ * ```json
216
+ * "content": [{ "type": "text", "text": "...", "thoughtSignature": "..." }]
217
+ * ```
218
+ *
219
+ * `@ai-sdk/openai-compatible` validates the response against the OpenAI
220
+ * schema, where `content` is a nullable string, so it throws
221
+ * `Type validation failed: ... expected string, received array` and the whole
222
+ * call fails (`AI_APICallError: Invalid JSON response` on an HTTP 200). Mastra
223
+ * uses `doGenerate` for its side calls, so the visible symptom is
224
+ * `Error generating title` - every thread keeps its placeholder name.
225
+ *
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
228
+ * 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.
233
+ */
234
+ export function flattenChoiceMessageContent(payload: Record<string, unknown>): boolean {
235
+ if (!Array.isArray(payload.choices)) return false;
236
+ let changed = false;
237
+ for (const choice of payload.choices) {
238
+ 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"] });
244
+ changed = true;
245
+ }
246
+ return changed;
247
+ }
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import { rewriteServingResponseBody } from "../src/serving-sanitize";
5
+
6
+ /**
7
+ * Trimmed copy of a real Databricks-hosted Gemini reply: `content` is the
8
+ * Gemini-native parts array, and the text part also carries the signed
9
+ * `thoughtSignature` the model returns alongside it.
10
+ */
11
+ const geminiPartsResponse = {
12
+ model: "gemini-3.1-flash-lite",
13
+ choices: [
14
+ {
15
+ message: {
16
+ role: "assistant",
17
+ content: [
18
+ { type: "text", text: "Fuel Mart Weekly Sales Summary", thoughtSignature: "AY89a18" },
19
+ ],
20
+ },
21
+ index: 0,
22
+ finish_reason: "stop",
23
+ },
24
+ ],
25
+ usage: { prompt_tokens: 2098, completion_tokens: 5, total_tokens: 2201 },
26
+ object: "chat.completion",
27
+ };
28
+
29
+ describe("serving response sanitize", () => {
30
+ it("flattens Gemini's content parts to the string the AI SDK expects", () => {
31
+ const result = JSON.parse(rewriteServingResponseBody(JSON.stringify(geminiPartsResponse)));
32
+ assert.equal(result.choices[0].message.content, "Fuel Mart Weekly Sales Summary");
33
+ });
34
+
35
+ it("preserves every sibling field while rewriting content", () => {
36
+ const result = JSON.parse(rewriteServingResponseBody(JSON.stringify(geminiPartsResponse)));
37
+ assert.equal(result.choices[0].finish_reason, "stop");
38
+ assert.equal(result.choices[0].message.role, "assistant");
39
+ assert.deepEqual(result.usage, geminiPartsResponse.usage);
40
+ assert.equal(result.model, "gemini-3.1-flash-lite");
41
+ });
42
+
43
+ it("joins multiple text parts and ignores non-text ones", () => {
44
+ const body = JSON.stringify({
45
+ choices: [
46
+ {
47
+ message: {
48
+ content: [
49
+ { type: "text", text: "Fuel Mart " },
50
+ { type: "image", image_url: "https://example.invalid/x.png" },
51
+ { type: "text", text: "Sales" },
52
+ ],
53
+ },
54
+ },
55
+ ],
56
+ });
57
+ const result = JSON.parse(rewriteServingResponseBody(body));
58
+ assert.equal(result.choices[0].message.content, "Fuel Mart Sales");
59
+ });
60
+
61
+ it("flattens a parts array holding no text to an empty string", () => {
62
+ const body = JSON.stringify({
63
+ choices: [{ message: { content: [{ thoughtSignature: "AY89" }] }, finish_reason: "stop" }],
64
+ });
65
+ const result = JSON.parse(rewriteServingResponseBody(body));
66
+ assert.equal(result.choices[0].message.content, "");
67
+ assert.equal(result.choices[0].finish_reason, "stop");
68
+ });
69
+
70
+ it("returns a compliant OpenAI response byte-identical", () => {
71
+ const body = JSON.stringify({
72
+ choices: [{ message: { role: "assistant", content: "already a string" } }],
73
+ });
74
+ assert.equal(rewriteServingResponseBody(body), body);
75
+ });
76
+
77
+ it("leaves a non-JSON or choice-less body untouched", () => {
78
+ assert.equal(rewriteServingResponseBody("not json"), "not json");
79
+ assert.equal(
80
+ rewriteServingResponseBody('{"error_code":"BAD_REQUEST"}'),
81
+ '{"error_code":"BAD_REQUEST"}',
82
+ );
83
+ });
84
+
85
+ it("repairs every choice when the provider returns more than one", () => {
86
+ const body = JSON.stringify({
87
+ choices: [
88
+ { message: { content: [{ type: "text", text: "first" }] } },
89
+ { message: { content: [{ type: "text", text: "second" }] } },
90
+ ],
91
+ });
92
+ const result = JSON.parse(rewriteServingResponseBody(body));
93
+ assert.deepEqual(
94
+ result.choices.map((c: { message: { content: string } }) => c.message.content),
95
+ ["first", "second"],
96
+ );
97
+ });
98
+ });