@dbx-tools/appkit-mastra 0.1.5 → 0.1.10

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.
Files changed (53) hide show
  1. package/README.md +394 -0
  2. package/index.ts +46 -37
  3. package/package.json +58 -47
  4. package/src/agents.ts +233 -62
  5. package/src/chart.ts +555 -285
  6. package/src/config.ts +282 -18
  7. package/src/filesystems.ts +1090 -0
  8. package/src/genie.ts +1004 -601
  9. package/src/history.ts +178 -79
  10. package/src/mcp.ts +105 -0
  11. package/src/memory.ts +129 -74
  12. package/src/mlflow.ts +149 -0
  13. package/src/model.ts +80 -408
  14. package/src/observability.ts +144 -0
  15. package/src/pagination.ts +34 -0
  16. package/src/plugin.ts +573 -59
  17. package/src/processors.ts +168 -0
  18. package/src/rest.ts +67 -0
  19. package/src/server.ts +243 -45
  20. package/src/serving-sanitize.ts +167 -0
  21. package/src/serving.ts +27 -224
  22. package/src/statement.ts +89 -0
  23. package/src/storage-schema.ts +41 -0
  24. package/src/summarize.ts +176 -0
  25. package/src/threads.ts +338 -0
  26. package/src/workspaces.ts +346 -0
  27. package/src/writer.ts +44 -0
  28. package/tsconfig.json +41 -0
  29. package/dist/index.d.ts +0 -19
  30. package/dist/index.js +0 -19
  31. package/dist/src/agents.d.ts +0 -306
  32. package/dist/src/agents.js +0 -393
  33. package/dist/src/chart.d.ts +0 -104
  34. package/dist/src/chart.js +0 -375
  35. package/dist/src/config.d.ts +0 -170
  36. package/dist/src/config.js +0 -12
  37. package/dist/src/genie.d.ts +0 -116
  38. package/dist/src/genie.js +0 -594
  39. package/dist/src/history.d.ts +0 -67
  40. package/dist/src/history.js +0 -158
  41. package/dist/src/memory.d.ts +0 -79
  42. package/dist/src/memory.js +0 -197
  43. package/dist/src/model.d.ts +0 -159
  44. package/dist/src/model.js +0 -423
  45. package/dist/src/plugin.d.ts +0 -130
  46. package/dist/src/plugin.js +0 -255
  47. package/dist/src/render-chart-route.d.ts +0 -33
  48. package/dist/src/render-chart-route.js +0 -120
  49. package/dist/src/server.d.ts +0 -46
  50. package/dist/src/server.js +0 -113
  51. package/dist/src/serving.d.ts +0 -156
  52. package/dist/src/serving.js +0 -214
  53. package/src/render-chart-route.ts +0 -141
@@ -1,156 +0,0 @@
1
- /**
2
- * Dynamic model resolution against Databricks Model Serving.
3
- *
4
- * Three concerns live here:
5
- *
6
- * 1. **Listing** - {@link listServingEndpoints} pulls the workspace's
7
- * `/serving-endpoints` via the SDK and caches the result per host
8
- * with a TTL. Concurrent callers share one in-flight promise (the
9
- * same coalescing pattern as Python's `cachetools-async`).
10
- * 2. **Fuzzy matching** - {@link resolveModelId} runs the user's input
11
- * through `fuse.js` extended search so loose tokens like
12
- * `"claude sonnet"` snap to `databricks-claude-sonnet-4-6` even
13
- * when typed without the full endpoint name.
14
- * 3. **Per-request override** - {@link extractModelOverride} pulls a
15
- * model name from the `X-Mastra-Model` header, `?model=` query
16
- * string, or `model` body field so the same agent can be exercised
17
- * against different endpoints without redeploying.
18
- *
19
- * `model.ts` glues these together inside the per-step model resolver;
20
- * `plugin.ts` exposes the cached list at `GET /models`.
21
- */
22
- import { type getExecutionContext } from "@databricks/appkit";
23
- import type { ServingEndpointSummary } from "@dbx-tools/appkit-mastra-shared";
24
- import type { MastraPluginConfig } from "./config.js";
25
- export type { ServingEndpointSummary };
26
- /**
27
- * Structural type for the Databricks workspace client. Derived from
28
- * AppKit's `ExecutionContext` so this module doesn't take a direct
29
- * dependency on `@databricks/sdk-experimental`; the dep flows in
30
- * transitively through `@databricks/appkit`.
31
- */
32
- type WorkspaceClientLike = ReturnType<typeof getExecutionContext>["client"];
33
- /**
34
- * `RequestContext` key under which {@link MastraServer} stores the
35
- * per-request model override (header / query / body). `model.ts`
36
- * reads it before falling back to the agent / plugin default.
37
- */
38
- export declare const MASTRA_MODEL_OVERRIDE_KEY = "mastra__model_override";
39
- /** HTTP header inspected for a per-request model override. */
40
- export declare const MODEL_OVERRIDE_HEADER = "x-mastra-model";
41
- /** Query string parameter inspected for a per-request model override. */
42
- export declare const MODEL_OVERRIDE_QUERY = "model";
43
- /** Body fields (in priority order) inspected for a per-request model override. */
44
- export declare const MODEL_OVERRIDE_BODY_FIELDS: readonly ["model", "modelId"];
45
- /**
46
- * List Model Serving endpoints for the workspace owning `client`,
47
- * routed through AppKit's `CacheManager`. The manager gives us
48
- * everything `cachetools.TTLCache` provides plus what
49
- * `cachetools-async` adds on top: per-entry TTL, in-flight request
50
- * coalescing (concurrent callers share one fetch via the manager's
51
- * internal `inFlightRequests` map), bounded size, telemetry spans
52
- * (`cache.getOrExecute`), and optional Lakebase persistence so the
53
- * catalogue survives restarts when the lakebase plugin is wired up.
54
- *
55
- * Returns plain {@link ServingEndpointSummary} objects (a stable
56
- * subset of the SDK type) so cache hits never expose stale SDK
57
- * internals. Errors from `CacheManager` or the SDK fetch propagate
58
- * to the caller - we don't swallow them so users see the real
59
- * auth / network issue.
60
- *
61
- * @param host - Workspace host used as the cache key. Pass the value
62
- * resolved from `client.config.getHost()` so multi-host apps share
63
- * one entry per workspace.
64
- * @param opts.ttlMs - Override the default TTL just for this call.
65
- * Forwarded to `CacheManager` as seconds.
66
- */
67
- export declare function listServingEndpoints(client: WorkspaceClientLike, host: string, opts?: {
68
- ttlMs?: number;
69
- }): Promise<ServingEndpointSummary[]>;
70
- /**
71
- * Force-evict cached endpoint listings via AppKit's `CacheManager`.
72
- * With a `host` deletes that one workspace's entry; without one
73
- * clears every cache entry on the manager (since `CacheManager`
74
- * doesn't expose a namespace-scoped clear, this is the brute-force
75
- * path - fine for tests, avoid in steady-state code).
76
- */
77
- export declare function clearServingEndpointsCache(host?: string): Promise<void>;
78
- /**
79
- * Result of fuzzy-resolving a user-supplied model name against the
80
- * live endpoint list. `score` is Fuse.js's distance (`0` is exact,
81
- * `1` is no match); `matched` is `false` when the score exceeds the
82
- * configured threshold so callers can fall back to the original
83
- * input (Databricks will then return a clean 404).
84
- */
85
- export interface ResolvedModel {
86
- modelId: string;
87
- matched: boolean;
88
- score?: number;
89
- }
90
- /** Options accepted by {@link resolveModelId}. */
91
- export interface ResolveModelOptions {
92
- /** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
93
- threshold?: number;
94
- }
95
- /**
96
- * Snap a user-supplied model name to the closest configured serving
97
- * endpoint:
98
- *
99
- * 1. Exact name match wins immediately (no fuzzy needed).
100
- * 2. Otherwise the input is tokenized (dashes / underscores / spaces
101
- * become separators) and fed through Fuse.js extended search,
102
- * which AND-s each token with fuzzy matching enabled. This is the
103
- * "tokenized fuzzy match" the user reaches for when they type
104
- * `"claude sonnet"` instead of the full endpoint name.
105
- * 3. If the best Fuse score is above `threshold`, return the input
106
- * unchanged and let the upstream call surface the 404. This keeps
107
- * deliberate model ids (e.g. brand new endpoints) from being
108
- * silently rewritten to a similar-looking neighbour.
109
- *
110
- * Pass an empty endpoint list to short-circuit fuzzy matching - the
111
- * input is returned verbatim. This is what {@link buildModel} does
112
- * when the workspace client can't be reached at resolve time.
113
- */
114
- export declare function resolveModelId(input: string, endpoints: readonly ServingEndpointSummary[], opts?: ResolveModelOptions): ResolvedModel;
115
- /**
116
- * Minimal Express-ish request shape used by {@link extractModelOverride}.
117
- * Keeps this module independent of `express` so the helper can be
118
- * reused from non-Express adapters.
119
- */
120
- export interface ModelOverrideRequest {
121
- headers?: Record<string, string | string[] | undefined>;
122
- query?: Record<string, unknown> | undefined;
123
- body?: unknown;
124
- }
125
- /**
126
- * Pull a model override out of a single HTTP request, checking
127
- * sources in priority order:
128
- *
129
- * 1. `X-Mastra-Model` header
130
- * 2. `?model=` query string parameter
131
- * 3. Body field (`model` or `modelId`, in that order)
132
- *
133
- * Returns `null` when nothing is set, so callers can wrap with
134
- * `if (override) ...` without juggling empty strings. Body inspection
135
- * is lenient - any plain object with one of the configured keys
136
- * counts, mirroring how AI SDK chat clients pass arbitrary metadata
137
- * alongside `messages`.
138
- */
139
- export declare function extractModelOverride(req: ModelOverrideRequest): string | null;
140
- /**
141
- * Read the fuzzy-resolution config knobs off the plugin config with
142
- * defaults applied. Kept here so `buildModel` and the `/models` route
143
- * agree on what "enabled" means.
144
- *
145
- * `fallbacks` is the priority-ordered list `pickModelId` walks when
146
- * nothing explicit is set; defaults live in `model.ts`
147
- * (`FALLBACK_MODEL_IDS`) and are passed in by callers to avoid a
148
- * circular import between `serving.ts` and `model.ts`.
149
- */
150
- export declare function resolveServingConfig(config: MastraPluginConfig, defaultFallbacks?: readonly string[]): {
151
- ttlMs: number;
152
- threshold: number;
153
- fuzzy: boolean;
154
- allowOverride: boolean;
155
- fallbacks: readonly string[];
156
- };
@@ -1,214 +0,0 @@
1
- /**
2
- * Dynamic model resolution against Databricks Model Serving.
3
- *
4
- * Three concerns live here:
5
- *
6
- * 1. **Listing** - {@link listServingEndpoints} pulls the workspace's
7
- * `/serving-endpoints` via the SDK and caches the result per host
8
- * with a TTL. Concurrent callers share one in-flight promise (the
9
- * same coalescing pattern as Python's `cachetools-async`).
10
- * 2. **Fuzzy matching** - {@link resolveModelId} runs the user's input
11
- * through `fuse.js` extended search so loose tokens like
12
- * `"claude sonnet"` snap to `databricks-claude-sonnet-4-6` even
13
- * when typed without the full endpoint name.
14
- * 3. **Per-request override** - {@link extractModelOverride} pulls a
15
- * model name from the `X-Mastra-Model` header, `?model=` query
16
- * string, or `model` body field so the same agent can be exercised
17
- * against different endpoints without redeploying.
18
- *
19
- * `model.ts` glues these together inside the per-step model resolver;
20
- * `plugin.ts` exposes the cached list at `GET /models`.
21
- */
22
- import { CacheManager } from "@databricks/appkit";
23
- import { stringUtils } from "@dbx-tools/appkit-shared";
24
- import Fuse from "fuse.js";
25
- /**
26
- * `RequestContext` key under which {@link MastraServer} stores the
27
- * per-request model override (header / query / body). `model.ts`
28
- * reads it before falling back to the agent / plugin default.
29
- */
30
- export const MASTRA_MODEL_OVERRIDE_KEY = "mastra__model_override";
31
- /** HTTP header inspected for a per-request model override. */
32
- export const MODEL_OVERRIDE_HEADER = "x-mastra-model";
33
- /** Query string parameter inspected for a per-request model override. */
34
- export const MODEL_OVERRIDE_QUERY = "model";
35
- /** Body fields (in priority order) inspected for a per-request model override. */
36
- export const MODEL_OVERRIDE_BODY_FIELDS = ["model", "modelId"];
37
- /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
38
- const DEFAULT_TTL_MS = 5 * 60 * 1000;
39
- /** Default Fuse.js score threshold below which a fuzzy match is accepted. */
40
- const DEFAULT_FUZZY_THRESHOLD = 0.4;
41
- /** Cache key parts under which endpoint listings are stored. */
42
- const CACHE_KEY_NAMESPACE = "mastra:serving-endpoints";
43
- /**
44
- * Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`.
45
- * Endpoint visibility is effectively workspace-scoped (we cache by
46
- * host in the key parts), so a single shared key lets every user of
47
- * the same workspace share one cached fetch and coalesce on the
48
- * in-flight promise. Permissions can differ in theory, but the
49
- * Foundation Model API catalogue is the same view for every caller.
50
- */
51
- const SHARED_USER_KEY = "mastra-shared";
52
- /**
53
- * List Model Serving endpoints for the workspace owning `client`,
54
- * routed through AppKit's `CacheManager`. The manager gives us
55
- * everything `cachetools.TTLCache` provides plus what
56
- * `cachetools-async` adds on top: per-entry TTL, in-flight request
57
- * coalescing (concurrent callers share one fetch via the manager's
58
- * internal `inFlightRequests` map), bounded size, telemetry spans
59
- * (`cache.getOrExecute`), and optional Lakebase persistence so the
60
- * catalogue survives restarts when the lakebase plugin is wired up.
61
- *
62
- * Returns plain {@link ServingEndpointSummary} objects (a stable
63
- * subset of the SDK type) so cache hits never expose stale SDK
64
- * internals. Errors from `CacheManager` or the SDK fetch propagate
65
- * to the caller - we don't swallow them so users see the real
66
- * auth / network issue.
67
- *
68
- * @param host - Workspace host used as the cache key. Pass the value
69
- * resolved from `client.config.getHost()` so multi-host apps share
70
- * one entry per workspace.
71
- * @param opts.ttlMs - Override the default TTL just for this call.
72
- * Forwarded to `CacheManager` as seconds.
73
- */
74
- export async function listServingEndpoints(client, host, opts = {}) {
75
- const ttlSec = Math.max(1, Math.round((opts.ttlMs ?? DEFAULT_TTL_MS) / 1000));
76
- return CacheManager.getInstanceSync().getOrExecute([CACHE_KEY_NAMESPACE, host], () => fetchEndpoints(client), SHARED_USER_KEY, { ttl: ttlSec });
77
- }
78
- async function fetchEndpoints(client) {
79
- const out = [];
80
- for await (const ep of client.servingEndpoints.list()) {
81
- if (!ep.name)
82
- continue;
83
- out.push({
84
- name: ep.name,
85
- ...(ep.task !== undefined ? { task: ep.task } : {}),
86
- ...(ep.state?.ready !== undefined ? { state: String(ep.state.ready) } : {}),
87
- ...(ep.description !== undefined ? { description: ep.description } : {}),
88
- });
89
- }
90
- return out;
91
- }
92
- /**
93
- * Force-evict cached endpoint listings via AppKit's `CacheManager`.
94
- * With a `host` deletes that one workspace's entry; without one
95
- * clears every cache entry on the manager (since `CacheManager`
96
- * doesn't expose a namespace-scoped clear, this is the brute-force
97
- * path - fine for tests, avoid in steady-state code).
98
- */
99
- export async function clearServingEndpointsCache(host) {
100
- const cache = CacheManager.getInstanceSync();
101
- if (host) {
102
- const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
103
- await cache.delete(key);
104
- }
105
- else {
106
- await cache.clear();
107
- }
108
- }
109
- /**
110
- * Snap a user-supplied model name to the closest configured serving
111
- * endpoint:
112
- *
113
- * 1. Exact name match wins immediately (no fuzzy needed).
114
- * 2. Otherwise the input is tokenized (dashes / underscores / spaces
115
- * become separators) and fed through Fuse.js extended search,
116
- * which AND-s each token with fuzzy matching enabled. This is the
117
- * "tokenized fuzzy match" the user reaches for when they type
118
- * `"claude sonnet"` instead of the full endpoint name.
119
- * 3. If the best Fuse score is above `threshold`, return the input
120
- * unchanged and let the upstream call surface the 404. This keeps
121
- * deliberate model ids (e.g. brand new endpoints) from being
122
- * silently rewritten to a similar-looking neighbour.
123
- *
124
- * Pass an empty endpoint list to short-circuit fuzzy matching - the
125
- * input is returned verbatim. This is what {@link buildModel} does
126
- * when the workspace client can't be reached at resolve time.
127
- */
128
- export function resolveModelId(input, endpoints, opts = {}) {
129
- if (endpoints.length === 0) {
130
- return { modelId: input, matched: false };
131
- }
132
- for (const ep of endpoints) {
133
- if (ep.name === input) {
134
- return { modelId: ep.name, matched: true, score: 0 };
135
- }
136
- }
137
- const threshold = opts.threshold ?? DEFAULT_FUZZY_THRESHOLD;
138
- const fuse = new Fuse(endpoints, {
139
- keys: ["name"],
140
- threshold,
141
- ignoreLocation: true,
142
- includeScore: true,
143
- useExtendedSearch: true,
144
- isCaseSensitive: false,
145
- });
146
- // Fuse 7.3 has no built-in tokenize hook; in extended search,
147
- // space-separated tokens are AND-ed with fuzzy matching enabled. We
148
- // lean on the shared tokenizer so the splitting rules stay
149
- // consistent with the rest of the toolkit.
150
- const query = Array.from(stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input)).join(" ");
151
- if (!query)
152
- return { modelId: input, matched: false };
153
- const results = fuse.search(query);
154
- const best = results[0];
155
- if (best?.item.name && (best.score ?? 0) <= threshold) {
156
- return { modelId: best.item.name, matched: true, score: best.score };
157
- }
158
- return { modelId: input, matched: false };
159
- }
160
- /**
161
- * Pull a model override out of a single HTTP request, checking
162
- * sources in priority order:
163
- *
164
- * 1. `X-Mastra-Model` header
165
- * 2. `?model=` query string parameter
166
- * 3. Body field (`model` or `modelId`, in that order)
167
- *
168
- * Returns `null` when nothing is set, so callers can wrap with
169
- * `if (override) ...` without juggling empty strings. Body inspection
170
- * is lenient - any plain object with one of the configured keys
171
- * counts, mirroring how AI SDK chat clients pass arbitrary metadata
172
- * alongside `messages`.
173
- */
174
- export function extractModelOverride(req) {
175
- const headers = req.headers;
176
- if (headers) {
177
- const headerVal = stringUtils.firstNonEmpty(headers[MODEL_OVERRIDE_HEADER] ?? headers[MODEL_OVERRIDE_HEADER.toLowerCase()]);
178
- if (headerVal)
179
- return headerVal;
180
- }
181
- if (req.query) {
182
- const queryVal = stringUtils.firstNonEmpty(req.query[MODEL_OVERRIDE_QUERY]);
183
- if (queryVal)
184
- return queryVal;
185
- }
186
- if (req.body && typeof req.body === "object") {
187
- const record = req.body;
188
- for (const field of MODEL_OVERRIDE_BODY_FIELDS) {
189
- const bodyVal = stringUtils.firstNonEmpty(record[field]);
190
- if (bodyVal)
191
- return bodyVal;
192
- }
193
- }
194
- return null;
195
- }
196
- /**
197
- * Read the fuzzy-resolution config knobs off the plugin config with
198
- * defaults applied. Kept here so `buildModel` and the `/models` route
199
- * agree on what "enabled" means.
200
- *
201
- * `fallbacks` is the priority-ordered list `pickModelId` walks when
202
- * nothing explicit is set; defaults live in `model.ts`
203
- * (`FALLBACK_MODEL_IDS`) and are passed in by callers to avoid a
204
- * circular import between `serving.ts` and `model.ts`.
205
- */
206
- export function resolveServingConfig(config, defaultFallbacks = []) {
207
- return {
208
- ttlMs: config.modelCacheTtlMs ?? DEFAULT_TTL_MS,
209
- threshold: config.modelFuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD,
210
- fuzzy: config.modelFuzzyMatch !== false,
211
- allowOverride: config.modelOverride !== false,
212
- fallbacks: config.defaultModelFallbacks ?? defaultFallbacks,
213
- };
214
- }
@@ -1,141 +0,0 @@
1
- /**
2
- * Chart-render HTTP endpoint for the Mastra plugin.
3
- *
4
- * The `render_data` tool returns immediately with a `chartId` and
5
- * emits the dataset over `ctx.writer`; the client then POSTs that
6
- * dataset to this endpoint to actually run the chart-planner
7
- * agent and get back an Echarts `EChartsOption` JSON. Planning
8
- * happens out-of-band so the calling agent's response stream
9
- * doesn't sit idle waiting for it - the model can finish the
10
- * report while the client is still rendering charts.
11
- *
12
- * Auth flows through the standard Mastra middleware: the route
13
- * sits in the same dispatcher pipeline as `chatRoute` /
14
- * `historyRoute`, so by the time the handler runs the
15
- * `RequestContext` is populated with the workspace user and
16
- * the chart-planner's model resolver has the OBO token it
17
- * needs.
18
- */
19
-
20
- import type {
21
- RenderChartRequest,
22
- RenderChartResponse,
23
- } from "@dbx-tools/appkit-mastra-shared";
24
- import { registerApiRoute } from "@mastra/core/server";
25
-
26
- import { runChartPlanner } from "./chart.js";
27
- import type { MastraPluginConfig } from "./config.js";
28
-
29
- /** Hard cap so a misbehaving client can't hand us a million-row payload. */
30
- const MAX_ROWS = 5_000;
31
- /**
32
- * Hard cap on the JSON body the route accepts (in bytes). Mirrors
33
- * the same intent as {@link MAX_ROWS}: bound the chart-planner's
34
- * prompt size and protect against accidental denial-of-service
35
- * from a runaway tool that ships an enormous payload.
36
- */
37
- const MAX_BODY_BYTES = 2 * 1024 * 1024;
38
-
39
- /** Options accepted by {@link renderChartRoute}. */
40
- export interface RenderChartRouteOptions {
41
- path: string;
42
- config: MastraPluginConfig;
43
- }
44
-
45
- /**
46
- * Register a `POST <path>` Mastra custom API route that runs the
47
- * chart-planner agent against a dataset and returns an Echarts
48
- * `EChartsOption` JSON.
49
- *
50
- * Body shape: {@link RenderChartRequest}; response:
51
- * {@link RenderChartResponse}.
52
- */
53
- export function renderChartRoute(options: RenderChartRouteOptions) {
54
- const { path, config } = options;
55
- return registerApiRoute(path, {
56
- method: "POST",
57
- handler: async (c) => {
58
- const requestContext = c.get("requestContext");
59
-
60
- // Hono parses the body as JSON; we still validate shape /
61
- // size since the tool's structured output is a contract,
62
- // not a guarantee, and the route is publicly mountable.
63
- const raw = (await c.req.json().catch(() => null)) as unknown;
64
- const validation = validateBody(raw);
65
- if ("error" in validation) {
66
- return c.json({ error: validation.error }, 400);
67
- }
68
- const { title, description, data } = validation.body;
69
-
70
- try {
71
- const result = await runChartPlanner({
72
- config,
73
- ...(requestContext ? { requestContext } : {}),
74
- title,
75
- ...(description ? { description } : {}),
76
- data,
77
- });
78
- const payload: RenderChartResponse = {
79
- option: result.option,
80
- chartType: result.chartType,
81
- };
82
- return c.json(payload);
83
- } catch (err) {
84
- const message = err instanceof Error ? err.message : String(err);
85
- return c.json({ error: message }, 500);
86
- }
87
- },
88
- });
89
- }
90
-
91
- type ValidationResult =
92
- | { body: RenderChartRequest }
93
- | { error: string };
94
-
95
- /**
96
- * Best-effort body validation. Surfaces a 400 for malformed input
97
- * instead of letting a downstream `.map` / `.length` blow up
98
- * inside the planner agent. Field-level shape mirrors
99
- * {@link RenderChartRequest}.
100
- */
101
- function validateBody(raw: unknown): ValidationResult {
102
- if (!raw || typeof raw !== "object") {
103
- return { error: "request body must be a JSON object" };
104
- }
105
- const r = raw as Record<string, unknown>;
106
- const title = r.title;
107
- if (typeof title !== "string" || title.length === 0) {
108
- return { error: "`title` must be a non-empty string" };
109
- }
110
- if (r.description !== undefined && typeof r.description !== "string") {
111
- return { error: "`description` must be a string when provided" };
112
- }
113
- if (!Array.isArray(r.data)) {
114
- return { error: "`data` must be an array of row objects" };
115
- }
116
- if (r.data.length === 0) {
117
- return { error: "`data` must contain at least one row" };
118
- }
119
- if (r.data.length > MAX_ROWS) {
120
- return { error: `\`data\` exceeds the per-request limit of ${MAX_ROWS} rows` };
121
- }
122
- for (const [i, row] of r.data.entries()) {
123
- if (!row || typeof row !== "object" || Array.isArray(row)) {
124
- return { error: `data[${i}] must be a plain object` };
125
- }
126
- }
127
- // Approximate body-size check; spares us pulling Buffer in.
128
- const approximateBytes = JSON.stringify(r.data).length;
129
- if (approximateBytes > MAX_BODY_BYTES) {
130
- return {
131
- error: `\`data\` exceeds the per-request size limit of ${MAX_BODY_BYTES} bytes`,
132
- };
133
- }
134
- return {
135
- body: {
136
- title,
137
- ...(typeof r.description === "string" ? { description: r.description } : {}),
138
- data: r.data as Array<Record<string, unknown>>,
139
- },
140
- };
141
- }