@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 +3 -1
- package/package.json +9 -9
- package/src/model.ts +38 -2
- package/src/serving-sanitize.ts +68 -9
- package/test/serving-sanitize.test.ts +98 -0
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
|
|
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.
|
|
32
|
-
"@dbx-tools/
|
|
33
|
-
"@dbx-tools/
|
|
34
|
-
"@dbx-tools/
|
|
35
|
-
"@dbx-tools/
|
|
36
|
-
"@dbx-tools/shared-genie": "0.3.
|
|
37
|
-
"@dbx-tools/shared-
|
|
38
|
-
"@dbx-tools/shared-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/serving-sanitize.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Repairs Mastra / AI SDK
|
|
3
|
-
*
|
|
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
|
-
*
|
|
7
|
-
* provider will accept back:
|
|
8
|
-
* extended-thinking blocks and reads
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
+
});
|