@dbx-tools/appkit-mastra 0.3.37 → 0.3.40
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 +27 -0
- package/package.json +14 -10
- package/src/plugin.ts +25 -1
- package/src/server.ts +74 -30
- package/test/chart.test.ts +0 -63
- package/test/config.test.ts +0 -64
- package/test/serving-sanitize.test.ts +0 -98
- package/test/tsconfig.json +0 -14
- package/tsconfig.json +0 -41
package/README.md
CHANGED
|
@@ -388,6 +388,33 @@ plugin.mastra({
|
|
|
388
388
|
Use `mcp: false` to disable MCP. Turn on `tools: true` only for ambient tools
|
|
389
389
|
that are safe outside an in-process chat turn.
|
|
390
390
|
|
|
391
|
+
## Driving A Turn From Outside The Routes
|
|
392
|
+
|
|
393
|
+
Another plugin (or a scheduled job) can run an agent turn directly, but a raw
|
|
394
|
+
`agent.generate(prompt)` loses everything the HTTP middleware stamps - most
|
|
395
|
+
visibly the AppKit user, which every user-scoped tool reads. `ask_genie` then
|
|
396
|
+
fails with "invoke the tool from an agent turn served by the mastra plugin", so
|
|
397
|
+
the turn answers "the data source is unreachable" where the chat routes answer
|
|
398
|
+
with real data.
|
|
399
|
+
|
|
400
|
+
`exports().createRequestContext()` builds the missing context:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const mastra = context.getPlugins()?.get("mastra")?.exports();
|
|
404
|
+
const requestContext = await mastra.createRequestContext({
|
|
405
|
+
threadId: conversationId,
|
|
406
|
+
resourceId: userId,
|
|
407
|
+
});
|
|
408
|
+
const result = await mastra.getDefault().generate(prompt, { requestContext });
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The AppKit user, the memory thread / resource pair, and a request id (so the
|
|
412
|
+
turn's spans join up in traces) are stamped exactly as the request middleware
|
|
413
|
+
stamps them. Call it inside an `asUser(req)` scope to inherit the caller's OBO
|
|
414
|
+
identity; outside one it resolves to the service principal.
|
|
415
|
+
[`@dbx-tools/teams`](../teams) uses this so a Teams card turn has the same tool
|
|
416
|
+
reach - and therefore the same answer - as a chat turn.
|
|
417
|
+
|
|
391
418
|
## API Gate
|
|
392
419
|
|
|
393
420
|
The stock `@mastra/express` app has broad management routes. The plugin's
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"repository": {
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "git+https://github.com/reggie-db/dbx-tools.git",
|
|
6
|
-
"directory": "
|
|
6
|
+
"directory": "packages/node/appkit-mastra"
|
|
7
7
|
},
|
|
8
8
|
"devDependencies": {
|
|
9
9
|
"@types/express": "^5.0.5",
|
|
@@ -28,27 +28,31 @@
|
|
|
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-model": "0.3.
|
|
38
|
-
"@dbx-tools/shared-
|
|
31
|
+
"@dbx-tools/appkit": "0.3.40",
|
|
32
|
+
"@dbx-tools/model": "0.3.40",
|
|
33
|
+
"@dbx-tools/genie": "0.3.40",
|
|
34
|
+
"@dbx-tools/core": "0.3.40",
|
|
35
|
+
"@dbx-tools/shared-mastra": "0.3.40",
|
|
36
|
+
"@dbx-tools/shared-genie": "0.3.40",
|
|
37
|
+
"@dbx-tools/shared-model": "0.3.40",
|
|
38
|
+
"@dbx-tools/shared-core": "0.3.40"
|
|
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.40",
|
|
46
46
|
"types": "index.ts",
|
|
47
47
|
"type": "module",
|
|
48
48
|
"exports": {
|
|
49
49
|
".": "./index.ts",
|
|
50
50
|
"./package.json": "./package.json"
|
|
51
51
|
},
|
|
52
|
+
"files": [
|
|
53
|
+
"index.ts",
|
|
54
|
+
"src"
|
|
55
|
+
],
|
|
52
56
|
"dbxToolsConfig": {
|
|
53
57
|
"tags": [
|
|
54
58
|
"node"
|
package/src/plugin.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
import { display, type ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
71
71
|
import type { Agent } from "@mastra/core/agent";
|
|
72
72
|
import { Mastra } from "@mastra/core/mastra";
|
|
73
|
+
import type { RequestContext } from "@mastra/core/request-context";
|
|
73
74
|
import express from "express";
|
|
74
75
|
import type { Pool } from "pg";
|
|
75
76
|
|
|
@@ -89,7 +90,12 @@ import { buildMcpServer, type ResolvedMcp } from "./mcp";
|
|
|
89
90
|
import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory";
|
|
90
91
|
import { logFeedback, resolveFeedbackEnabled } from "./mlflow";
|
|
91
92
|
import { buildObservability } from "./observability";
|
|
92
|
-
import {
|
|
93
|
+
import {
|
|
94
|
+
attachRoutePatchMiddleware,
|
|
95
|
+
createRequestContext,
|
|
96
|
+
isMastraRequestAllowed,
|
|
97
|
+
MastraServer,
|
|
98
|
+
} from "./server";
|
|
93
99
|
import { resolveServingConfig } from "./serving";
|
|
94
100
|
import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement";
|
|
95
101
|
import { threadsRoute } from "./threads";
|
|
@@ -303,6 +309,24 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
303
309
|
(this.built && this.built.agents[this.built.defaultAgentId]) ?? null,
|
|
304
310
|
/** Underlying Mastra instance for advanced use (custom routes etc.). */
|
|
305
311
|
getMastra: () => this.mastra,
|
|
312
|
+
/**
|
|
313
|
+
* Build the `RequestContext` an agent turn driven from OUTSIDE this
|
|
314
|
+
* plugin's routes needs (a Teams activity on another plugin's endpoint, a
|
|
315
|
+
* scheduled job).
|
|
316
|
+
*
|
|
317
|
+
* Mastra's user-scoped tools - `ask_genie` most visibly - read the AppKit
|
|
318
|
+
* user off the request context, which only the HTTP middleware stamps.
|
|
319
|
+
* Calling `agent.generate` with a raw prompt therefore answers "the data
|
|
320
|
+
* source is unreachable" where the chat routes answer with real data.
|
|
321
|
+
* Passing this as `requestContext` closes that gap, so an out-of-band turn
|
|
322
|
+
* has exactly the capabilities a chat turn has.
|
|
323
|
+
*
|
|
324
|
+
* Must be called INSIDE an `asUser(req)` scope to inherit the caller's
|
|
325
|
+
* OBO identity; outside one it resolves to the service principal.
|
|
326
|
+
*/
|
|
327
|
+
createRequestContext: (
|
|
328
|
+
options: { threadId?: string; resourceId?: string; requestId?: string } = {},
|
|
329
|
+
): Promise<RequestContext> => createRequestContext(options),
|
|
306
330
|
/**
|
|
307
331
|
* MCP endpoint info when `config.mcp` is enabled, else `null`.
|
|
308
332
|
* Streamable HTTP is `http`; the SSE pair is the legacy transport.
|
package/src/server.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { feedback, thread } from "@dbx-tools/shared-mastra";
|
|
|
13
13
|
import {
|
|
14
14
|
MASTRA_RESOURCE_ID_KEY,
|
|
15
15
|
MASTRA_THREAD_ID_KEY,
|
|
16
|
-
|
|
16
|
+
RequestContext,
|
|
17
17
|
} from "@mastra/core/request-context";
|
|
18
18
|
import { MastraServer as MastraServerExpress } from "@mastra/express";
|
|
19
19
|
import { trace } from "@opentelemetry/api";
|
|
@@ -40,6 +40,78 @@ import { extractModelOverride, MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig }
|
|
|
40
40
|
*/
|
|
41
41
|
const INVALID_TRACE_ID = "0".repeat(32);
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Stamp the AppKit user (plus the resource id and trace metadata) onto
|
|
45
|
+
* `requestContext`.
|
|
46
|
+
*
|
|
47
|
+
* Split out of {@link MastraServer} because the HTTP middleware is not the only
|
|
48
|
+
* caller that needs it: an out-of-band turn (a Teams activity arriving on
|
|
49
|
+
* another plugin's route, a scheduled job) drives `agent.generate` directly, and
|
|
50
|
+
* without this stamp every user-scoped tool - `ask_genie` above all - fails with
|
|
51
|
+
* "invoke the tool from an agent turn served by the mastra plugin". Those
|
|
52
|
+
* callers build a context with {@link createRequestContext} instead of a
|
|
53
|
+
* request.
|
|
54
|
+
*
|
|
55
|
+
* Idempotent: returns immediately when the user and resource id are already
|
|
56
|
+
* present, so the middleware can call it over a context another layer stamped.
|
|
57
|
+
*/
|
|
58
|
+
export async function stampRequestContextUser(requestContext: RequestContext): Promise<void> {
|
|
59
|
+
if ([MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))) return;
|
|
60
|
+
const executionContext = getExecutionContext();
|
|
61
|
+
const user: User = {
|
|
62
|
+
id: executionContextUserId(executionContext),
|
|
63
|
+
executionContext,
|
|
64
|
+
};
|
|
65
|
+
requestContext.set(MASTRA_USER_KEY, user);
|
|
66
|
+
requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
|
|
67
|
+
// AppKit's `UserContext` surfaces display name / email only on
|
|
68
|
+
// OBO requests. Service-context calls (background tasks, server
|
|
69
|
+
// start-up) leave these undefined and we skip the stamp so
|
|
70
|
+
// downstream trace metadata stays absent rather than empty.
|
|
71
|
+
let userName: string | undefined;
|
|
72
|
+
let email: string | undefined;
|
|
73
|
+
if ("isUserContext" in executionContext) {
|
|
74
|
+
userName = executionContext.userName;
|
|
75
|
+
email = executionContext.userEmail;
|
|
76
|
+
} else if (process.env.NODE_ENV === "development") {
|
|
77
|
+
const currentUser = await executionContext.client.currentUser.me();
|
|
78
|
+
userName = currentUser?.userName;
|
|
79
|
+
email = currentUser?.emails?.filter((email) => email.primary).find((email) => email.value)
|
|
80
|
+
?.value as string;
|
|
81
|
+
}
|
|
82
|
+
if (userName) {
|
|
83
|
+
requestContext.set(MASTRA_USER_NAME_KEY, userName);
|
|
84
|
+
}
|
|
85
|
+
if (email) {
|
|
86
|
+
requestContext.set(MASTRA_USER_EMAIL_KEY, email);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the `RequestContext` a NON-HTTP agent turn needs.
|
|
92
|
+
*
|
|
93
|
+
* Everything Mastra's tools read off the context is stamped the same way the
|
|
94
|
+
* request middleware stamps it - the AppKit user (so `ask_genie` and the model
|
|
95
|
+
* resolver can mint user-scoped tokens), the memory thread / resource pair, and
|
|
96
|
+
* a request id so the turn's spans join up in traces. The result is passed as
|
|
97
|
+
* `requestContext` to `agent.generate` / `agent.stream`.
|
|
98
|
+
*
|
|
99
|
+
* This is what makes a card turn answer with the SAME data a chat turn does: the
|
|
100
|
+
* difference between the two endpoints is presentation, not capability.
|
|
101
|
+
*/
|
|
102
|
+
export async function createRequestContext(
|
|
103
|
+
options: { threadId?: string; resourceId?: string; requestId?: string } = {},
|
|
104
|
+
): Promise<RequestContext> {
|
|
105
|
+
const requestContext = new RequestContext();
|
|
106
|
+
await stampRequestContextUser(requestContext);
|
|
107
|
+
if (options.threadId) requestContext.set(MASTRA_THREAD_ID_KEY, options.threadId);
|
|
108
|
+
// Stamped AFTER the user so an explicit resource id (a channel member, say)
|
|
109
|
+
// wins over the ambient identity `stampRequestContextUser` defaulted to.
|
|
110
|
+
if (options.resourceId) requestContext.set(MASTRA_RESOURCE_ID_KEY, options.resourceId);
|
|
111
|
+
requestContext.set(MASTRA_REQUEST_ID_KEY, options.requestId ?? hash.id());
|
|
112
|
+
return requestContext;
|
|
113
|
+
}
|
|
114
|
+
|
|
43
115
|
/**
|
|
44
116
|
* `@mastra/express` subclass that stamps `RequestContext` with the
|
|
45
117
|
* AppKit user, resource id, and a thread id backed by an HTTP-only
|
|
@@ -92,35 +164,7 @@ export class MastraServer extends MastraServerExpress {
|
|
|
92
164
|
}
|
|
93
165
|
|
|
94
166
|
async configureRequestContextUser(requestContext: RequestContext) {
|
|
95
|
-
|
|
96
|
-
const executionContext = getExecutionContext();
|
|
97
|
-
const user: User = {
|
|
98
|
-
id: executionContextUserId(executionContext),
|
|
99
|
-
executionContext,
|
|
100
|
-
};
|
|
101
|
-
requestContext.set(MASTRA_USER_KEY, user);
|
|
102
|
-
requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
|
|
103
|
-
// AppKit's `UserContext` surfaces display name / email only on
|
|
104
|
-
// OBO requests. Service-context calls (background tasks, server
|
|
105
|
-
// start-up) leave these undefined and we skip the stamp so
|
|
106
|
-
// downstream trace metadata stays absent rather than empty.
|
|
107
|
-
let userName: string | undefined;
|
|
108
|
-
let email: string | undefined;
|
|
109
|
-
if ("isUserContext" in executionContext) {
|
|
110
|
-
userName = executionContext.userName;
|
|
111
|
-
email = executionContext.userEmail;
|
|
112
|
-
} else if (process.env.NODE_ENV === "development") {
|
|
113
|
-
const currentUser = await executionContext.client.currentUser.me();
|
|
114
|
-
userName = currentUser?.userName;
|
|
115
|
-
email = currentUser?.emails?.filter((email) => email.primary).find((email) => email.value)
|
|
116
|
-
?.value as string;
|
|
117
|
-
}
|
|
118
|
-
if (userName) {
|
|
119
|
-
requestContext.set(MASTRA_USER_NAME_KEY, userName);
|
|
120
|
-
}
|
|
121
|
-
if (email) {
|
|
122
|
-
requestContext.set(MASTRA_USER_EMAIL_KEY, email);
|
|
123
|
-
}
|
|
167
|
+
await stampRequestContextUser(requestContext);
|
|
124
168
|
}
|
|
125
169
|
|
|
126
170
|
/**
|
package/test/chart.test.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
|
|
5
|
-
import { chartPlanSchema } from "../src/chart";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Walk every nested schema node, yielding each object so a test can
|
|
9
|
-
* assert on keywords wherever they appear in the tree.
|
|
10
|
-
*/
|
|
11
|
-
function* nodes(schema: unknown): Generator<Record<string, unknown>> {
|
|
12
|
-
if (Array.isArray(schema)) {
|
|
13
|
-
for (const item of schema) yield* nodes(item);
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
if (typeof schema !== "object" || schema === null) return;
|
|
17
|
-
const record = schema as Record<string, unknown>;
|
|
18
|
-
yield record;
|
|
19
|
-
for (const value of Object.values(record)) yield* nodes(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe("chart plan JSON schema", () => {
|
|
23
|
-
// Databricks' Gemini serving endpoints validate `response_json_schema`
|
|
24
|
-
// and reject JSON Schema 2020-12 `prefixItems` with "schema at
|
|
25
|
-
// properties.series.items.properties.data.items.anyOf.2.items must be a
|
|
26
|
-
// boolean or an object". Zod emits `prefixItems` for `z.tuple`, so the
|
|
27
|
-
// scatter `[x, y]` point must stay a length-constrained number array.
|
|
28
|
-
it("emits no prefixItems, so Gemini accepts it as a response schema", () => {
|
|
29
|
-
const jsonSchema = z.toJSONSchema(chartPlanSchema);
|
|
30
|
-
const offenders = [...nodes(jsonSchema)].filter((node) => "prefixItems" in node);
|
|
31
|
-
assert.deepEqual(offenders, []);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("types the scatter point as a two-number array", () => {
|
|
35
|
-
const jsonSchema = z.toJSONSchema(chartPlanSchema) as Record<string, any>;
|
|
36
|
-
const variants = jsonSchema.properties.series.items.properties.data.items.anyOf;
|
|
37
|
-
const arrayVariant = variants.find((v: Record<string, unknown>) => v.type === "array");
|
|
38
|
-
assert.deepEqual(arrayVariant, {
|
|
39
|
-
type: "array",
|
|
40
|
-
items: { type: "number" },
|
|
41
|
-
minItems: 2,
|
|
42
|
-
maxItems: 2,
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("chart data point coercion", () => {
|
|
48
|
-
const dataPoint = chartPlanSchema.shape.series.element.shape.data.element;
|
|
49
|
-
|
|
50
|
-
it("keeps the shapes a SQL row set produces", () => {
|
|
51
|
-
assert.equal(dataPoint.parse(12.5), 12.5);
|
|
52
|
-
assert.equal(dataPoint.parse("12.5"), 12.5);
|
|
53
|
-
assert.equal(dataPoint.parse(null), null);
|
|
54
|
-
assert.deepEqual(dataPoint.parse([1, 2]), [1, 2]);
|
|
55
|
-
assert.deepEqual(dataPoint.parse({ name: "ATL", value: 3 }), { name: "ATL", value: 3 });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("degrades unusable values to null instead of failing the plan", () => {
|
|
59
|
-
assert.equal(dataPoint.parse("not a number"), null);
|
|
60
|
-
assert.equal(dataPoint.parse(Number.POSITIVE_INFINITY), null);
|
|
61
|
-
assert.equal(dataPoint.parse({ nope: true }), null);
|
|
62
|
-
});
|
|
63
|
-
});
|
package/test/config.test.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { ConfigurationError } from "@databricks/appkit";
|
|
4
|
-
|
|
5
|
-
import { MASTRA_CONFIG_SCHEMA } from "../src/config";
|
|
6
|
-
import { normalizeGenieSpaces } from "../src/genie";
|
|
7
|
-
import { invalidFields } from "../src/validation";
|
|
8
|
-
|
|
9
|
-
describe("mastra config schema", () => {
|
|
10
|
-
it("describes every published property", () => {
|
|
11
|
-
const properties = Object.entries(MASTRA_CONFIG_SCHEMA.properties ?? {});
|
|
12
|
-
assert.ok(properties.length > 0);
|
|
13
|
-
for (const [name, schema] of properties) {
|
|
14
|
-
assert.equal(typeof schema, "object", `${name} must be a schema object`);
|
|
15
|
-
const description = (schema as { description?: unknown }).description;
|
|
16
|
-
assert.equal(typeof description, "string", `${name} must carry a description`);
|
|
17
|
-
assert.ok((description as string).length > 0, `${name} description must be non-empty`);
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("genie space normalization", () => {
|
|
23
|
-
it("wraps bare space ids and passes objects through", () => {
|
|
24
|
-
assert.deepEqual(normalizeGenieSpaces({ default: "01ef", sales: { spaceId: "02ef" } }), {
|
|
25
|
-
default: { spaceId: "01ef" },
|
|
26
|
-
sales: { spaceId: "02ef" },
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("treats no config as no spaces", () => {
|
|
31
|
-
assert.deepEqual(normalizeGenieSpaces(undefined), {});
|
|
32
|
-
assert.deepEqual(normalizeGenieSpaces({}), {});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("fails loudly on an alias with no space id", () => {
|
|
36
|
-
for (const spaces of [
|
|
37
|
-
{ default: undefined },
|
|
38
|
-
{ default: "" },
|
|
39
|
-
{ sales: { spaceId: "" } },
|
|
40
|
-
] as Parameters<typeof normalizeGenieSpaces>[0][]) {
|
|
41
|
-
assert.throws(() => normalizeGenieSpaces(spaces), ConfigurationError);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("names the env var only for the default alias", () => {
|
|
46
|
-
assert.throws(() => normalizeGenieSpaces({ default: undefined }), /DATABRICKS_GENIE_SPACE_ID/);
|
|
47
|
-
assert.throws(() => normalizeGenieSpaces({ sales: undefined }), /genieSpaces\.sales/);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("request body validation", () => {
|
|
52
|
-
it("reports distinct dot-joined field paths", () => {
|
|
53
|
-
assert.deepEqual(
|
|
54
|
-
invalidFields({
|
|
55
|
-
issues: [{ path: ["traceId"] }, { path: ["value", 0] }, { path: ["traceId"] }],
|
|
56
|
-
}),
|
|
57
|
-
["traceId", "value.0"],
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("drops the root path, which names no field", () => {
|
|
62
|
-
assert.deepEqual(invalidFields({ issues: [{ path: [] }] }), []);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
@@ -1,98 +0,0 @@
|
|
|
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
|
-
});
|
package/test/tsconfig.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
-
{
|
|
3
|
-
"extends": "../tsconfig.json",
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"noEmit": true,
|
|
6
|
-
"rootDir": ".."
|
|
7
|
-
},
|
|
8
|
-
"include": [
|
|
9
|
-
"**/*.ts"
|
|
10
|
-
],
|
|
11
|
-
"exclude": [
|
|
12
|
-
"node_modules"
|
|
13
|
-
]
|
|
14
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
-
{
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"rootDir": "src",
|
|
5
|
-
"outDir": "lib",
|
|
6
|
-
"alwaysStrict": true,
|
|
7
|
-
"declaration": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"experimentalDecorators": true,
|
|
10
|
-
"inlineSourceMap": true,
|
|
11
|
-
"inlineSources": true,
|
|
12
|
-
"lib": [
|
|
13
|
-
"ES2022"
|
|
14
|
-
],
|
|
15
|
-
"module": "ESNext",
|
|
16
|
-
"noEmitOnError": false,
|
|
17
|
-
"noFallthroughCasesInSwitch": true,
|
|
18
|
-
"noImplicitAny": true,
|
|
19
|
-
"noImplicitReturns": true,
|
|
20
|
-
"noImplicitThis": true,
|
|
21
|
-
"noUnusedLocals": true,
|
|
22
|
-
"noUnusedParameters": true,
|
|
23
|
-
"resolveJsonModule": true,
|
|
24
|
-
"strict": true,
|
|
25
|
-
"strictNullChecks": true,
|
|
26
|
-
"strictPropertyInitialization": true,
|
|
27
|
-
"stripInternal": true,
|
|
28
|
-
"target": "ES2022",
|
|
29
|
-
"types": [
|
|
30
|
-
"node"
|
|
31
|
-
],
|
|
32
|
-
"moduleResolution": "bundler",
|
|
33
|
-
"skipLibCheck": true
|
|
34
|
-
},
|
|
35
|
-
"include": [
|
|
36
|
-
"src/**/*.ts"
|
|
37
|
-
],
|
|
38
|
-
"exclude": [
|
|
39
|
-
"node_modules"
|
|
40
|
-
]
|
|
41
|
-
}
|