@dbx-tools/appkit-mastra 0.1.112 → 0.3.2

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/memory.ts ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Lakebase-backed Mastra memory wiring.
3
+ *
4
+ * Provides a {@link MemoryBuilder} that mints one `Memory` per agent
5
+ * with two independent knobs:
6
+ *
7
+ * - **Storage** (threads / messages via `PostgresStore`): defaults to
8
+ * **per-agent** namespacing via {@link agentStorageSchemaName} so
9
+ * conversation history stays isolated between agents in the same
10
+ * database. `PostgresStore` auto-creates the schema with
11
+ * `CREATE SCHEMA IF NOT EXISTS` on init.
12
+ * - **Memory** (semantic recall via `PgVector`): defaults to a single
13
+ * **shared** instance across every agent. Cross-agent recall on one
14
+ * index is almost always what users want; opt into per-agent recall
15
+ * by passing a {@link MastraMemoryConfigOverride} on the agent.
16
+ *
17
+ * Additionally, {@link MemoryBuilder.instanceStorage} returns a
18
+ * **Mastra-instance-level** `PostgresStore` (schema `mastra_instance`)
19
+ * used for workflow snapshots - the persistence layer
20
+ * `agent.resumeStream()` reads from when waking a suspended
21
+ * `requireApproval` tool call. Per-agent stores are not enough for
22
+ * this: workflow runs are scoped to the Mastra instance, not an
23
+ * individual agent's `Memory`.
24
+ *
25
+ * Plugin-level `config.storage` / `config.memory` act as the baseline
26
+ * (auto-defaulted to `true` in `plugin.ts` when the `lakebase` plugin
27
+ * is registered); per-agent settings cascade on top of that.
28
+ */
29
+
30
+ import { getUsernameWithApiLookup } from "@databricks/appkit";
31
+ import { fastembed } from "@mastra/fastembed";
32
+ import { Memory } from "@mastra/memory";
33
+ import { PgVector, PostgresStore } from "@mastra/pg";
34
+ import { randomUUID } from "node:crypto";
35
+ import { Pool, type PoolConfig } from "pg";
36
+
37
+ import type { MastraAgentDefinition, MastraMemoryConfigOverride } from "./agents";
38
+ import type { MastraPluginConfig } from "./config";
39
+ import { agentStorageSchemaName } from "./storage-schema";
40
+ import { summaryModel, TITLE_INSTRUCTIONS } from "./summarize";
41
+ import { log } from "@dbx-tools/shared-core";
42
+
43
+ const logger = log.logger("mastra/memory");
44
+
45
+ /**
46
+ * Build a dedicated **service-principal** Lakebase pool for Mastra
47
+ * memory from the lakebase plugin's resolved SP pg config.
48
+ *
49
+ * The plugin's `exports().pool` is a `RoutingPool` that switches to
50
+ * the per-user (OBO) pool whenever a query runs inside an `asUser`
51
+ * scope - exactly the context the mastra plugin establishes around
52
+ * every chat turn. Memory (threads / messages + semantic recall) must
53
+ * instead always act as the app service principal: it owns the
54
+ * auto-created `mastra_*` schemas (a per-user role usually can't
55
+ * `CREATE SCHEMA`) and is shared across users, so it cannot inherit a
56
+ * request's OBO identity.
57
+ *
58
+ * `pgConfig` must be the plugin's `exports().getPgConfig()` evaluated
59
+ * **outside** any `asUser` scope (i.e. during setup), so it carries
60
+ * the SP connection target, OAuth token-refresh `password` callback,
61
+ * and any `lakebase({ pool })` tuning overrides - all of which this
62
+ * pool inherits. See the call site in `plugin.ts`.
63
+ */
64
+ export async function createServicePrincipalPool(pgConfig: PoolConfig): Promise<Pool> {
65
+ // `getPgConfig()` resolves the SP username synchronously from
66
+ // `PGUSER` / `DATABRICKS_CLIENT_ID`; fall back to the async API
67
+ // lookup (e.g. local dev authenticating via PAT) so the pool always
68
+ // has an identity to connect with.
69
+ const user = pgConfig.user ?? (await getUsernameWithApiLookup());
70
+ return new Pool({ ...pgConfig, user });
71
+ }
72
+
73
+ /** Effective per-knob setting after the plugin/agent cascade. */
74
+ type StorageSetting = MastraAgentDefinition["storage"];
75
+ type MemorySetting = MastraAgentDefinition["memory"];
76
+
77
+ /**
78
+ * True when any plugin-level or per-agent setting could need the
79
+ * Lakebase pool. Used by `plugin.ts` to gate creation of the
80
+ * service-principal pool and the {@link MemoryBuilder} that consumes
81
+ * it; when false neither is built.
82
+ */
83
+ export function needsLakebase(config: MastraPluginConfig): boolean {
84
+ if (settingNeedsSharedPool(config.storage)) return true;
85
+ if (settingNeedsSharedPool(config.memory)) return true;
86
+ const defs = collectAgentDefinitions(config);
87
+ return defs.some((d) => settingNeedsSharedPool(d.storage) || settingNeedsSharedPool(d.memory));
88
+ }
89
+
90
+ /**
91
+ * Construct a per-agent {@link Memory} factory bound to the supplied
92
+ * service-principal pool (see {@link createServicePrincipalPool}).
93
+ * Caches the shared `PgVector` singleton (built on first need) so each
94
+ * agent build is O(1) after the first.
95
+ */
96
+ export function createMemoryBuilder(
97
+ config: MastraPluginConfig,
98
+ servicePrincipalPool: Pool,
99
+ ): MemoryBuilder {
100
+ return new MemoryBuilder(config, servicePrincipalPool);
101
+ }
102
+
103
+ /**
104
+ * Builds one `Memory` per agent against a shared service-principal
105
+ * Lakebase pool. Per-instance state keeps the shared `PgVector` alive
106
+ * across calls so registering N agents stays cheap.
107
+ */
108
+ export class MemoryBuilder {
109
+ private sharedVector: PgVector | undefined;
110
+
111
+ constructor(
112
+ private readonly config: MastraPluginConfig,
113
+ private readonly servicePrincipalPool: Pool,
114
+ ) {}
115
+
116
+ /**
117
+ * Build the Mastra-instance-level storage used for workflow
118
+ * snapshots. Returns `undefined` when plugin-level `storage` is
119
+ * disabled, in which case `agent.resumeStream()` (and therefore
120
+ * the `requireApproval` flow) will not be available.
121
+ *
122
+ * The store lives in a dedicated `mastra_instance` schema so it
123
+ * never collides with per-agent {@link agentStorageSchemaName} namespaces.
124
+ * Workflow snapshots are not per-agent state; they belong to the
125
+ * `Mastra` instance that owns the workflow execution.
126
+ */
127
+ instanceStorage(): PostgresStore | undefined {
128
+ const setting = this.config.storage;
129
+ if (!setting) return undefined;
130
+ if (typeof setting === "object") {
131
+ return new PostgresStore(
132
+ withId(setting, "mastra-store__instance") as ConstructorParameters<typeof PostgresStore>[0],
133
+ );
134
+ }
135
+ return new PostgresStore({
136
+ id: "mastra-store__instance",
137
+ schemaName: "mastra_instance",
138
+ pool: this.servicePrincipalPool,
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Build a `Memory` for `agentId` after the plugin/agent cascade.
144
+ * Returns `undefined` when the agent has neither storage nor a
145
+ * vector store enabled - Mastra accepts a missing `memory` field
146
+ * and treats the agent as stateless.
147
+ */
148
+ forAgent(agentId: string, def: MastraAgentDefinition): Memory | undefined {
149
+ const storageSetting = def.storage ?? this.config.storage;
150
+ const memorySetting = def.memory ?? this.config.memory;
151
+
152
+ const storage = this.buildStorage(agentId, storageSetting);
153
+ const vector = this.buildVector(memorySetting);
154
+ if (!storage && !vector) {
155
+ logger.debug("agent:stateless", { agentId });
156
+ return undefined;
157
+ }
158
+
159
+ logger.debug("agent:configured", {
160
+ agentId,
161
+ storage: storage !== undefined,
162
+ vector: vector !== undefined,
163
+ vectorMode:
164
+ vector === undefined ? "off" : typeof memorySetting === "object" ? "dedicated" : "shared",
165
+ });
166
+
167
+ return new Memory({
168
+ ...(storage ? { storage } : {}),
169
+ ...(vector ? { vector, embedder: fastembed } : {}),
170
+ options: {
171
+ lastMessages: 10,
172
+ ...(vector ? { semanticRecall: { topK: 3, messageRange: 2 } } : {}),
173
+ // Auto-name each thread from its opening turn so the
174
+ // conversation list the UI renders shows meaningful titles
175
+ // instead of raw ids. Titling runs on the small / fast chat
176
+ // tier (see `summarize.ts`) rather than the agent's primary
177
+ // model, so naming a thread never spends the heavyweight model.
178
+ // Only meaningful when storage is on; harmless otherwise.
179
+ ...(storage
180
+ ? {
181
+ generateTitle: {
182
+ model: summaryModel(this.config),
183
+ instructions: TITLE_INSTRUCTIONS,
184
+ },
185
+ }
186
+ : {}),
187
+ },
188
+ });
189
+ }
190
+
191
+ private buildStorage(agentId: string, setting: StorageSetting): PostgresStore | undefined {
192
+ if (!setting) return undefined;
193
+ if (typeof setting === "boolean") {
194
+ return new PostgresStore({
195
+ id: `mastra-store__${agentId}`,
196
+ schemaName: agentStorageSchemaName(agentId),
197
+ pool: this.servicePrincipalPool,
198
+ });
199
+ }
200
+ // Cast: `withId` guarantees `id` is set, but the distributive
201
+ // Omit + `id?: string` shape doesn't structurally narrow to the
202
+ // discriminated union members. Runtime shape is identical.
203
+ return new PostgresStore(
204
+ withId(setting, `mastra-store__${agentId}`) as ConstructorParameters<typeof PostgresStore>[0],
205
+ );
206
+ }
207
+
208
+ /**
209
+ * Resolve the agent's vector store. Cascade:
210
+ *
211
+ * - falsy: no vector.
212
+ * - `boolean` / `undefined-inheriting-true`: return the shared
213
+ * singleton (built lazily on first call). All agents that
214
+ * default-enable memory write into and recall from one index.
215
+ * - object: build a dedicated `PgVector` for this agent.
216
+ */
217
+ private buildVector(setting: MemorySetting): PgVector | undefined {
218
+ if (!setting) return undefined;
219
+ if (typeof setting === "boolean") return this.getSharedVector();
220
+ return buildPgVector(setting);
221
+ }
222
+
223
+ private getSharedVector(): PgVector {
224
+ if (!this.sharedVector) {
225
+ this.sharedVector = buildSharedPgVector(this.servicePrincipalPool);
226
+ }
227
+ return this.sharedVector;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Build the shared `PgVector` that backs the default
233
+ * `def.memory === true` case across every agent.
234
+ *
235
+ * `PgVector`'s constructor accepts only connection-style configs
236
+ * (`HostConfig` / `ConnectionStringConfig` / `ClientConfig`); there is
237
+ * no `{ pool }` shorthand the way `PostgresStore` has one. Worse, the
238
+ * constructor synchronously kicks off a `cacheWarmupPromise` IIFE that
239
+ * calls `this.pool.connect()` before returning, so we can't cleanly
240
+ * hand it an inert config and patch the pool afterwards.
241
+ *
242
+ * The trick: pass illegal-but-validation-passing placeholders so the
243
+ * warmup's `net.connect()` rejects synchronously with `RangeError`
244
+ * (Node validates `0 <= port < 65536`). The IIFE's `catch {}` swallows
245
+ * it, no DNS lookup or TCP attempt happens, and we then swap
246
+ * `pgVector.pool` to the lakebase pool. Every subsequent `PgVector`
247
+ * method reads `this.pool` at call time, so all real I/O goes through
248
+ * the lakebase pool from then on. The placeholder pool is `.end()`'d
249
+ * so its socket book-keeping is released.
250
+ */
251
+ function buildSharedPgVector(pool: Pool): PgVector {
252
+ const vector = new PgVector({
253
+ id: `pg${randomUUID()}`,
254
+ // Keep the recall index out of `public`: on a Lakebase database the app
255
+ // service principal has no CREATE on `public` (PG15+ locks it down), so a
256
+ // default-schema PgVector fails on CREATE INDEX with "permission denied for
257
+ // schema public". PgVector runs CREATE SCHEMA IF NOT EXISTS for a named
258
+ // schema, so the SP creates and owns `mastra_instance` (the same
259
+ // instance-level schema the workflow-snapshot store uses) and the index
260
+ // lands there. Table names don't collide with the storage tables.
261
+ schemaName: "mastra_instance",
262
+ host: "-1",
263
+ port: -1,
264
+ database: "_",
265
+ user: "_",
266
+ password: "_",
267
+ });
268
+ const placeholder = vector.pool;
269
+ vector.pool = pool;
270
+ void placeholder.end().catch(() => undefined);
271
+ return vector;
272
+ }
273
+
274
+ /** Per-agent dedicated `PgVector` (rare; opt-in via object override). */
275
+ function buildPgVector(setting: MastraMemoryConfigOverride): PgVector {
276
+ return new PgVector(
277
+ withId(setting, `pg-vector__${randomUUID()}`) as ConstructorParameters<typeof PgVector>[0],
278
+ );
279
+ }
280
+
281
+ /** True when this setting requires the shared Lakebase pool. */
282
+ function settingNeedsSharedPool(setting: StorageSetting | MemorySetting | undefined): boolean {
283
+ return setting === true;
284
+ }
285
+
286
+ /** Walk the three shapes of `config.agents` into a flat list. */
287
+ function collectAgentDefinitions(config: MastraPluginConfig): MastraAgentDefinition[] {
288
+ const agents = config.agents;
289
+ if (!agents) return [];
290
+ if (Array.isArray(agents)) return agents;
291
+ if (typeof (agents as MastraAgentDefinition).instructions === "string") {
292
+ return [agents as MastraAgentDefinition];
293
+ }
294
+ return Object.values(agents as Record<string, MastraAgentDefinition>);
295
+ }
296
+
297
+ /** Fill in a default `id` when the caller didn't supply one. */
298
+ function withId<T extends { id?: string }>(value: T, fallback: string): T {
299
+ return value.id ? value : { ...value, id: fallback };
300
+ }
package/src/mlflow.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * MLflow user-feedback logging: detect whether MLflow tracing is wired
3
+ * for this deployment, and log a thumbs / comment as a trace
4
+ * *assessment* via the Databricks MLflow REST API.
5
+ *
6
+ * Feedback attaches to a trace, and the plugin's spans reach MLflow
7
+ * through the same OTel pipeline as every other AppKit span (see
8
+ * `observability.ts`). MLflow derives its trace id from the OpenTelemetry
9
+ * trace id (`tr-<hex(otelTraceId)>`), so the server stamps the active
10
+ * trace id on each turn's response and the client sends it back here.
11
+ *
12
+ * There is no MLflow JS SDK, so this posts to the assessments REST
13
+ * endpoint directly using the OBO-scoped workspace client (the feedback
14
+ * is thus attributed to the signed-in user). Trace export is
15
+ * asynchronous, so the just-finished trace may not exist in MLflow yet
16
+ * when the user reacts; the log call retries briefly on "not found"
17
+ * before giving up softly.
18
+ */
19
+
20
+ import { feedback } from "@dbx-tools/shared-mastra";
21
+ import { databricksFetch, readResponseJson, readResponseText } from "./rest";
22
+ import { async, error, log } from "@dbx-tools/shared-core";
23
+ import { appkit } from "@dbx-tools/appkit";
24
+
25
+ const logger = log.logger("mastra/mlflow");
26
+
27
+ /** Workspace client carried on an AppKit execution context. */
28
+ type WorkspaceClient = appkit.WorkspaceClientLike;
29
+
30
+ /** Assessments REST path for a trace. `3.0` is the current MLflow API version. */
31
+ const assessmentsPath = (traceId: string): string =>
32
+ `/api/3.0/mlflow/traces/${encodeURIComponent(traceId)}/assessments`;
33
+
34
+ /** Number of times to retry a "trace not found" response before giving up. */
35
+ const NOT_FOUND_RETRIES = 3;
36
+ /** Base backoff between "trace not found" retries, in ms (grows linearly). */
37
+ const NOT_FOUND_BACKOFF_MS = 1200;
38
+
39
+ /**
40
+ * Whether MLflow feedback logging is available for this deployment.
41
+ *
42
+ * Enabled when an OTLP exporter endpoint is configured (traces are
43
+ * actually shipped somewhere) AND an MLflow experiment is named - the
44
+ * two signals that the OTLP backend is MLflow and traces will
45
+ * materialize there. Both are standard env vars, so no plugin config is
46
+ * required; a deployment opts in simply by wiring MLflow tracing.
47
+ */
48
+ export function mlflowEnabled(): boolean {
49
+ const hasExporter = Boolean(
50
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() ||
51
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim(),
52
+ );
53
+ const hasExperiment = Boolean(
54
+ process.env.MLFLOW_EXPERIMENT_ID?.trim() || process.env.MLFLOW_EXPERIMENT_NAME?.trim(),
55
+ );
56
+ return hasExporter && hasExperiment;
57
+ }
58
+
59
+ /**
60
+ * Resolve whether user feedback is enabled from the plugin's optional
61
+ * `config.feedback` override: the explicit boolean wins, otherwise fall
62
+ * back to auto-detecting MLflow tracing ({@link mlflowEnabled}). Shared
63
+ * by the plugin's client-config gate and the server's trace-id header so
64
+ * the two never disagree.
65
+ */
66
+ export function resolveFeedbackEnabled(explicit: boolean | undefined): boolean {
67
+ return explicit ?? mlflowEnabled();
68
+ }
69
+
70
+ /** Parameters for {@link logFeedback}. */
71
+ export interface LogFeedbackParams {
72
+ /** MLflow trace id the assessment attaches to (`tr-<hex>`). */
73
+ traceId: string;
74
+ /** Assessment name; defaults per whether a value or a comment is sent. */
75
+ name?: string;
76
+ /** Thumbs / rating / label value. Omit for a comment-only submission. */
77
+ value?: boolean | number | string;
78
+ /** Freeform comment: the rationale alongside a value, or the value itself when none. */
79
+ comment?: string;
80
+ /** Identity the feedback is attributed to (user email / id). */
81
+ sourceId?: string;
82
+ }
83
+
84
+ /**
85
+ * Log a HUMAN feedback assessment to a trace. Returns the created
86
+ * assessment id on success, or `undefined` when the trace can't be
87
+ * found (even after retrying for export lag) or the request otherwise
88
+ * fails - callers surface that as a soft "not recorded" rather than an
89
+ * error, keeping the chat usable.
90
+ */
91
+ export async function logFeedback(
92
+ client: WorkspaceClient,
93
+ params: LogFeedbackParams,
94
+ ): Promise<string | undefined> {
95
+ // A comment with no thumbs value is logged as text feedback; a value
96
+ // (with an optional comment as the rationale) is the thumbs path.
97
+ const hasValue = params.value !== undefined;
98
+ const name =
99
+ params.name?.trim() ||
100
+ (hasValue ? feedback.DEFAULT_FEEDBACK_NAME : feedback.DEFAULT_COMMENT_NAME);
101
+ const value = hasValue ? params.value : params.comment;
102
+ const assessment: Record<string, unknown> = {
103
+ trace_id: params.traceId,
104
+ assessment_name: name,
105
+ source: {
106
+ source_type: "HUMAN",
107
+ source_id: params.sourceId?.trim() || "user",
108
+ },
109
+ feedback: { value },
110
+ ...(hasValue && params.comment?.trim() ? { rationale: params.comment } : {}),
111
+ };
112
+ const body = { assessment };
113
+
114
+ for (let attempt = 0; attempt <= NOT_FOUND_RETRIES; attempt++) {
115
+ let res: Response;
116
+ try {
117
+ res = await databricksFetch(client, assessmentsPath(params.traceId), {
118
+ method: "POST",
119
+ body,
120
+ });
121
+ } catch (err) {
122
+ logger.warn("feedback request failed", {
123
+ traceId: params.traceId,
124
+ error: error.errorMessage(err),
125
+ });
126
+ return undefined;
127
+ }
128
+ if (res.ok) {
129
+ const parsed = await readResponseJson(res);
130
+ const assessmentId =
131
+ (parsed as { assessment?: { assessment_id?: unknown } })?.assessment?.assessment_id ??
132
+ (parsed as { assessment_id?: unknown })?.assessment_id;
133
+ return typeof assessmentId === "string" ? assessmentId : "";
134
+ }
135
+ // Trace export is async; a fresh trace may not exist yet. Retry a
136
+ // few times with a short backoff before giving up softly.
137
+ if (res.status === 404 && attempt < NOT_FOUND_RETRIES) {
138
+ await async.sleep(NOT_FOUND_BACKOFF_MS * (attempt + 1));
139
+ continue;
140
+ }
141
+ logger.warn("feedback not recorded", {
142
+ traceId: params.traceId,
143
+ status: res.status,
144
+ body: await readResponseText(res),
145
+ });
146
+ return undefined;
147
+ }
148
+ return undefined;
149
+ }
package/src/model.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Databricks Model Serving resolver for Mastra agents.
3
+ *
4
+ * Each agent step calls {@link buildModel} with the active
5
+ * `RequestContext`. The user stamped by `MastraServer` carries an
6
+ * AppKit `WorkspaceClient`; we ask it for the workspace host and a
7
+ * fresh bearer header, then point Mastra's OpenAI-compatible provider
8
+ * at `/serving-endpoints` on that host.
9
+ *
10
+ * This module only adds the Mastra-specific glue. The actual model
11
+ * selection - listing the workspace catalogue and resolving an
12
+ * explicit name / class / fallback chain to a real endpoint id - lives
13
+ * in `@dbx-tools/model` ({@link selectModel}) so non-Mastra consumers
14
+ * (e.g. a job that just needs a model name) can reuse it. Here we
15
+ * assemble the explicit ask from Mastra's request context (the
16
+ * per-request override under {@link MASTRA_MODEL_OVERRIDE_KEY}, the
17
+ * agent / plugin `modelId`, or `DATABRICKS_SERVING_ENDPOINT_NAME`),
18
+ * pass the plugin's fuzzy / class / fallback knobs through, and wrap
19
+ * the resolved id in the OpenAI-compatible provider config Mastra
20
+ * expects. Catalogue fetches fail loud: network / auth errors
21
+ * propagate so callers see the real SDK message.
22
+ */
23
+
24
+ import { getExecutionContext } from "@databricks/appkit";
25
+ import { classes, resolve } from "@dbx-tools/model";
26
+ import { model } from "@dbx-tools/shared-model";
27
+ import type { MastraModelConfig } from "@mastra/core/llm";
28
+ import type { RequestContext } from "@mastra/core/request-context";
29
+
30
+ import { MASTRA_USER_KEY, type MastraPluginConfig, type User } from "./config";
31
+ import { rewriteServingBody } from "./serving-sanitize";
32
+ import { MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving";
33
+ import { functionModule, log, net } from "@dbx-tools/shared-core";
34
+
35
+ type ModelClass = model.ModelClass;
36
+ const { parseModelClass } = classes;
37
+ const { selectModel } = resolve;
38
+
39
+ /** Optional overrides accepted by {@link buildModel}. */
40
+ export interface BuildModelOverrides {
41
+ /**
42
+ * Static model id from the agent / plugin config (string sugar on
43
+ * `def.model` or `config.defaultModel`). Loses to the per-request
44
+ * override but wins over env / class / fallback.
45
+ */
46
+ modelId?: string;
47
+ /**
48
+ * Chat capability class to resolve when no explicit model id is
49
+ * supplied. Used by internal agents (e.g. the chart planner asks for
50
+ * {@link model.ModelClass.ChatFast}) to express intent without pinning an
51
+ * endpoint name; the live catalogue is classified and the top
52
+ * available model in the class is chosen, falling back to the
53
+ * class's static list when the workspace has none.
54
+ */
55
+ modelClass?: ModelClass;
56
+ }
57
+
58
+ /**
59
+ * Resolve a `MastraModelConfig` for the current agent step. Runs
60
+ * while `agent.stream` is inside the `asUser(req)` scope so tokens
61
+ * are user-scoped; outside an active user context the workspace
62
+ * client falls back to the service principal.
63
+ */
64
+ export async function buildModel(
65
+ config: MastraPluginConfig,
66
+ requestContext: RequestContext,
67
+ overrides: BuildModelOverrides = {},
68
+ ): Promise<MastraModelConfig> {
69
+ void setupFetchInterceptor();
70
+ // The chat path stamps the AppKit user on the request context via
71
+ // `MastraServer`. The MCP transport routes don't thread that context
72
+ // into tool execution, so fall back to the ambient execution context
73
+ // (the active OBO scope, or the service principal) when it's absent.
74
+ const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
75
+ const executionContext = user?.executionContext ?? getExecutionContext();
76
+ const clientConfig = executionContext.client.config;
77
+ const host = (await clientConfig.getHost()).toString();
78
+ const headers = new Headers();
79
+ await clientConfig.authenticate(headers);
80
+ // The OpenAI Node SDK appends paths like `/chat/completions` to whatever
81
+ // URL we hand it. Drop the trailing slash so the resulting URL stays
82
+ // well-formed (`/serving-endpoints/chat/completions`).
83
+ const url = new URL("/serving-endpoints", host).toString().replace(/\/$/, "");
84
+
85
+ const logger = log.logger(config);
86
+ const serving = resolveServingConfig(config);
87
+ const override = serving.allowOverride
88
+ ? (requestContext.get(MASTRA_MODEL_OVERRIDE_KEY) as string | undefined)
89
+ : undefined;
90
+
91
+ // The override / agent default / env value can be either a concrete
92
+ // endpoint name or a model class slug ("chat-thinking" /
93
+ // "chat-balanced" / "chat-fast"). A class slug becomes a class intent
94
+ // (let the live catalogue pick the best model in that band); anything
95
+ // else is an explicit name fuzzy-matched against the catalogue. An
96
+ // internal `overrides.modelClass` (e.g. the chart planner) is the
97
+ // floor when nothing was requested.
98
+ const requested = override ?? overrides.modelId ?? process.env.DATABRICKS_SERVING_ENDPOINT_NAME;
99
+ const requestedClass = requested !== undefined ? parseModelClass(requested) : null;
100
+ const explicit = requestedClass === null ? requested : undefined;
101
+ const modelClass = requestedClass ?? overrides.modelClass;
102
+
103
+ const { modelId, source } = await selectModel(executionContext.client, host, {
104
+ ...(explicit !== undefined ? { explicit } : {}),
105
+ fuzzy: serving.fuzzy,
106
+ threshold: serving.threshold,
107
+ ...(modelClass !== undefined ? { modelClass } : {}),
108
+ fallbacks: serving.fallbacks,
109
+ ttlMs: serving.ttlMs,
110
+ });
111
+ logger.debug("model selected", { modelId, source, requested });
112
+
113
+ return {
114
+ providerId: config.providerId ?? "databricks",
115
+ modelId,
116
+ url,
117
+ headers: Object.fromEntries(headers.entries()),
118
+ };
119
+ }
120
+
121
+ /** Path prefix that identifies a Databricks Model Serving REST call. */
122
+ const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
123
+
124
+ /**
125
+ * Install a single shared `globalThis.fetch` wrapper for every POST to
126
+ * `/serving-endpoints/...`. The wrapper does two things:
127
+ *
128
+ * 1. Rewrites the outgoing `messages` array to repair Mastra/AI SDK
129
+ * stream-replay quirks that Databricks-hosted Claude rejects (see
130
+ * {@link rewriteServingBody} in `./serving-sanitize.js`).
131
+ * 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
132
+ * 4xx debugging doesn't have to fight AI SDK's `[Array]`
133
+ * formatter.
134
+ *
135
+ * Safe to call from any hot path: {@link functionModule.memoize} ensures
136
+ * the wrapper is installed at most once per process, so subsequent
137
+ * calls are a no-op even when {@link buildModel} fires on every agent
138
+ * step.
139
+ */
140
+ const setupFetchInterceptor = functionModule.memoize((): void => {
141
+ const logger = log.logger("mastra/llm");
142
+ const original = globalThis.fetch.bind(globalThis);
143
+ globalThis.fetch = (async (input, init) => {
144
+ const url = net.urlBuilder(input);
145
+ if (
146
+ !url ||
147
+ !url.pathname.startsWith(SERVING_ENDPOINTS_PATH_PREFIX) ||
148
+ typeof init?.body !== "string"
149
+ ) {
150
+ return original(input, init);
151
+ }
152
+ const rewritten = rewriteServingBody(init.body);
153
+ if (rewritten !== init.body) {
154
+ init = { ...init, body: rewritten };
155
+ }
156
+ try {
157
+ logger.debug("POST", { url: url.toString(), body: JSON.parse(rewritten) });
158
+ } catch {
159
+ logger.debug("POST", { url: url.toString(), bodyType: "non-JSON" });
160
+ }
161
+ return original(input, init);
162
+ }) as typeof globalThis.fetch;
163
+ });