@dbx-tools/model 0.1.67 → 0.1.68

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/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@dbx-tools/model",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "dependencies": {
5
- "@dbx-tools/model-shared": "0.1.67",
6
- "@dbx-tools/shared": "0.1.67",
5
+ "@dbx-tools/model-shared": "0.1.68",
6
+ "@dbx-tools/shared": "0.1.68",
7
7
  "fuse.js": "^7.0.0"
8
8
  },
9
9
  "exports": {
10
10
  ".": {
11
- "source": "./src/index.ts",
12
11
  "types": "./dist/index.d.ts",
13
12
  "default": "./dist/index.js"
14
13
  }
@@ -20,8 +19,7 @@
20
19
  "main": "./dist/index.js",
21
20
  "types": "./dist/index.d.ts",
22
21
  "files": [
23
- "dist",
24
- "src"
22
+ "dist"
25
23
  ],
26
24
  "license": "Apache-2.0"
27
25
  }
package/src/classes.ts DELETED
@@ -1,75 +0,0 @@
1
- /**
2
- * Model-class ordering for the model service.
3
- *
4
- * `@dbx-tools/model-shared` declares the {@link ModelClass} values and
5
- * their schema; the *behavior* over those values - the chat capability
6
- * order, the "this class and below" ceiling, and coercing loose request
7
- * input to a class - lives here in the service so the shared
8
- * wire-format surface stays purely declarative.
9
- */
10
-
11
- import { ModelClass, ModelClassSchema } from "@dbx-tools/model-shared";
12
-
13
- /**
14
- * Chat capability ladder in descending order - most capable
15
- * ({@link ModelClass.ChatThinking}) first, least
16
- * ({@link ModelClass.ChatFast}) last. {@link ModelClass.Embedding} is a
17
- * separate modality and is deliberately absent: the ceiling never spans
18
- * it. This is the order used to filter "this class and below" and to
19
- * break ranking ties toward the more capable model.
20
- */
21
- export const CHAT_CLASS_ORDER: readonly ModelClass[] = [
22
- ModelClass.ChatThinking,
23
- ModelClass.ChatBalanced,
24
- ModelClass.ChatFast,
25
- ];
26
-
27
- /**
28
- * Every class in display order: the chat ladder followed by
29
- * {@link ModelClass.Embedding}. Used when flattening a full
30
- * classification (e.g. stamping the class onto each cached endpoint).
31
- */
32
- export const MODEL_CLASS_ORDER: readonly ModelClass[] = [
33
- ...CHAT_CLASS_ORDER,
34
- ModelClass.Embedding,
35
- ];
36
-
37
- /** Whether `cls` is one of the chat capability bands (vs. embedding). */
38
- export function isChatClass(cls: ModelClass): boolean {
39
- return CHAT_CLASS_ORDER.includes(cls);
40
- }
41
-
42
- /**
43
- * Coerce an arbitrary value (query string, header, body field) to a
44
- * {@link ModelClass}, returning `null` when it isn't a known class.
45
- * Lets a route accept a class request without throwing on junk input.
46
- *
47
- * Matches the full slug first (`"chat-fast"`, `"embedding"`), then -
48
- * for a bare chat band - retries with a `chat-` prefix, so the shorthand
49
- * `"thinking"` / `"balanced"` / `"fast"` resolves to the corresponding
50
- * `chat-*` class.
51
- */
52
- export function parseModelClass(value: unknown): ModelClass | null {
53
- const exact = ModelClassSchema.safeParse(value);
54
- if (exact.success) return exact.data;
55
- const prefixed = ModelClassSchema.safeParse(`chat-${value}`);
56
- return prefixed.success ? prefixed.data : null;
57
- }
58
-
59
- /**
60
- * Classes at or below `cls` in chat capability: the class itself plus
61
- * every less-capable chat band, in {@link CHAT_CLASS_ORDER}. Treats a
62
- * chat `cls` as a ceiling - requesting {@link ModelClass.ChatBalanced}
63
- * yields `[ChatBalanced, ChatFast]`, never
64
- * {@link ModelClass.ChatThinking} - so a class ask can degrade downward
65
- * to a smaller chat model but never escalate to a larger one.
66
- *
67
- * {@link ModelClass.Embedding} is its own modality, not a rung on the
68
- * chat ladder, so it yields just `[Embedding]`. An unrecognized class
69
- * yields the full chat ladder.
70
- */
71
- export function classesAtOrBelow(cls: ModelClass): ModelClass[] {
72
- if (cls === ModelClass.Embedding) return [ModelClass.Embedding];
73
- const index = CHAT_CLASS_ORDER.indexOf(cls);
74
- return index < 0 ? [...CHAT_CLASS_ORDER] : [...CHAT_CLASS_ORDER.slice(index)];
75
- }
package/src/fallback.ts DELETED
@@ -1,77 +0,0 @@
1
- /**
2
- * Server-side offline fallback opinion for model selection.
3
- *
4
- * When the live `/serving-endpoints` catalogue can't be read at all -
5
- * no user token, the service principal can't list, or the workspace is
6
- * unreachable - the resolver still has to name *some* endpoint. This
7
- * module holds that floor: a small, hard-coded set of well-known
8
- * Foundation Model API endpoint names, each bucketed into a chat
9
- * {@link ModelClass} by the shared {@link classifyByFamily} heuristic
10
- * (no classes are hard-coded here) and ordered best-first.
11
- *
12
- * This is deliberately *server-only*. A browser client never talks to
13
- * Databricks directly - it always goes through this server - so it has
14
- * nothing to fall back to and must not assume a stale, baked-in model
15
- * list; it consumes the live `/models` response instead. The pure
16
- * classifier ({@link classifyEndpoints}) is what the client shares.
17
- */
18
-
19
- import {
20
- classifyByFamily,
21
- type FamilyClass,
22
- ModelClass,
23
- } from "@dbx-tools/model-shared";
24
-
25
- /**
26
- * Small, last-resort set of well-known Foundation Model API endpoint
27
- * names, ordered best-first within each class by {@link classifyByFamily}.
28
- * Used only as the floor when the live `/serving-endpoints` catalogue
29
- * can't be read at resolve time; the live, score-driven classification
30
- * supersedes it whenever the workspace listing is available. Classes are
31
- * not hard-coded here - each name is classified by family heuristic.
32
- */
33
- const FALLBACK_MODEL_NAMES: readonly string[] = [
34
- "databricks-claude-opus-4-8",
35
- "databricks-gpt-5-5-pro",
36
- "databricks-gemini-3-1-pro",
37
- "databricks-claude-sonnet-4-6",
38
- "databricks-gpt-5-5",
39
- "databricks-meta-llama-3-3-70b-instruct",
40
- "databricks-claude-haiku-4-5",
41
- "databricks-gpt-5-nano",
42
- "databricks-meta-llama-3-1-8b-instruct",
43
- ];
44
-
45
- /**
46
- * Static fallback model ids for a chat class, drawn from the small
47
- * built-in {@link FALLBACK_MODEL_NAMES} list and ordered best-first by
48
- * family rank. Sync and workspace-independent: this is the *fallback
49
- * opinion* used to seed default lists or when the live catalogue is
50
- * unreachable - live resolution prefers `classifyEndpoints`. Returns
51
- * `[]` for {@link ModelClass.Embedding} (the floor is chat-only).
52
- */
53
- export function modelsForClass(cls: ModelClass): readonly string[] {
54
- return FALLBACK_MODEL_NAMES.map((name) => ({ name, c: classifyByFamily(name) }))
55
- .filter(
56
- (x): x is { name: string; c: FamilyClass } => x.c !== null && x.c.class === cls,
57
- )
58
- .sort((a, b) => b.c.rank - a.c.rank)
59
- .map((x) => x.name);
60
- }
61
-
62
- /** Top static fallback model id for a chat class. */
63
- export function modelForClass(cls: ModelClass): string {
64
- return modelsForClass(cls)[0]!;
65
- }
66
-
67
- /**
68
- * Priority-ordered fallback chain (ChatThinking -> ChatBalanced ->
69
- * ChatFast) over the small built-in list. The floor walked at resolve
70
- * time when no agent / plugin / env / request model is set *and* the
71
- * live catalogue yields nothing.
72
- */
73
- export const FALLBACK_MODEL_IDS: readonly string[] = [
74
- ...modelsForClass(ModelClass.ChatThinking),
75
- ...modelsForClass(ModelClass.ChatBalanced),
76
- ...modelsForClass(ModelClass.ChatFast),
77
- ];
package/src/index.ts DELETED
@@ -1,23 +0,0 @@
1
- /**
2
- * `@dbx-tools/model` public surface.
3
- *
4
- * Bundles the package's Node-side Model Serving access (cached
5
- * `/serving-endpoints` listing plus fuzzy name resolution), the
6
- * workspace-aware {@link resolveModel} selector, and the server-only
7
- * offline fallback opinion (`FALLBACK_MODEL_IDS` / `modelsForClass`)
8
- * with a re-export of the pure `@dbx-tools/model-shared` surface
9
- * (capability tiers, the score profile, the endpoint descriptor, and
10
- * the tier classifier) so a single `from "@dbx-tools/model"` import
11
- * serves server-side consumers.
12
- *
13
- * Browser-side consumers should import `@dbx-tools/model-shared`
14
- * directly: the serving driver pulls in `WorkspaceClient` and AppKit's
15
- * `CacheManager` and is Node-only, whereas the re-exported surface is
16
- * pure (types + sync functions) and safe for any runtime.
17
- */
18
-
19
- export * from "@dbx-tools/model-shared";
20
- export * from "./classes.js";
21
- export * from "./fallback.js";
22
- export * from "./resolve.js";
23
- export * from "./serving.js";
package/src/resolve.ts DELETED
@@ -1,303 +0,0 @@
1
- /**
2
- * Workspace-aware model selection.
3
- *
4
- * Given a caller's intent - a search string, a capability
5
- * {@link ModelClass} ceiling, both, or nothing - the toolkit returns
6
- * matching endpoints ranked by match quality then class, or collapses
7
- * to the single best id the workspace actually has, degrading from
8
- * "best in range" down to the static fallback floor. Selection is
9
- * chat-only: embedding endpoints surface only when `modelClass` is
10
- * explicitly {@link ModelClass.Embedding}.
11
- *
12
- * Ranking entry points (return a ranked list):
13
- *
14
- * - {@link rankModels} is the pure ranker over an endpoint list you
15
- * already hold. A chat `modelClass` acts as a ceiling: only that
16
- * band and the less-capable chat bands below it are eligible (see
17
- * {@link classesAtOrBelow}), so a `chat-balanced` ask can fall to
18
- * `chat-fast` but never escalate to `chat-thinking`.
19
- * - {@link searchModels} is the I/O wrapper: hand it a
20
- * `WorkspaceClient` and it lists `/serving-endpoints` (cached) then
21
- * ranks in one step.
22
- *
23
- * Single-selection entry points (return one id + how it was reached):
24
- *
25
- * - {@link resolveModel} is the pure selector; it delegates to
26
- * {@link rankModels} with `limit: 1` and adds the operator-pinned
27
- * fallback / static-floor safety net.
28
- * - {@link selectModel} is the I/O wrapper over {@link resolveModel}.
29
- */
30
-
31
- import {
32
- classifyEndpoints,
33
- ModelClass,
34
- type ModelQuery,
35
- type RankedModel,
36
- type ServingEndpointSummary,
37
- } from "@dbx-tools/model-shared";
38
-
39
- import { CHAT_CLASS_ORDER, classesAtOrBelow, MODEL_CLASS_ORDER } from "./classes.js";
40
- import { FALLBACK_MODEL_IDS, modelsForClass } from "./fallback.js";
41
- import {
42
- listServingEndpoints,
43
- searchServingEndpoints,
44
- type WorkspaceClientLike,
45
- } from "./serving.js";
46
-
47
- /** Caller intent passed to {@link resolveModel}. */
48
- export interface ResolveModelInput {
49
- /**
50
- * Explicit model id / loose name (per-request override, agent /
51
- * plugin default, or env var). When set it wins over `modelClass`
52
- * and `fallbacks`.
53
- */
54
- explicit?: string;
55
- /**
56
- * Fuzzy-match an `explicit` name against the live catalogue so loose
57
- * names like `"claude sonnet"` resolve. Default `true`. When `false`
58
- * the explicit input is returned verbatim (Databricks surfaces the
59
- * canonical 404 if it doesn't exist).
60
- */
61
- fuzzy?: boolean;
62
- /** Fuse.js threshold forwarded to {@link resolveModelId}. */
63
- threshold?: number;
64
- /**
65
- * Chat capability class to resolve when no `explicit` id is given.
66
- * The live catalogue is classified by its Foundation Model API scores
67
- * and the top available model in the class (and the chat bands below
68
- * it) wins, falling back to the class's small static list.
69
- */
70
- modelClass?: ModelClass;
71
- /**
72
- * Operator-supplied fallback ids tried *first* in the no-explicit,
73
- * no-class path (e.g. a regulated workspace pinned to an approved
74
- * subset), ahead of the auto-classified catalogue.
75
- */
76
- fallbacks?: readonly string[];
77
- }
78
-
79
- /** Outcome of {@link resolveModel}: the chosen id plus how it was reached. */
80
- export interface ResolvedModelSelection {
81
- modelId: string;
82
- source: "explicit" | "fuzzy-match" | "class" | "fallback";
83
- }
84
-
85
- /** Intent + catalogue knobs passed to {@link selectModel}. */
86
- export interface SelectModelInput extends ResolveModelInput {
87
- /** TTL override for the cached `/serving-endpoints` listing, in ms. */
88
- ttlMs?: number;
89
- }
90
-
91
- /** TTL override merged into a {@link ModelQuery} for {@link searchModels}. */
92
- export interface SearchModelsInput extends ModelQuery {
93
- /** TTL override for the cached `/serving-endpoints` listing, in ms. */
94
- ttlMs?: number;
95
- }
96
-
97
- /**
98
- * Round a Fuse score to the display precision so version siblings that
99
- * match a token identically (e.g. `opus-4-7` vs `opus-4-8` for the
100
- * query `"opus"`) tie on match and let the class / within-class rank
101
- * decide - which is what surfaces the newer, higher-quality sibling.
102
- */
103
- function matchBucket(score: number | undefined): number {
104
- return Math.round((score ?? 0) * 1000);
105
- }
106
-
107
- /**
108
- * Rank the live catalogue against a {@link ModelQuery}, best-first.
109
- *
110
- * Candidates are the classified endpoints in the eligible classes:
111
- * {@link classesAtOrBelow} the requested `modelClass`, or - when none is
112
- * given - the chat bands only ({@link CHAT_CLASS_ORDER}), so a general
113
- * ask never surfaces an embedding endpoint. Each class bucket is
114
- * already best-first from {@link classifyEndpoints}. Ranking is **match
115
- * then class**:
116
- *
117
- * 1. With a `search`, only endpoints matching it survive, ordered by
118
- * match distance (bucketed via {@link matchBucket} so near-identical
119
- * scores tie), then by class (more capable first), then by the stable
120
- * within-class rank.
121
- * 2. Without a `search`, the class-then-rank candidate order stands.
122
- *
123
- * A `limit` truncates the result. Returns `[]` when nothing is
124
- * eligible or matches - callers layer their own fallback.
125
- */
126
- export function rankModels(
127
- endpoints: readonly ServingEndpointSummary[],
128
- query: ModelQuery = {},
129
- ): RankedModel[] {
130
- const classified = classifyEndpoints(endpoints);
131
- const eligible =
132
- query.modelClass !== undefined
133
- ? classesAtOrBelow(query.modelClass)
134
- : CHAT_CLASS_ORDER;
135
-
136
- // Flatten eligible classes in capability order, carrying each
137
- // endpoint's class; bucket order is already best-first.
138
- const candidates: RankedModel[] = [];
139
- for (const modelClass of eligible) {
140
- for (const endpoint of classified[modelClass])
141
- candidates.push({ endpoint, modelClass });
142
- }
143
-
144
- const search = query.search?.trim();
145
- let ranked: RankedModel[];
146
- if (search) {
147
- const scores = new Map<string, number>();
148
- for (const match of searchServingEndpoints(
149
- search,
150
- candidates.map((c) => c.endpoint),
151
- query.threshold !== undefined ? { threshold: query.threshold } : {},
152
- )) {
153
- scores.set(match.endpoint.name, match.score);
154
- }
155
- // `Array.prototype.sort` is stable, so endpoints equal on match and
156
- // class keep their best-first within-class order.
157
- ranked = candidates
158
- .filter((c) => scores.has(c.endpoint.name))
159
- .map((c) => ({ ...c, score: scores.get(c.endpoint.name) }))
160
- .sort((a, b) => {
161
- const byMatch = matchBucket(a.score) - matchBucket(b.score);
162
- if (byMatch !== 0) return byMatch;
163
- return (
164
- MODEL_CLASS_ORDER.indexOf(a.modelClass) -
165
- MODEL_CLASS_ORDER.indexOf(b.modelClass)
166
- );
167
- });
168
- } else {
169
- ranked = candidates;
170
- }
171
-
172
- return query.limit !== undefined ? ranked.slice(0, Math.max(0, query.limit)) : ranked;
173
- }
174
-
175
- /**
176
- * Rank a workspace's catalogue in one call: list its
177
- * `/serving-endpoints` (cached) and run {@link rankModels} over the
178
- * result. The list counterpart to {@link selectModel}, for a consumer
179
- * that wants the full ranked set (a model picker, a CLI) rather than a
180
- * single id. Catalogue fetches fail loud: network / auth errors
181
- * propagate so the caller sees the real SDK message.
182
- *
183
- * @param host - Workspace host used as the cache key. Pass the value
184
- * resolved from `client.config.getHost()`.
185
- */
186
- export async function searchModels(
187
- client: WorkspaceClientLike,
188
- host: string,
189
- input: SearchModelsInput = {},
190
- ): Promise<RankedModel[]> {
191
- const endpoints = await listServingEndpoints(
192
- client,
193
- host,
194
- input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {},
195
- );
196
- return rankModels(endpoints, input);
197
- }
198
-
199
- /**
200
- * Resolve a model id for a workspace in one call: list its
201
- * `/serving-endpoints` (cached) and run {@link resolveModel} over the
202
- * result. This is the entry point for any consumer that holds a
203
- * `WorkspaceClient` and just wants a usable model name - a Lakeflow
204
- * job, a one-off script, or the Mastra plugin alike.
205
- *
206
- * Cheap exit: when an `explicit` name is given and `fuzzy` is off, the
207
- * catalogue is never fetched - the name is returned verbatim and
208
- * Databricks surfaces the canonical 404 if it doesn't exist. Catalogue
209
- * fetches otherwise fail loud: network / auth errors propagate so the
210
- * caller sees the real SDK message instead of a silent fallback.
211
- *
212
- * @param host - Workspace host used as the cache key. Pass the value
213
- * resolved from `client.config.getHost()`.
214
- */
215
- export async function selectModel(
216
- client: WorkspaceClientLike,
217
- host: string,
218
- input: SelectModelInput = {},
219
- ): Promise<ResolvedModelSelection> {
220
- if (input.explicit !== undefined && input.fuzzy === false) {
221
- return { modelId: input.explicit, source: "explicit" };
222
- }
223
- const endpoints = await listServingEndpoints(client, host, {
224
- ...(input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {}),
225
- });
226
- return resolveModel(endpoints, input);
227
- }
228
-
229
- /**
230
- * Resolve a single model id from the live catalogue and caller intent,
231
- * delegating the live selection to {@link rankModels} with `limit: 1`.
232
- *
233
- * 1. **Explicit ask**: with `fuzzy` off, returned verbatim; otherwise
234
- * fuzzy-ranked within the (optional) class ceiling and the best taken,
235
- * falling back to the input verbatim when nothing matches.
236
- * 2. **No explicit ask**: an operator-pinned `fallback` that exists in
237
- * the live catalogue wins first; then the ranked live catalogue
238
- * (class ceiling applied); then the static {@link FALLBACK_MODEL_IDS}
239
- * floor when the catalogue yields nothing in range.
240
- */
241
- export function resolveModel(
242
- endpoints: readonly ServingEndpointSummary[],
243
- input: ResolveModelInput = {},
244
- ): ResolvedModelSelection {
245
- if (input.explicit !== undefined) {
246
- if (input.fuzzy === false) {
247
- return { modelId: input.explicit, source: "explicit" };
248
- }
249
- const [top] = rankModels(endpoints, buildQuery(input, input.explicit));
250
- return { modelId: top?.endpoint.name ?? input.explicit, source: "fuzzy-match" };
251
- }
252
-
253
- // Operator-pinned fallbacks win when present and live (e.g. a
254
- // regulated workspace restricted to an approved subset).
255
- if (input.modelClass === undefined && input.fallbacks && input.fallbacks.length > 0) {
256
- const present = new Set(endpoints.map((e) => e.name));
257
- const pinned = input.fallbacks.find((id) => present.has(id));
258
- if (pinned) return { modelId: pinned, source: "fallback" };
259
- }
260
-
261
- const source = input.modelClass !== undefined ? "class" : "fallback";
262
- const [top] = rankModels(endpoints, buildQuery(input, undefined));
263
- if (top) return { modelId: top.endpoint.name, source };
264
-
265
- // Live catalogue yielded nothing in range: walk the static floor.
266
- const floor =
267
- input.modelClass !== undefined
268
- ? dedupe([...modelsForClass(input.modelClass), ...FALLBACK_MODEL_IDS])
269
- : dedupe([...(input.fallbacks ?? []), ...FALLBACK_MODEL_IDS]);
270
- return { modelId: pickFirstAvailable(floor, endpoints), source };
271
- }
272
-
273
- /** Build a {@link ModelQuery} from {@link ResolveModelInput} for the `limit: 1` delegation. */
274
- function buildQuery(input: ResolveModelInput, search: string | undefined): ModelQuery {
275
- return {
276
- ...(search !== undefined ? { search } : {}),
277
- ...(input.modelClass !== undefined ? { modelClass: input.modelClass } : {}),
278
- ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
279
- limit: 1,
280
- };
281
- }
282
-
283
- /** Drop duplicate ids while preserving first-seen order. */
284
- function dedupe(ids: readonly string[]): string[] {
285
- return [...new Set(ids)];
286
- }
287
-
288
- /**
289
- * Find the first id in `candidates` whose endpoint is present in
290
- * `endpoints`. Returns the top candidate when the workspace has none
291
- * of them so callers always get a string; an offline workspace then
292
- * receives a clean 404 from Databricks instead of a malformed config.
293
- */
294
- function pickFirstAvailable(
295
- candidates: readonly string[],
296
- endpoints: readonly ServingEndpointSummary[],
297
- ): string {
298
- const present = new Set(endpoints.map((e) => e.name));
299
- for (const candidate of candidates) {
300
- if (present.has(candidate)) return candidate;
301
- }
302
- return candidates[0] ?? FALLBACK_MODEL_IDS[0]!;
303
- }
package/src/serving.ts DELETED
@@ -1,363 +0,0 @@
1
- /**
2
- * Live Databricks Model Serving catalogue access.
3
- *
4
- * Lists the workspace's `/serving-endpoints` once per host and caches
5
- * the result with a TTL via AppKit's `CacheManager`, with concurrent
6
- * callers sharing one in-flight promise (the coalescing pattern of
7
- * Python's `cachetools-async`). Surfaces each endpoint as a stable
8
- * {@link ServingEndpointSummary} - including the Foundation Model API
9
- * `quality` / `speed` / `cost` profile when present, the classified
10
- * {@link ModelClass}, and (for embedding endpoints) the measured vector
11
- * `dimension` - and snaps loose, human-typed names to real endpoint ids
12
- * through `fuse.js` extended search so tokens like `"claude sonnet"`
13
- * resolve to `databricks-claude-sonnet-4-6`.
14
- *
15
- * The class stamp and embedding dimension are computed once per cache
16
- * load: every embedding endpoint is "pinged" in parallel and the
17
- * resulting vector length recorded, so the cost is paid on a cache miss,
18
- * not per read. The ping is best-effort - a failure logs at debug and
19
- * leaves `dimension` unset rather than failing the whole listing.
20
- */
21
-
22
- import { CacheManager } from "@databricks/appkit";
23
- import {
24
- classifyEndpoints,
25
- ModelClass,
26
- type ModelProfile,
27
- type ServingEndpointSummary,
28
- } from "@dbx-tools/model-shared";
29
- import type { appkitUtils } from "@dbx-tools/shared";
30
- import { commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
31
- import Fuse from "fuse.js";
32
-
33
- import { MODEL_CLASS_ORDER } from "./classes.js";
34
-
35
- const log = logUtils.logger("model/serving");
36
-
37
- /**
38
- * Structural type for the Databricks workspace client, re-exported from
39
- * `@dbx-tools/shared` so the rest of this package can keep importing it
40
- * from here. See `appkitUtils.WorkspaceClientLike` for the canonical
41
- * definition.
42
- */
43
- export type WorkspaceClientLike = appkitUtils.WorkspaceClientLike;
44
-
45
- /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
46
- export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
47
-
48
- /** Default Fuse.js score threshold below which a fuzzy match is accepted. */
49
- export const DEFAULT_FUZZY_THRESHOLD = 0.4;
50
-
51
- /** Cache key parts under which endpoint listings are stored. */
52
- const CACHE_KEY_NAMESPACE = "serving-endpoints";
53
-
54
- /**
55
- * Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`.
56
- * Endpoint visibility is effectively workspace-scoped (we cache by
57
- * host in the key parts), so a single shared key lets every user of
58
- * the same workspace share one cached fetch and coalesce on the
59
- * in-flight promise. Permissions can differ in theory, but the
60
- * Foundation Model API catalogue is the same view for every caller.
61
- */
62
- const SHARED_USER_KEY = "model-shared";
63
-
64
- /** Options for {@link listServingEndpoints}. */
65
- export interface ListServingEndpointsOptions {
66
- /**
67
- * Override the default cache TTL for this call, in milliseconds.
68
- * Forwarded to `CacheManager` as seconds.
69
- */
70
- ttlMs?: number;
71
- }
72
-
73
- /**
74
- * List Model Serving endpoints for the workspace owning `client`,
75
- * routed through AppKit's `CacheManager`. The manager gives us
76
- * everything `cachetools.TTLCache` provides plus what
77
- * `cachetools-async` adds on top: per-entry TTL, in-flight request
78
- * coalescing (concurrent callers share one fetch via the manager's
79
- * internal `inFlightRequests` map), bounded size, telemetry spans
80
- * (`cache.getOrExecute`), and optional Lakebase persistence so the
81
- * catalogue survives restarts when the lakebase plugin is wired up.
82
- *
83
- * Returns plain {@link ServingEndpointSummary} objects (a stable
84
- * subset of the SDK type) so cache hits never expose stale SDK
85
- * internals. Errors from `CacheManager` or the SDK fetch propagate
86
- * to the caller - we don't swallow them so users see the real
87
- * auth / network issue.
88
- *
89
- * @param host - Workspace host used as the cache key. Pass the value
90
- * resolved from `client.config.getHost()` so multi-host apps share
91
- * one entry per workspace.
92
- * @param options.ttlMs - Override the default TTL just for this call.
93
- * Forwarded to `CacheManager` as seconds.
94
- */
95
- export async function listServingEndpoints(
96
- client: WorkspaceClientLike,
97
- host: string,
98
- options: ListServingEndpointsOptions = {},
99
- ): Promise<ServingEndpointSummary[]> {
100
- const ttlSec = Math.max(
101
- 1,
102
- Math.round((options.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000),
103
- );
104
- return CacheManager.getInstanceSync().getOrExecute(
105
- [CACHE_KEY_NAMESPACE, host],
106
- () => fetchEndpoints(client),
107
- SHARED_USER_KEY,
108
- { ttl: ttlSec },
109
- );
110
- }
111
-
112
- /**
113
- * List the workspace's serving endpoints as minimal
114
- * {@link ServingEndpointSummary} objects straight from the SDK: no
115
- * caching, and none of the cache-load enrichment ({@link listServingEndpoints}
116
- * adds the {@link ModelClass} stamp and the embedding-dimension probe).
117
- * Use this for a one-shot, dependency-light listing - e.g. a CLI that
118
- * only needs names/tasks for fuzzy resolution and doesn't want AppKit's
119
- * `CacheManager` or the per-embedding ping cost. Prefer
120
- * {@link listServingEndpoints} for the cached, enriched view.
121
- */
122
- export async function listServingEndpointsUncached(
123
- client: WorkspaceClientLike,
124
- ): Promise<ServingEndpointSummary[]> {
125
- const out: ServingEndpointSummary[] = [];
126
- for await (const ep of client.servingEndpoints.list()) {
127
- if (!ep.name) continue;
128
- const profile = extractProfile(ep);
129
- out.push({
130
- name: ep.name,
131
- ...(ep.task !== undefined ? { task: ep.task } : {}),
132
- ...(ep.state?.ready !== undefined ? { state: String(ep.state.ready) } : {}),
133
- ...(ep.description !== undefined ? { description: ep.description } : {}),
134
- ...(profile ? { profile } : {}),
135
- });
136
- }
137
- return out;
138
- }
139
-
140
- async function fetchEndpoints(
141
- client: WorkspaceClientLike,
142
- ): Promise<ServingEndpointSummary[]> {
143
- const startedAt = Date.now();
144
- const out = await listServingEndpointsUncached(client);
145
- stampModelClasses(out);
146
- await measureEmbeddingDimensions(client, out);
147
- log.debug("listed", { count: out.length, elapsedMs: Date.now() - startedAt });
148
- return out;
149
- }
150
-
151
- /**
152
- * Stamp each summary's {@link ServingEndpointSummary.class} from the
153
- * relative classification of the whole set. Mutates `summaries` in
154
- * place. Endpoints the classifier doesn't recognize (custom, unscored,
155
- * non-LLM) are left without a class.
156
- */
157
- function stampModelClasses(summaries: ServingEndpointSummary[]): void {
158
- const buckets = classifyEndpoints(summaries);
159
- const classOf = new Map<string, ModelClass>();
160
- for (const cls of MODEL_CLASS_ORDER) {
161
- for (const ep of buckets[cls]) classOf.set(ep.name, cls);
162
- }
163
- for (const summary of summaries) {
164
- const cls = classOf.get(summary.name);
165
- if (cls !== undefined) summary.class = cls;
166
- }
167
- }
168
-
169
- /**
170
- * Measure the embedding vector dimension of every
171
- * {@link ModelClass.Embedding} endpoint by pinging it once, all in
172
- * parallel. Mutates the matching summaries in place with the resulting
173
- * `dimension`. Runs only on a cache miss (it's called from
174
- * {@link fetchEndpoints}), so the probe cost is amortized across the
175
- * cached TTL window. Per-endpoint failures are swallowed (logged at
176
- * warn) so one unreachable embedding model never fails the listing.
177
- */
178
- async function measureEmbeddingDimensions(
179
- client: WorkspaceClientLike,
180
- summaries: ServingEndpointSummary[],
181
- ): Promise<void> {
182
- await Promise.all(
183
- summaries
184
- .filter((s) => s.class === ModelClass.Embedding)
185
- .map(async (summary) => {
186
- const dimension = await pingEmbeddingDimension(client, summary.name);
187
- if (dimension !== undefined) summary.dimension = dimension;
188
- }),
189
- );
190
- }
191
-
192
- /**
193
- * Best-effort embedding dimension probe: query `name` with a tiny
194
- * `"ping"` input and return the length of the returned vector. Returns
195
- * `undefined` (and logs at warn) when the endpoint can't be queried or
196
- * returns no vector - the dimension is informational, never required.
197
- */
198
- async function pingEmbeddingDimension(
199
- client: WorkspaceClientLike,
200
- name: string,
201
- ): Promise<number | undefined> {
202
- try {
203
- const response = await client.servingEndpoints.query({ name, input: "ping" });
204
- const dimension = response.data?.[0]?.embedding?.length;
205
- if (typeof dimension === "number" && dimension > 0) return dimension;
206
- log.warn("embedding ping returned no vector", { name });
207
- return undefined;
208
- } catch (err) {
209
- log.warn("embedding ping failed", { name, error: commonUtils.errorMessage(err) });
210
- return undefined;
211
- }
212
- }
213
-
214
- /**
215
- * Pull the Foundation Model API `quality` / `speed` / `cost` scores
216
- * off a serving-endpoint listing entry. Databricks returns these
217
- * under `config.served_entities[].foundation_model.ai_gateway_model_profile`
218
- * (snake_case at the wire level, preserved verbatim by the SDK), but
219
- * the field is newer than the typed `FoundationModel` interface, so we
220
- * read it through a structural cast. Returns `undefined` when no
221
- * served entity carries a profile (custom models, embeddings, and
222
- * brand-new endpoints that Databricks has not scored yet).
223
- */
224
- function extractProfile(ep: unknown): ModelProfile | undefined {
225
- const entities = (
226
- ep as {
227
- config?: {
228
- served_entities?: Array<{
229
- foundation_model?: {
230
- ai_gateway_model_profile?: {
231
- quality?: number;
232
- speed?: number;
233
- cost?: number;
234
- };
235
- };
236
- }>;
237
- };
238
- }
239
- ).config?.served_entities;
240
- if (!entities) return undefined;
241
- for (const entity of entities) {
242
- const raw = entity.foundation_model?.ai_gateway_model_profile;
243
- if (!raw) continue;
244
- const profile: ModelProfile = {};
245
- if (Number.isFinite(raw.quality)) profile.quality = raw.quality;
246
- if (Number.isFinite(raw.speed)) profile.speed = raw.speed;
247
- if (Number.isFinite(raw.cost)) profile.cost = raw.cost;
248
- if (Object.keys(profile).length > 0) return profile;
249
- }
250
- return undefined;
251
- }
252
-
253
- /**
254
- * Force-evict cached endpoint listings via AppKit's `CacheManager`.
255
- * With a `host` deletes that one workspace's entry; without one
256
- * clears every cache entry on the manager (since `CacheManager`
257
- * doesn't expose a namespace-scoped clear, this is the brute-force
258
- * path - fine for tests, avoid in steady-state code).
259
- */
260
- export async function clearServingEndpointsCache(host?: string): Promise<void> {
261
- const cache = CacheManager.getInstanceSync();
262
- if (host) {
263
- const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
264
- await cache.delete(key);
265
- } else {
266
- await cache.clear();
267
- }
268
- }
269
-
270
- /**
271
- * Result of fuzzy-resolving a user-supplied model name against the
272
- * live endpoint list. `score` is Fuse.js's distance (`0` is exact,
273
- * `1` is no match); `matched` is `false` when the score exceeds the
274
- * configured threshold so callers can fall back to the original
275
- * input (Databricks will then return a clean 404).
276
- */
277
- export interface ResolvedModel {
278
- modelId: string;
279
- matched: boolean;
280
- score?: number;
281
- }
282
-
283
- /** Options accepted by {@link resolveModelId} / {@link searchServingEndpoints}. */
284
- export interface ResolveModelOptions {
285
- /** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
286
- threshold?: number;
287
- }
288
-
289
- /** A serving endpoint paired with its fuzzy-match distance for a query. */
290
- export interface ScoredEndpoint {
291
- endpoint: ServingEndpointSummary;
292
- /** Fuse.js distance: `0` is exact, `1` is no match. */
293
- score: number;
294
- }
295
-
296
- /**
297
- * Fuzzy-rank endpoints by how closely their `name` matches `input`,
298
- * best (lowest score) first, keeping only those within `threshold`:
299
- *
300
- * 1. An exact name match short-circuits to a single `score: 0` result.
301
- * 2. Otherwise the input is tokenized (dashes / underscores / spaces
302
- * become separators) and fed through Fuse.js extended search, which
303
- * AND-s each token with fuzzy matching enabled - the "tokenized
304
- * fuzzy match" a caller reaches for when they type `"claude sonnet"`
305
- * instead of the full endpoint name.
306
- *
307
- * Returns `[]` for an empty endpoint list or when `input` tokenizes to
308
- * nothing, so callers fall back to the raw input and let Databricks
309
- * surface a clean 404. This multi-result core is shared by
310
- * {@link resolveModelId} (single best) and the ranked `rankModels`
311
- * selector.
312
- */
313
- export function searchServingEndpoints(
314
- input: string,
315
- endpoints: readonly ServingEndpointSummary[],
316
- options: ResolveModelOptions = {},
317
- ): ScoredEndpoint[] {
318
- if (endpoints.length === 0) return [];
319
- for (const ep of endpoints) {
320
- if (ep.name === input) return [{ endpoint: ep, score: 0 }];
321
- }
322
- const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
323
- // Fuse 7.3 has no built-in tokenize hook; in extended search,
324
- // space-separated tokens are AND-ed with fuzzy matching enabled. We
325
- // lean on the shared tokenizer so the splitting rules stay
326
- // consistent with the rest of the toolkit.
327
- const query = Array.from(
328
- stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input),
329
- ).join(" ");
330
- if (!query) return [];
331
- const fuse = new Fuse(endpoints, {
332
- keys: ["name"],
333
- threshold,
334
- ignoreLocation: true,
335
- includeScore: true,
336
- useExtendedSearch: true,
337
- isCaseSensitive: false,
338
- });
339
- return fuse
340
- .search(query)
341
- .filter((r) => (r.score ?? 0) <= threshold)
342
- .map((r) => ({ endpoint: r.item, score: r.score ?? 0 }));
343
- }
344
-
345
- /**
346
- * Snap a user-supplied model name to the single closest configured
347
- * serving endpoint via {@link searchServingEndpoints}. Returns the
348
- * input unchanged with `matched: false` when nothing scores within the
349
- * threshold (or the catalogue is empty), so a deliberate model id is
350
- * never silently rewritten to a similar-looking neighbour and the
351
- * upstream call surfaces the canonical 404.
352
- */
353
- export function resolveModelId(
354
- input: string,
355
- endpoints: readonly ServingEndpointSummary[],
356
- options: ResolveModelOptions = {},
357
- ): ResolvedModel {
358
- const [best] = searchServingEndpoints(input, endpoints, options);
359
- if (best) {
360
- return { modelId: best.endpoint.name, matched: true, score: best.score };
361
- }
362
- return { modelId: input, matched: false };
363
- }