@dbx-tools/model 0.1.42 → 0.1.48

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/serving.ts CHANGED
@@ -6,26 +6,41 @@
6
6
  * callers sharing one in-flight promise (the coalescing pattern of
7
7
  * Python's `cachetools-async`). Surfaces each endpoint as a stable
8
8
  * {@link ServingEndpointSummary} - including the Foundation Model API
9
- * `quality` / `speed` / `cost` profile when present - and snaps loose,
10
- * human-typed names to real endpoint ids through `fuse.js` extended
11
- * search so tokens like `"claude sonnet"` resolve to
12
- * `databricks-claude-sonnet-4-6`.
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.
13
20
  */
14
21
 
15
- import { CacheManager, type getExecutionContext } from "@databricks/appkit";
16
- import type { ModelProfile, ServingEndpointSummary } from "@dbx-tools/model-shared";
17
- import { logUtils, stringUtils } from "@dbx-tools/shared";
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";
18
31
  import Fuse from "fuse.js";
19
32
 
33
+ import { MODEL_CLASS_ORDER } from "./classes.js";
34
+
20
35
  const log = logUtils.logger("model/serving");
21
36
 
22
37
  /**
23
- * Structural type for the Databricks workspace client. Derived from
24
- * AppKit's `ExecutionContext` so this module doesn't take a direct
25
- * dependency on `@databricks/sdk-experimental`; the dep flows in
26
- * transitively through `@databricks/appkit`.
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.
27
42
  */
28
- export type WorkspaceClientLike = ReturnType<typeof getExecutionContext>["client"];
43
+ export type WorkspaceClientLike = appkitUtils.WorkspaceClientLike;
29
44
 
30
45
  /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
31
46
  export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -46,6 +61,15 @@ const CACHE_KEY_NAMESPACE = "serving-endpoints";
46
61
  */
47
62
  const SHARED_USER_KEY = "model-shared";
48
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
+
49
73
  /**
50
74
  * List Model Serving endpoints for the workspace owning `client`,
51
75
  * routed through AppKit's `CacheManager`. The manager gives us
@@ -65,17 +89,17 @@ const SHARED_USER_KEY = "model-shared";
65
89
  * @param host - Workspace host used as the cache key. Pass the value
66
90
  * resolved from `client.config.getHost()` so multi-host apps share
67
91
  * one entry per workspace.
68
- * @param opts.ttlMs - Override the default TTL just for this call.
92
+ * @param options.ttlMs - Override the default TTL just for this call.
69
93
  * Forwarded to `CacheManager` as seconds.
70
94
  */
71
95
  export async function listServingEndpoints(
72
96
  client: WorkspaceClientLike,
73
97
  host: string,
74
- opts: { ttlMs?: number } = {},
98
+ options: ListServingEndpointsOptions = {},
75
99
  ): Promise<ServingEndpointSummary[]> {
76
100
  const ttlSec = Math.max(
77
101
  1,
78
- Math.round((opts.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000),
102
+ Math.round((options.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000),
79
103
  );
80
104
  return CacheManager.getInstanceSync().getOrExecute(
81
105
  [CACHE_KEY_NAMESPACE, host],
@@ -101,10 +125,75 @@ async function fetchEndpoints(
101
125
  ...(profile ? { profile } : {}),
102
126
  });
103
127
  }
128
+ stampModelClasses(out);
129
+ await measureEmbeddingDimensions(client, out);
104
130
  log.debug("listed", { count: out.length, elapsedMs: Date.now() - startedAt });
105
131
  return out;
106
132
  }
107
133
 
134
+ /**
135
+ * Stamp each summary's {@link ServingEndpointSummary.class} from the
136
+ * relative classification of the whole set. Mutates `summaries` in
137
+ * place. Endpoints the classifier doesn't recognize (custom, unscored,
138
+ * non-LLM) are left without a class.
139
+ */
140
+ function stampModelClasses(summaries: ServingEndpointSummary[]): void {
141
+ const buckets = classifyEndpoints(summaries);
142
+ const classOf = new Map<string, ModelClass>();
143
+ for (const cls of MODEL_CLASS_ORDER) {
144
+ for (const ep of buckets[cls]) classOf.set(ep.name, cls);
145
+ }
146
+ for (const summary of summaries) {
147
+ const cls = classOf.get(summary.name);
148
+ if (cls !== undefined) summary.class = cls;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Measure the embedding vector dimension of every
154
+ * {@link ModelClass.Embedding} endpoint by pinging it once, all in
155
+ * parallel. Mutates the matching summaries in place with the resulting
156
+ * `dimension`. Runs only on a cache miss (it's called from
157
+ * {@link fetchEndpoints}), so the probe cost is amortized across the
158
+ * cached TTL window. Per-endpoint failures are swallowed (logged at
159
+ * warn) so one unreachable embedding model never fails the listing.
160
+ */
161
+ async function measureEmbeddingDimensions(
162
+ client: WorkspaceClientLike,
163
+ summaries: ServingEndpointSummary[],
164
+ ): Promise<void> {
165
+ await Promise.all(
166
+ summaries
167
+ .filter((s) => s.class === ModelClass.Embedding)
168
+ .map(async (summary) => {
169
+ const dimension = await pingEmbeddingDimension(client, summary.name);
170
+ if (dimension !== undefined) summary.dimension = dimension;
171
+ }),
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Best-effort embedding dimension probe: query `name` with a tiny
177
+ * `"ping"` input and return the length of the returned vector. Returns
178
+ * `undefined` (and logs at warn) when the endpoint can't be queried or
179
+ * returns no vector - the dimension is informational, never required.
180
+ */
181
+ async function pingEmbeddingDimension(
182
+ client: WorkspaceClientLike,
183
+ name: string,
184
+ ): Promise<number | undefined> {
185
+ try {
186
+ const response = await client.servingEndpoints.query({ name, input: "ping" });
187
+ const dimension = response.data?.[0]?.embedding?.length;
188
+ if (typeof dimension === "number" && dimension > 0) return dimension;
189
+ log.warn("embedding ping returned no vector", { name });
190
+ return undefined;
191
+ } catch (err) {
192
+ log.warn("embedding ping failed", { name, error: commonUtils.errorMessage(err) });
193
+ return undefined;
194
+ }
195
+ }
196
+
108
197
  /**
109
198
  * Pull the Foundation Model API `quality` / `speed` / `cost` scores
110
199
  * off a serving-endpoint listing entry. Databricks returns these
@@ -174,55 +263,46 @@ export interface ResolvedModel {
174
263
  score?: number;
175
264
  }
176
265
 
177
- /** Options accepted by {@link resolveModelId}. */
266
+ /** Options accepted by {@link resolveModelId} / {@link searchServingEndpoints}. */
178
267
  export interface ResolveModelOptions {
179
268
  /** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
180
269
  threshold?: number;
181
270
  }
182
271
 
272
+ /** A serving endpoint paired with its fuzzy-match distance for a query. */
273
+ export interface ScoredEndpoint {
274
+ endpoint: ServingEndpointSummary;
275
+ /** Fuse.js distance: `0` is exact, `1` is no match. */
276
+ score: number;
277
+ }
278
+
183
279
  /**
184
- * Snap a user-supplied model name to the closest configured serving
185
- * endpoint:
280
+ * Fuzzy-rank endpoints by how closely their `name` matches `input`,
281
+ * best (lowest score) first, keeping only those within `threshold`:
186
282
  *
187
- * 1. Exact name match wins immediately (no fuzzy needed).
283
+ * 1. An exact name match short-circuits to a single `score: 0` result.
188
284
  * 2. Otherwise the input is tokenized (dashes / underscores / spaces
189
- * become separators) and fed through Fuse.js extended search,
190
- * which AND-s each token with fuzzy matching enabled. This is the
191
- * "tokenized fuzzy match" the user reaches for when they type
192
- * `"claude sonnet"` instead of the full endpoint name.
193
- * 3. If the best Fuse score is above `threshold`, return the input
194
- * unchanged and let the upstream call surface the 404. This keeps
195
- * deliberate model ids (e.g. brand new endpoints) from being
196
- * silently rewritten to a similar-looking neighbour.
285
+ * become separators) and fed through Fuse.js extended search, which
286
+ * AND-s each token with fuzzy matching enabled - the "tokenized
287
+ * fuzzy match" a caller reaches for when they type `"claude sonnet"`
288
+ * instead of the full endpoint name.
197
289
  *
198
- * Pass an empty endpoint list to short-circuit fuzzy matching - the
199
- * input is returned verbatim. This is what callers do when the
200
- * workspace client can't be reached at resolve time.
290
+ * Returns `[]` for an empty endpoint list or when `input` tokenizes to
291
+ * nothing, so callers fall back to the raw input and let Databricks
292
+ * surface a clean 404. This multi-result core is shared by
293
+ * {@link resolveModelId} (single best) and the ranked `rankModels`
294
+ * selector.
201
295
  */
202
- export function resolveModelId(
296
+ export function searchServingEndpoints(
203
297
  input: string,
204
298
  endpoints: readonly ServingEndpointSummary[],
205
- opts: ResolveModelOptions = {},
206
- ): ResolvedModel {
207
- if (endpoints.length === 0) {
208
- log.debug("resolve:no-endpoints", { input });
209
- return { modelId: input, matched: false };
210
- }
299
+ options: ResolveModelOptions = {},
300
+ ): ScoredEndpoint[] {
301
+ if (endpoints.length === 0) return [];
211
302
  for (const ep of endpoints) {
212
- if (ep.name === input) {
213
- log.debug("resolve:exact", { input });
214
- return { modelId: ep.name, matched: true, score: 0 };
215
- }
303
+ if (ep.name === input) return [{ endpoint: ep, score: 0 }];
216
304
  }
217
- const threshold = opts.threshold ?? DEFAULT_FUZZY_THRESHOLD;
218
- const fuse = new Fuse(endpoints, {
219
- keys: ["name"],
220
- threshold,
221
- ignoreLocation: true,
222
- includeScore: true,
223
- useExtendedSearch: true,
224
- isCaseSensitive: false,
225
- });
305
+ const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
226
306
  // Fuse 7.3 has no built-in tokenize hook; in extended search,
227
307
  // space-separated tokens are AND-ed with fuzzy matching enabled. We
228
308
  // lean on the shared tokenizer so the splitting rules stay
@@ -230,24 +310,37 @@ export function resolveModelId(
230
310
  const query = Array.from(
231
311
  stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input),
232
312
  ).join(" ");
233
- if (!query) {
234
- log.debug("resolve:empty-tokens", { input });
235
- return { modelId: input, matched: false };
236
- }
237
- const results = fuse.search(query);
238
- const best = results[0];
239
- if (best?.item.name && (best.score ?? 0) <= threshold) {
240
- log.debug("resolve:fuzzy-match", {
241
- input,
242
- modelId: best.item.name,
243
- score: best.score,
244
- });
245
- return { modelId: best.item.name, matched: true, score: best.score };
246
- }
247
- log.debug("resolve:no-match", {
248
- input,
249
- bestScore: best?.score,
313
+ if (!query) return [];
314
+ const fuse = new Fuse(endpoints, {
315
+ keys: ["name"],
250
316
  threshold,
317
+ ignoreLocation: true,
318
+ includeScore: true,
319
+ useExtendedSearch: true,
320
+ isCaseSensitive: false,
251
321
  });
322
+ return fuse
323
+ .search(query)
324
+ .filter((r) => (r.score ?? 0) <= threshold)
325
+ .map((r) => ({ endpoint: r.item, score: r.score ?? 0 }));
326
+ }
327
+
328
+ /**
329
+ * Snap a user-supplied model name to the single closest configured
330
+ * serving endpoint via {@link searchServingEndpoints}. Returns the
331
+ * input unchanged with `matched: false` when nothing scores within the
332
+ * threshold (or the catalogue is empty), so a deliberate model id is
333
+ * never silently rewritten to a similar-looking neighbour and the
334
+ * upstream call surfaces the canonical 404.
335
+ */
336
+ export function resolveModelId(
337
+ input: string,
338
+ endpoints: readonly ServingEndpointSummary[],
339
+ options: ResolveModelOptions = {},
340
+ ): ResolvedModel {
341
+ const [best] = searchServingEndpoints(input, endpoints, options);
342
+ if (best) {
343
+ return { modelId: best.endpoint.name, matched: true, score: best.score };
344
+ }
252
345
  return { modelId: input, matched: false };
253
346
  }