@dbx-tools/appkit-mastra 0.1.40 → 0.1.42

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/model.ts CHANGED
@@ -7,237 +7,53 @@
7
7
  * fresh bearer header, then point Mastra's OpenAI-compatible provider
8
8
  * at `/serving-endpoints` on that host.
9
9
  *
10
- * Model id resolution walks three sources before falling back to the
11
- * hard-coded default, **in this priority order**:
12
- *
13
- * 1. Per-request override stashed by the auth middleware under
14
- * {@link MASTRA_MODEL_OVERRIDE_KEY} (header / query / body).
15
- * 2. The static `modelId` passed in by the agent / plugin (string
16
- * sugar on `def.model` or `config.defaultModel`).
17
- * 3. `DATABRICKS_SERVING_ENDPOINT_NAME` env var.
18
- * 4. {@link FALLBACK_MODEL_ID}.
19
- *
20
- * Whatever wins is then fuzzy-matched against the live
21
- * `/serving-endpoints` list ({@link listServingEndpoints}) so loose
22
- * names like `"claude sonnet"` resolve to the real endpoint name.
23
- * Fuzzy matching is best-effort: when the workspace client throws
24
- * (network blip, expired token at cache-fill time) we fall back to
25
- * the input verbatim and let Databricks return the canonical error.
10
+ * This module only adds the Mastra-specific glue. The actual model
11
+ * selection - listing the workspace catalogue and resolving an
12
+ * explicit name / tier / 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 / tier / 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.
26
22
  */
27
23
 
24
+ import { type ModelTier, parseModelTier, selectModel } from "@dbx-tools/model";
28
25
  import { commonUtils, logUtils, netUtils, stringUtils } from "@dbx-tools/shared";
29
26
  import type { MastraModelConfig } from "@mastra/core/llm";
30
27
  import type { RequestContext } from "@mastra/core/request-context";
31
28
 
32
29
  import { MASTRA_USER_KEY, type MastraPluginConfig, type User } from "./config.js";
33
- import {
34
- listServingEndpoints,
35
- MASTRA_MODEL_OVERRIDE_KEY,
36
- resolveModelId,
37
- resolveServingConfig,
38
- type ServingEndpointSummary,
39
- } from "./serving.js";
40
-
41
- /**
42
- * Capability tiers for Databricks Foundation Model API endpoints.
43
- *
44
- * - {@link ModelTier.Thinking}: deepest reasoning / "thinking" models
45
- * (Claude Opus, GPT-5.5 Pro, Gemini Pro, Llama 4 Maverick, etc).
46
- * Highest cost and latency; reserve for hard multi-step reasoning.
47
- * - {@link ModelTier.Balanced}: cost/latency sweet spot for general
48
- * agent work (Claude Sonnet, GPT-5.x, Gemini Flash, Llama 3.3 70B).
49
- * The right default for most agents.
50
- * - {@link ModelTier.Fast}: cheap and quick; classification, routing,
51
- * tool-arg extraction, simple summarisation (Claude Haiku, GPT-5
52
- * mini/nano, Gemini Flash Lite, GPT-OSS 20B, Llama 3.1 8B).
53
- *
54
- * String enum so the value is the slug we use in cache keys, logs,
55
- * and as the value users see in serialized configs.
56
- */
57
- export enum ModelTier {
58
- Thinking = "thinking",
59
- Balanced = "balanced",
60
- Fast = "fast",
61
- }
62
-
63
- /**
64
- * Catalogue of Databricks-hosted Foundation Model API endpoints,
65
- * grouped by capability {@link ModelTier} and then by provider. Each
66
- * inner array is priority-ordered (most powerful first within the
67
- * same provider+tier).
68
- *
69
- * Provider buckets:
70
- *
71
- * - `claude`: Anthropic Claude family (closed; flagship reasoning).
72
- * - `gpt`: OpenAI GPT-5 family (closed; "ChatGPT" on Databricks FMAPI).
73
- * - `gemini`: Google Gemini family (closed; multimodal + web-search).
74
- * - `openSource`: open-weights models (widest regional / SKU availability).
75
- *
76
- * The list is curated by hand; refresh from the Databricks "supported
77
- * foundation models" doc when new endpoints land.
78
- */
79
- export const MODEL_CATALOG = {
80
- [ModelTier.Thinking]: {
81
- claude: [
82
- "databricks-claude-opus-4-8",
83
- "databricks-claude-opus-4-7",
84
- "databricks-claude-opus-4-6",
85
- "databricks-claude-opus-4-5",
86
- "databricks-claude-opus-4-1",
87
- ],
88
- gpt: ["databricks-gpt-5-5-pro"],
89
- gemini: [
90
- "databricks-gemini-3-1-pro",
91
- "databricks-gemini-3-pro",
92
- "databricks-gemini-2-5-pro",
93
- ],
94
- openSource: [
95
- "databricks-llama-4-maverick",
96
- "databricks-gpt-oss-120b",
97
- "databricks-meta-llama-3-1-405b-instruct",
98
- ],
99
- },
100
- [ModelTier.Balanced]: {
101
- claude: [
102
- "databricks-claude-sonnet-4-6",
103
- "databricks-claude-sonnet-4-5",
104
- "databricks-claude-sonnet-4",
105
- ],
106
- gpt: [
107
- "databricks-gpt-5-5",
108
- "databricks-gpt-5-4",
109
- "databricks-gpt-5-2",
110
- "databricks-gpt-5-1",
111
- "databricks-gpt-5",
112
- ],
113
- gemini: [
114
- "databricks-gemini-3-5-flash",
115
- "databricks-gemini-3-flash",
116
- "databricks-gemini-2-5-flash",
117
- ],
118
- openSource: [
119
- "databricks-meta-llama-3-3-70b-instruct",
120
- "databricks-qwen3-next-80b-a3b-instruct",
121
- "databricks-qwen35-122b-a10b",
122
- ],
123
- },
124
- [ModelTier.Fast]: {
125
- claude: ["databricks-claude-haiku-4-5"],
126
- gpt: [
127
- "databricks-gpt-5-4-mini",
128
- "databricks-gpt-5-4-nano",
129
- "databricks-gpt-5-mini",
130
- "databricks-gpt-5-nano",
131
- ],
132
- gemini: ["databricks-gemini-3-1-flash-lite"],
133
- openSource: [
134
- "databricks-gpt-oss-20b",
135
- "databricks-gemma-3-12b",
136
- "databricks-meta-llama-3-1-8b-instruct",
137
- ],
138
- },
139
- } as const satisfies Record<ModelTier, Record<string, readonly string[]>>;
140
-
141
- /**
142
- * Round-robin zip: take one from each input list in order, skipping
143
- * lists that have already been exhausted. Used to interleave provider
144
- * buckets within a tier so the resolver alternates between vendors
145
- * instead of draining one before trying the next.
146
- *
147
- * Example: `interleave(["a1","a2","a3"], ["b1","b2"])` ->
148
- * `["a1","b1","a2","b2","a3"]`.
149
- */
150
- function interleave<T>(...lists: readonly (readonly T[])[]): T[] {
151
- const out: T[] = [];
152
- const max = Math.max(0, ...lists.map((l) => l.length));
153
- for (let i = 0; i < max; i++) {
154
- for (const list of lists) {
155
- if (i < list.length) out.push(list[i]!);
156
- }
157
- }
158
- return out;
159
- }
160
-
161
- /**
162
- * Priority-ordered model ids for a single capability {@link ModelTier},
163
- * interleaved across providers so a workspace missing the top Claude
164
- * still lands on a flagship GPT / Gemini on the next probe.
165
- *
166
- * Provider order within the interleave: Claude, GPT, Gemini, then the
167
- * open-weights tail appended verbatim as the universal floor (widest
168
- * regional availability).
169
- *
170
- * @example
171
- * ```ts
172
- * mastra({
173
- * defaultModelFallbacks: modelsForTier(ModelTier.Fast),
174
- * });
175
- * ```
176
- */
177
- export function modelsForTier(tier: ModelTier): readonly string[] {
178
- const bucket = MODEL_CATALOG[tier];
179
- return [
180
- ...interleave(bucket.claude, bucket.gpt, bucket.gemini),
181
- ...bucket.openSource,
182
- ];
183
- }
30
+ import { MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving.js";
184
31
 
185
- /**
186
- * Top model id at the given {@link ModelTier}. Sync; the agent-step
187
- * resolver fuzzy-matches it against the workspace catalogue at call
188
- * time, so this works even when the literal top pick isn't deployed.
189
- *
190
- * Use when wiring a tier-appropriate model into an agent definition:
191
- *
192
- * @example
193
- * ```ts
194
- * const classifier = createAgent({
195
- * instructions: "Classify this email",
196
- * model: modelForTier(ModelTier.Fast), // cheap, quick
197
- * });
198
- *
199
- * const planner = createAgent({
200
- * instructions: "Plan a multi-step migration",
201
- * model: modelForTier(ModelTier.Thinking), // deep reasoning
202
- * });
203
- * ```
204
- */
205
- export function modelForTier(tier: ModelTier): string {
206
- return modelsForTier(tier)[0]!;
207
- }
208
-
209
- /**
210
- * Last-resort model ids used when neither `config.defaultModel`,
211
- * per-agent `model`, nor `DATABRICKS_SERVING_ENDPOINT_NAME` is set.
212
- *
213
- * Walked in order at resolve time: the first id whose endpoint is
214
- * actually present in the workspace's `/serving-endpoints` listing
215
- * wins. Workspaces vary - not every region / SKU has every model,
216
- * and the list of Foundation Model APIs evolves quickly - so the
217
- * resolver degrades all the way from "best thinking model" down to
218
- * "smallest commodity Llama" before giving up.
219
- *
220
- * Built by chaining the per-tier interleaves (Thinking -> Balanced
221
- * -> Fast); within each tier the providers are round-robin-zipped
222
- * (Claude, GPT, Gemini, then open-weights tail). Override the entire
223
- * list via `MastraPluginConfig.defaultModelFallbacks` (e.g. to pin a
224
- * regulated workspace to a specific approved subset, or to bias the
225
- * priority toward a particular tier).
226
- */
227
- export const FALLBACK_MODEL_IDS: readonly string[] = [
228
- ...modelsForTier(ModelTier.Thinking),
229
- ...modelsForTier(ModelTier.Balanced),
230
- ...modelsForTier(ModelTier.Fast),
231
- ];
32
+ export {
33
+ classifyEndpoints,
34
+ FALLBACK_MODEL_IDS,
35
+ modelForTier,
36
+ modelsForTier,
37
+ ModelTier,
38
+ } from "@dbx-tools/model";
232
39
 
233
40
  /** Optional overrides accepted by {@link buildModel}. */
234
41
  export interface BuildModelOverrides {
235
42
  /**
236
43
  * Static model id from the agent / plugin config (string sugar on
237
44
  * `def.model` or `config.defaultModel`). Loses to the per-request
238
- * override but wins over env / fallback.
45
+ * override but wins over env / tier / fallback.
239
46
  */
240
47
  modelId?: string;
48
+ /**
49
+ * Capability tier to resolve when no explicit model id is supplied.
50
+ * Used by internal agents (e.g. the chart planner asks for
51
+ * {@link ModelTier.Fast}) to express intent without pinning an
52
+ * endpoint name; the live catalogue is classified and the top
53
+ * available model in the tier is chosen, falling back to the
54
+ * tier's static list when the workspace has none.
55
+ */
56
+ tier?: ModelTier;
241
57
  }
242
58
 
243
59
  /**
@@ -262,91 +78,41 @@ export async function buildModel(
262
78
  // well-formed (`/serving-endpoints/chat/completions`).
263
79
  const url = new URL("/serving-endpoints", host).toString().replace(/\/$/, "");
264
80
 
265
- const modelId = await pickModelId(config, requestContext, overrides, user, host);
266
-
267
- return {
268
- providerId: config.providerId ?? "databricks",
269
- modelId,
270
- url,
271
- headers: Object.fromEntries(headers.entries()),
272
- };
273
- }
274
-
275
- /**
276
- * Walk the resolution ladder and pick a modelId.
277
- *
278
- * 1. **Explicit ask** (per-request override, agent `model` string,
279
- * `config.defaultModel` string, or `DATABRICKS_SERVING_ENDPOINT_NAME`):
280
- * when fuzzy matching is on, snap the input to the closest live
281
- * endpoint so loose names like `"claude sonnet"` resolve. When it's
282
- * off (or no endpoint matches within threshold), the input is used
283
- * verbatim and Databricks surfaces the canonical 404.
284
- *
285
- * 2. **No explicit ask**: walk
286
- * {@link MastraPluginConfig.defaultModelFallbacks} (or
287
- * {@link FALLBACK_MODEL_IDS} when unset) and return the first id
288
- * whose endpoint is actually present in the workspace listing. A
289
- * workspace without Claude Opus still gets a sensible default by
290
- * skipping ahead to whichever Sonnet / GPT-5 / Llama variant is
291
- * wired up.
292
- *
293
- * Catalogue fetches fail loud: network / auth errors propagate to the
294
- * caller so they see the real SDK message instead of a silent fallback
295
- * to the top of the priority list.
296
- */
297
- async function pickModelId(
298
- config: MastraPluginConfig,
299
- requestContext: RequestContext,
300
- overrides: BuildModelOverrides,
301
- user: User,
302
- host: string,
303
- ): Promise<string> {
304
81
  const log = logUtils.logger(config);
305
- const serving = resolveServingConfig(config, FALLBACK_MODEL_IDS);
82
+ const serving = resolveServingConfig(config);
306
83
  const override = serving.allowOverride
307
84
  ? (requestContext.get(MASTRA_MODEL_OVERRIDE_KEY) as string | undefined)
308
85
  : undefined;
309
- const explicit =
310
- override ?? overrides.modelId ?? process.env.DATABRICKS_SERVING_ENDPOINT_NAME;
311
86
 
312
- // Cheap exit: when the caller named a specific model and fuzzy
313
- // matching is off, there's no reason to touch the catalogue at all.
314
- if (explicit !== undefined && !serving.fuzzy) {
315
- log.debug("model selected", { modelId: explicit, source: "explicit" });
316
- return explicit;
317
- }
87
+ // The override / agent default / env value can be either a concrete
88
+ // endpoint name or a capability tier slug ("thinking" / "balanced" /
89
+ // "fast"). A tier slug becomes a tier intent (let the live catalogue
90
+ // pick the best model in that band); anything else is an explicit
91
+ // name fuzzy-matched against the catalogue. An internal
92
+ // `overrides.tier` (e.g. the chart planner) is the floor when nothing
93
+ // was requested.
94
+ const requested =
95
+ override ?? overrides.modelId ?? process.env.DATABRICKS_SERVING_ENDPOINT_NAME;
96
+ const requestedTier = requested !== undefined ? parseModelTier(requested) : null;
97
+ const explicit = requestedTier === null ? requested : undefined;
98
+ const tier = requestedTier ?? overrides.tier;
318
99
 
319
- const endpoints = await listServingEndpoints(user.executionContext.client, host, {
100
+ const { modelId, source } = await selectModel(user.executionContext.client, host, {
101
+ ...(explicit !== undefined ? { explicit } : {}),
102
+ fuzzy: serving.fuzzy,
103
+ threshold: serving.threshold,
104
+ ...(tier !== undefined ? { tier } : {}),
105
+ fallbacks: serving.fallbacks,
320
106
  ttlMs: serving.ttlMs,
321
107
  });
322
- const modelId =
323
- explicit !== undefined
324
- ? resolveModelId(explicit, endpoints, { threshold: serving.threshold }).modelId
325
- : pickFirstAvailable(serving.fallbacks, endpoints);
326
- log.debug("model selected", {
327
- modelId,
328
- source: explicit !== undefined ? "fuzzy-match" : "fallback",
329
- requestedExplicit: explicit,
330
- });
331
- return modelId;
332
- }
108
+ log.debug("model selected", { modelId, source, requested });
333
109
 
334
- /**
335
- * Find the first id in `fallbacks` whose endpoint is present in
336
- * `endpoints`. Returns the top fallback when the workspace has none
337
- * of them so callers always get a string; an offline workspace will
338
- * then receive a clean 404 from Databricks instead of a malformed
339
- * config.
340
- */
341
- function pickFirstAvailable(
342
- fallbacks: readonly string[],
343
- endpoints: readonly ServingEndpointSummary[],
344
- ): string {
345
- const present = new Set(endpoints.map((e) => e.name));
346
- for (const candidate of fallbacks) {
347
- if (present.has(candidate)) return candidate;
348
- }
349
- return fallbacks[0] ?? FALLBACK_MODEL_IDS[0]!;
110
+ return {
111
+ providerId: config.providerId ?? "databricks",
112
+ modelId,
113
+ url,
114
+ headers: Object.fromEntries(headers.entries()),
115
+ };
350
116
  }
351
117
 
352
118
  /** Path prefix that identifies a Databricks Model Serving REST call. */
package/src/plugin.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * AppKit plugin that builds one or more Mastra `Agent` instances and
3
- * mounts the `@mastra/express` server plus `@mastra/ai-sdk` `chatRoute`
4
- * handlers. The UI message stream matches what `chatRoute()` emits, so
5
- * the client can use `useChat()` from `@ai-sdk/react` without custom
6
- * parsing.
3
+ * mounts the `@mastra/express` server. Clients drive the conversation
4
+ * over the standard Mastra agent stream (`@mastra/client-js`'s
5
+ * `getAgent(id).stream()`), so there's no bespoke chat transport to
6
+ * keep in sync.
7
7
  *
8
8
  * - Agents: registered through `config.agents` at plugin creation
9
9
  * ({@link MastraAgentDefinition}). Each entry's `tools` field accepts
@@ -21,10 +21,11 @@
21
21
  * `schemaName: "mastra_<agentId>"`; the vector store is a single
22
22
  * shared singleton across every agent.
23
23
  * - Server: the Express subapp wiring lives in `./server.js`.
24
- * - HTTP: AppKit mounts this plugin under `/api/mastra`. `chatRoute`
25
- * is registered at `/route/chat` (bound to `config.defaultAgent` or
26
- * the first registered id) and `/route/chat/:agentId`, so the
27
- * AI SDK transport URL is `/api/mastra/route/chat/<agentId>`.
24
+ * - HTTP: AppKit mounts this plugin under `/api/mastra`. Alongside the
25
+ * Mastra agent routes, the plugin registers `/route/history`
26
+ * (load + clear thread history), `/models`, `/suggestions`, and the
27
+ * generic `/embed/:type/:id` resolver for inline chart / data
28
+ * markers.
28
29
  */
29
30
 
30
31
  import {
@@ -37,30 +38,35 @@ import {
37
38
  type PluginManifest,
38
39
  type ResourceRequirement,
39
40
  } from "@databricks/appkit";
40
- import { appkitUtils, logUtils } from "@dbx-tools/shared";
41
- import { chatRoute } from "@mastra/ai-sdk";
41
+ import { appkitUtils, commonUtils, logUtils } from "@dbx-tools/shared";
42
42
  import type { Agent } from "@mastra/core/agent";
43
43
  import { Mastra } from "@mastra/core/mastra";
44
44
  import express from "express";
45
+ import type { Pool } from "pg";
45
46
 
46
- import type {
47
- MastraClientConfig,
48
- StatementData,
47
+ import {
48
+ MASTRA_ROUTES,
49
+ type MastraClientConfig,
50
+ type StatementData,
49
51
  } from "@dbx-tools/appkit-mastra-shared";
52
+ import {
53
+ clearServingEndpointsCache,
54
+ listServingEndpoints,
55
+ type ServingEndpointSummary,
56
+ } from "@dbx-tools/model";
50
57
  import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents.js";
51
58
  import { fetchChart } from "./chart.js";
52
59
  import type { MastraPluginConfig } from "./config.js";
53
60
  import { collectSpaceSuggestions, resolveGenieSpaces } from "./genie.js";
54
61
  import { historyRoute } from "./history.js";
55
- import { createMemoryBuilder, needsLakebase } from "./memory.js";
62
+ import {
63
+ createMemoryBuilder,
64
+ createServicePrincipalPool,
65
+ needsLakebase,
66
+ } from "./memory.js";
56
67
  import { buildObservability } from "./observability.js";
57
68
  import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
58
- import {
59
- clearServingEndpointsCache,
60
- listServingEndpoints,
61
- resolveServingConfig,
62
- type ServingEndpointSummary,
63
- } from "./serving.js";
69
+ import { resolveServingConfig } from "./serving.js";
64
70
  import {
65
71
  fetchStatementData,
66
72
  isStatementNotFoundError,
@@ -125,6 +131,14 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
125
131
  private mastra: Mastra | null = null;
126
132
  private mastraApp: express.Express | null = null;
127
133
  private mastraServer: MastraServer | null = null;
134
+ /**
135
+ * Dedicated service-principal Lakebase pool backing Mastra memory /
136
+ * storage. Built once in {@link buildAgentAndServer} (outside any
137
+ * `asUser` scope, so it never inherits a request's OBO identity) and
138
+ * drained in {@link abortActiveOperations}. `null` until setup runs
139
+ * or when Lakebase isn't needed.
140
+ */
141
+ private servicePrincipalPool: Pool | null = null;
128
142
 
129
143
  override async setup(): Promise<void> {
130
144
  // Wait until sibling plugins (e.g. `lakebase`) finish `setup()` so
@@ -150,6 +164,26 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
150
164
  if (this.config.memory === undefined) this.config.memory = true;
151
165
  }
152
166
 
167
+ /**
168
+ * Drain the memory service-principal pool on shutdown. AppKit calls
169
+ * this during teardown; the lakebase plugin closes its own SP / OBO
170
+ * pools the same way. Fire-and-forget so shutdown isn't blocked on a
171
+ * slow drain, and clear the handle so a re-`setup()` rebuilds it.
172
+ */
173
+ override abortActiveOperations(): void {
174
+ super.abortActiveOperations();
175
+ if (this.servicePrincipalPool) {
176
+ this.log.info("closing memory SP pool");
177
+ const pool = this.servicePrincipalPool;
178
+ this.servicePrincipalPool = null;
179
+ pool.end().catch((err) => {
180
+ this.log.error("error closing memory SP pool", {
181
+ error: commonUtils.errorMessage(err),
182
+ });
183
+ });
184
+ }
185
+ }
186
+
153
187
  override exports() {
154
188
  return {
155
189
  /**
@@ -165,9 +199,9 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
165
199
  */
166
200
  get: (id: string): Agent | null => this.built?.agents[id] ?? null,
167
201
  /**
168
- * The agent `chatRoute` binds to when the client doesn't name
169
- * one. Resolves to `config.defaultAgent`, the first registered
170
- * id, or the built-in `default` fallback.
202
+ * The agent the client converses with when it doesn't name one.
203
+ * Resolves to `config.defaultAgent`, the first registered id, or
204
+ * the built-in `default` fallback.
171
205
  */
172
206
  getDefault: (): Agent | null =>
173
207
  (this.built && this.built.agents[this.built.defaultAgentId]) ?? null,
@@ -196,22 +230,16 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
196
230
 
197
231
  override clientConfig(): Record<string, unknown> {
198
232
  // AppKit mounts every plugin at `/api/<plugin.name>`. `this.name`
199
- // honors `config.name` overrides, so the published paths stay
200
- // accurate if someone remounts the plugin under a custom id.
233
+ // honors `config.name` overrides, so publishing `basePath` is
234
+ // enough for the client to stay correct under a custom mount id -
235
+ // the per-route segments are fixed (`MASTRA_ROUTES`) and the
236
+ // client (`MastraPluginClient`) derives every endpoint from
237
+ // `basePath`.
201
238
  // Return widens to `Record<string, unknown>` to satisfy the
202
239
  // base-class signature; consumers read it through the typed
203
240
  // `MastraClientConfig` shape via `usePluginClientConfig<...>(...)`.
204
- const basePath = `/api/${this.name}`;
205
241
  const config: MastraClientConfig = {
206
- basePath,
207
- chatPath: `${basePath}/route/chat`,
208
- chatPathTemplate: `${basePath}/route/chat/:agentId`,
209
- modelsPath: `${basePath}/models`,
210
- historyPath: `${basePath}/route/history`,
211
- historyPathTemplate: `${basePath}/route/history/:agentId`,
212
- embedPathTemplate: `${basePath}/embed/:type/:id`,
213
- suggestionsPath: `${basePath}/suggestions`,
214
- suggestionsPathTemplate: `${basePath}/suggestions/:agentId`,
242
+ basePath: `/api/${this.name}`,
215
243
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
216
244
  agents: Object.keys(this.built?.agents ?? {}),
217
245
  };
@@ -224,7 +252,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
224
252
  // be registered before the catch-all that forwards everything to
225
253
  // the Mastra subapp. Errors propagate to Express's default error
226
254
  // handler via `next(err)` so callers see the real SDK message.
227
- router.get("/models", (req, res, next) => {
255
+ router.get(MASTRA_ROUTES.models, (req, res, next) => {
228
256
  this.userScopedSelf(req)
229
257
  .listModels()
230
258
  .then((endpoints) => res.json({ endpoints }))
@@ -278,7 +306,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
278
306
  },
279
307
  };
280
308
 
281
- router.get("/embed/:type/:id", (req, res, next) => {
309
+ router.get(`${MASTRA_ROUTES.embed}/:type/:id`, (req, res, next) => {
282
310
  const type = req.params["type"] ?? "";
283
311
  const id = req.params["id"];
284
312
  const resolve = embedResolvers[type];
@@ -334,8 +362,8 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
334
362
  res.json({ questions: [] });
335
363
  });
336
364
  };
337
- router.get("/suggestions", handleSuggestions);
338
- router.get("/suggestions/:agentId", handleSuggestions);
365
+ router.get(MASTRA_ROUTES.suggestions, handleSuggestions);
366
+ router.get(`${MASTRA_ROUTES.suggestions}/:agentId`, handleSuggestions);
339
367
 
340
368
  router.use((req, res, next) => {
341
369
  if (!this.mastraApp) return res.status(503).end();
@@ -451,12 +479,25 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
451
479
  }
452
480
 
453
481
  private async buildAgentAndServer(): Promise<void> {
454
- // Per-agent memory factory. The builder resolves the Lakebase pool
455
- // lazily (on first agent that actually needs storage / vector) and
456
- // caches both the pool and the shared `PgVector` singleton so
457
- // registering N agents stays cheap. See `./memory.js`.
458
- const memoryBuilder = needsLakebase(this.config)
459
- ? createMemoryBuilder(this.config, this.context)
482
+ // Per-agent memory factory. When any storage / memory setting needs
483
+ // Postgres, stand up a dedicated service-principal pool first so
484
+ // memory acts as the app SP (owner of the `mastra_*` schemas),
485
+ // never the per-request OBO identity the chat turn runs under.
486
+ // `getPgConfig()` is read here, outside any `asUser` scope, so it
487
+ // returns the SP connection target + token refresh plus any
488
+ // `lakebase({ pool })` overrides; `require` turns a missing
489
+ // sibling into a clear wiring error. The builder caches the shared
490
+ // `PgVector` singleton so registering N agents stays cheap. See
491
+ // `./memory.js`.
492
+ if (needsLakebase(this.config)) {
493
+ const spPgConfig = appkitUtils
494
+ .require(this.context, lakebase, this.config)
495
+ .exports()
496
+ .getPgConfig();
497
+ this.servicePrincipalPool = await createServicePrincipalPool(spPgConfig);
498
+ }
499
+ const memoryBuilder = this.servicePrincipalPool
500
+ ? createMemoryBuilder(this.config, this.servicePrincipalPool)
460
501
  : undefined;
461
502
 
462
503
  this.log.debug("build:start", {
@@ -507,20 +548,28 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
507
548
  mastra: this.mastra,
508
549
  prefix: "",
509
550
  customApiRoutes: [
510
- chatRoute({ path: "/route/chat", agent: this.built.defaultAgentId }),
511
- chatRoute({ path: "/route/chat/:agentId" }),
512
551
  // `historyRoute` registers both GET (load) and DELETE
513
552
  // (clear) on the same path, so it returns an array we
514
553
  // splice in.
515
- ...historyRoute({ path: "/route/history", agent: this.built.defaultAgentId }),
516
- ...historyRoute({ path: "/route/history/:agentId" }),
554
+ ...historyRoute({
555
+ path: MASTRA_ROUTES.history,
556
+ agent: this.built.defaultAgentId,
557
+ }),
558
+ // Assert the `:agentId` template type: the per-package build's
559
+ // NodeNext resolution widens the imported `MASTRA_ROUTES.history`
560
+ // to `string` (the source/bundler typecheck keeps it a literal),
561
+ // which would otherwise drop this out of the dynamic-agent
562
+ // overload and demand a fixed `agent`.
563
+ ...historyRoute({
564
+ path: `${MASTRA_ROUTES.history}/:agentId` as `${string}:agentId`,
565
+ }),
517
566
  ],
518
567
  });
519
568
  await this.mastraServer.init();
520
569
  this.log.debug("build:done", {
521
570
  agents: Object.keys(this.built.agents),
522
571
  defaultAgent: this.built.defaultAgentId,
523
- routes: ["/route/chat", "/route/history", "/models"],
572
+ routes: ["/route/history", "/models", "/suggestions", "/embed/:type/:id"],
524
573
  instanceStorage: instanceStorage !== undefined,
525
574
  observability: observability !== undefined ? "mlflow" : "off",
526
575
  });