@dbx-tools/model 0.1.42 → 0.1.56

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.
@@ -1,21 +1,116 @@
1
1
  /**
2
2
  * Workspace-aware model selection.
3
3
  *
4
- * Given a caller's intent (an explicit name, a capability
5
- * {@link ModelTier}, or nothing), the resolver returns the endpoint id
6
- * to use - always one the workspace actually has, degrading from "best
7
- * in tier" down to the static fallback floor. Two entry points:
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}.
8
11
  *
9
- * - {@link selectModel} is the high-level, I/O-doing call: hand it a
10
- * `WorkspaceClient` and intent and it lists `/serving-endpoints`
11
- * (cached) and resolves in one step. This is what a non-Mastra
12
- * consumer (e.g. a job that just needs a model name) reaches for.
13
- * - {@link resolveModel} is the pure form over an endpoint list you
14
- * already hold; `selectModel` is a thin wrapper over it.
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
+ import { classifyEndpoints, ModelClass, } from "@dbx-tools/model-shared";
31
+ import { CHAT_CLASS_ORDER, classesAtOrBelow, MODEL_CLASS_ORDER } from "./classes.js";
32
+ import { FALLBACK_MODEL_IDS, modelsForClass } from "./fallback.js";
33
+ import { listServingEndpoints, searchServingEndpoints, } from "./serving.js";
34
+ /**
35
+ * Round a Fuse score to the display precision so version siblings that
36
+ * match a token identically (e.g. `opus-4-7` vs `opus-4-8` for the
37
+ * query `"opus"`) tie on match and let the class / within-class rank
38
+ * decide - which is what surfaces the newer, higher-quality sibling.
39
+ */
40
+ function matchBucket(score) {
41
+ return Math.round((score ?? 0) * 1000);
42
+ }
43
+ /**
44
+ * Rank the live catalogue against a {@link ModelQuery}, best-first.
45
+ *
46
+ * Candidates are the classified endpoints in the eligible classes:
47
+ * {@link classesAtOrBelow} the requested `modelClass`, or - when none is
48
+ * given - the chat bands only ({@link CHAT_CLASS_ORDER}), so a general
49
+ * ask never surfaces an embedding endpoint. Each class bucket is
50
+ * already best-first from {@link classifyEndpoints}. Ranking is **match
51
+ * then class**:
52
+ *
53
+ * 1. With a `search`, only endpoints matching it survive, ordered by
54
+ * match distance (bucketed via {@link matchBucket} so near-identical
55
+ * scores tie), then by class (more capable first), then by the stable
56
+ * within-class rank.
57
+ * 2. Without a `search`, the class-then-rank candidate order stands.
58
+ *
59
+ * A `limit` truncates the result. Returns `[]` when nothing is
60
+ * eligible or matches - callers layer their own fallback.
61
+ */
62
+ export function rankModels(endpoints, query = {}) {
63
+ const classified = classifyEndpoints(endpoints);
64
+ const eligible = query.modelClass !== undefined
65
+ ? classesAtOrBelow(query.modelClass)
66
+ : CHAT_CLASS_ORDER;
67
+ // Flatten eligible classes in capability order, carrying each
68
+ // endpoint's class; bucket order is already best-first.
69
+ const candidates = [];
70
+ for (const modelClass of eligible) {
71
+ for (const endpoint of classified[modelClass])
72
+ candidates.push({ endpoint, modelClass });
73
+ }
74
+ const search = query.search?.trim();
75
+ let ranked;
76
+ if (search) {
77
+ const scores = new Map();
78
+ for (const match of searchServingEndpoints(search, candidates.map((c) => c.endpoint), query.threshold !== undefined ? { threshold: query.threshold } : {})) {
79
+ scores.set(match.endpoint.name, match.score);
80
+ }
81
+ // `Array.prototype.sort` is stable, so endpoints equal on match and
82
+ // class keep their best-first within-class order.
83
+ ranked = candidates
84
+ .filter((c) => scores.has(c.endpoint.name))
85
+ .map((c) => ({ ...c, score: scores.get(c.endpoint.name) }))
86
+ .sort((a, b) => {
87
+ const byMatch = matchBucket(a.score) - matchBucket(b.score);
88
+ if (byMatch !== 0)
89
+ return byMatch;
90
+ return (MODEL_CLASS_ORDER.indexOf(a.modelClass) -
91
+ MODEL_CLASS_ORDER.indexOf(b.modelClass));
92
+ });
93
+ }
94
+ else {
95
+ ranked = candidates;
96
+ }
97
+ return query.limit !== undefined ? ranked.slice(0, Math.max(0, query.limit)) : ranked;
98
+ }
99
+ /**
100
+ * Rank a workspace's catalogue in one call: list its
101
+ * `/serving-endpoints` (cached) and run {@link rankModels} over the
102
+ * result. The list counterpart to {@link selectModel}, for a consumer
103
+ * that wants the full ranked set (a model picker, a CLI) rather than a
104
+ * single id. Catalogue fetches fail loud: network / auth errors
105
+ * propagate so the caller sees the real SDK message.
106
+ *
107
+ * @param host - Workspace host used as the cache key. Pass the value
108
+ * resolved from `client.config.getHost()`.
15
109
  */
16
- import { classifyEndpoints, ModelTier, } from "@dbx-tools/model-shared";
17
- import { FALLBACK_MODEL_IDS, modelsForTier } from "./fallback.js";
18
- import { listServingEndpoints, resolveModelId, } from "./serving.js";
110
+ export async function searchModels(client, host, input = {}) {
111
+ const endpoints = await listServingEndpoints(client, host, input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {});
112
+ return rankModels(endpoints, input);
113
+ }
19
114
  /**
20
115
  * Resolve a model id for a workspace in one call: list its
21
116
  * `/serving-endpoints` (cached) and run {@link resolveModel} over the
@@ -42,61 +137,52 @@ export async function selectModel(client, host, input = {}) {
42
137
  return resolveModel(endpoints, input);
43
138
  }
44
139
  /**
45
- * Resolve a model id from the live catalogue and caller intent.
140
+ * Resolve a single model id from the live catalogue and caller intent,
141
+ * delegating the live selection to {@link rankModels} with `limit: 1`.
46
142
  *
47
143
  * 1. **Explicit ask**: with `fuzzy` off, returned verbatim; otherwise
48
- * snapped to the closest live endpoint via {@link resolveModelId}.
49
- * 2. **Tier intent**: classify the live catalogue and return the top
50
- * available model in that tier, then the tier's static list, then
51
- * the {@link FALLBACK_MODEL_IDS} floor.
52
- * 3. **Neither**: walk `fallbacks` first, then the live
53
- * score-classified catalogue (Thinking -> Balanced -> Fast), then
54
- * the static floor, and return the first id actually present.
144
+ * fuzzy-ranked within the (optional) class ceiling and the best taken,
145
+ * falling back to the input verbatim when nothing matches.
146
+ * 2. **No explicit ask**: an operator-pinned `fallback` that exists in
147
+ * the live catalogue wins first; then the ranked live catalogue
148
+ * (class ceiling applied); then the static {@link FALLBACK_MODEL_IDS}
149
+ * floor when the catalogue yields nothing in range.
55
150
  */
56
151
  export function resolveModel(endpoints, input = {}) {
57
152
  if (input.explicit !== undefined) {
58
153
  if (input.fuzzy === false) {
59
154
  return { modelId: input.explicit, source: "explicit" };
60
155
  }
61
- const { modelId } = resolveModelId(input.explicit, endpoints, {
62
- threshold: input.threshold,
63
- });
64
- return { modelId, source: "fuzzy-match" };
156
+ const [top] = rankModels(endpoints, buildQuery(input, input.explicit));
157
+ return { modelId: top?.endpoint.name ?? input.explicit, source: "fuzzy-match" };
65
158
  }
66
- const classified = classifyEndpoints(endpoints);
67
- const chain = input.tier !== undefined
68
- ? tierChain(classified, input.tier)
69
- : defaultChain(classified, input.fallbacks);
159
+ // Operator-pinned fallbacks win when present and live (e.g. a
160
+ // regulated workspace restricted to an approved subset).
161
+ if (input.modelClass === undefined && input.fallbacks && input.fallbacks.length > 0) {
162
+ const present = new Set(endpoints.map((e) => e.name));
163
+ const pinned = input.fallbacks.find((id) => present.has(id));
164
+ if (pinned)
165
+ return { modelId: pinned, source: "fallback" };
166
+ }
167
+ const source = input.modelClass !== undefined ? "class" : "fallback";
168
+ const [top] = rankModels(endpoints, buildQuery(input, undefined));
169
+ if (top)
170
+ return { modelId: top.endpoint.name, source };
171
+ // Live catalogue yielded nothing in range: walk the static floor.
172
+ const floor = input.modelClass !== undefined
173
+ ? dedupe([...modelsForClass(input.modelClass), ...FALLBACK_MODEL_IDS])
174
+ : dedupe([...(input.fallbacks ?? []), ...FALLBACK_MODEL_IDS]);
175
+ return { modelId: pickFirstAvailable(floor, endpoints), source };
176
+ }
177
+ /** Build a {@link ModelQuery} from {@link ResolveModelInput} for the `limit: 1` delegation. */
178
+ function buildQuery(input, search) {
70
179
  return {
71
- modelId: pickFirstAvailable(chain, endpoints),
72
- source: input.tier !== undefined ? "tier" : "fallback",
180
+ ...(search !== undefined ? { search } : {}),
181
+ ...(input.modelClass !== undefined ? { modelClass: input.modelClass } : {}),
182
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
183
+ limit: 1,
73
184
  };
74
185
  }
75
- /**
76
- * Candidate chain for an explicit {@link ModelTier} ask: live models
77
- * classified into that tier (best-first), then the tier's small static
78
- * list, then the full fallback floor as a last resort.
79
- */
80
- function tierChain(classified, tier) {
81
- return dedupe([
82
- ...classified[tier].map((e) => e.name),
83
- ...modelsForTier(tier),
84
- ...FALLBACK_MODEL_IDS,
85
- ]);
86
- }
87
- /**
88
- * Candidate chain for the no-explicit default: any operator-supplied
89
- * `fallbacks` first, then the live score-classified catalogue in
90
- * descending tier order, then the static fallback floor.
91
- */
92
- function defaultChain(classified, fallbacks) {
93
- const live = [
94
- ...classified[ModelTier.Thinking],
95
- ...classified[ModelTier.Balanced],
96
- ...classified[ModelTier.Fast],
97
- ].map((e) => e.name);
98
- return dedupe([...(fallbacks ?? []), ...live, ...FALLBACK_MODEL_IDS]);
99
- }
100
186
  /** Drop duplicate ids while preserving first-seen order. */
101
187
  function dedupe(ids) {
102
188
  return [...new Set(ids)];
@@ -6,24 +6,39 @@
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
- import { type getExecutionContext } from "@databricks/appkit";
15
- import type { ServingEndpointSummary } from "@dbx-tools/model-shared";
21
+ import { type ServingEndpointSummary } from "@dbx-tools/model-shared";
22
+ import type { appkitUtils } from "@dbx-tools/shared";
16
23
  /**
17
- * Structural type for the Databricks workspace client. Derived from
18
- * AppKit's `ExecutionContext` so this module doesn't take a direct
19
- * dependency on `@databricks/sdk-experimental`; the dep flows in
20
- * transitively through `@databricks/appkit`.
24
+ * Structural type for the Databricks workspace client, re-exported from
25
+ * `@dbx-tools/shared` so the rest of this package can keep importing it
26
+ * from here. See `appkitUtils.WorkspaceClientLike` for the canonical
27
+ * definition.
21
28
  */
22
- export type WorkspaceClientLike = ReturnType<typeof getExecutionContext>["client"];
29
+ export type WorkspaceClientLike = appkitUtils.WorkspaceClientLike;
23
30
  /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
24
31
  export declare const DEFAULT_MODEL_CACHE_TTL_MS: number;
25
32
  /** Default Fuse.js score threshold below which a fuzzy match is accepted. */
26
33
  export declare const DEFAULT_FUZZY_THRESHOLD = 0.4;
34
+ /** Options for {@link listServingEndpoints}. */
35
+ export interface ListServingEndpointsOptions {
36
+ /**
37
+ * Override the default cache TTL for this call, in milliseconds.
38
+ * Forwarded to `CacheManager` as seconds.
39
+ */
40
+ ttlMs?: number;
41
+ }
27
42
  /**
28
43
  * List Model Serving endpoints for the workspace owning `client`,
29
44
  * routed through AppKit's `CacheManager`. The manager gives us
@@ -43,12 +58,10 @@ export declare const DEFAULT_FUZZY_THRESHOLD = 0.4;
43
58
  * @param host - Workspace host used as the cache key. Pass the value
44
59
  * resolved from `client.config.getHost()` so multi-host apps share
45
60
  * one entry per workspace.
46
- * @param opts.ttlMs - Override the default TTL just for this call.
61
+ * @param options.ttlMs - Override the default TTL just for this call.
47
62
  * Forwarded to `CacheManager` as seconds.
48
63
  */
49
- export declare function listServingEndpoints(client: WorkspaceClientLike, host: string, opts?: {
50
- ttlMs?: number;
51
- }): Promise<ServingEndpointSummary[]>;
64
+ export declare function listServingEndpoints(client: WorkspaceClientLike, host: string, options?: ListServingEndpointsOptions): Promise<ServingEndpointSummary[]>;
52
65
  /**
53
66
  * Force-evict cached endpoint listings via AppKit's `CacheManager`.
54
67
  * With a `host` deletes that one workspace's entry; without one
@@ -69,28 +82,41 @@ export interface ResolvedModel {
69
82
  matched: boolean;
70
83
  score?: number;
71
84
  }
72
- /** Options accepted by {@link resolveModelId}. */
85
+ /** Options accepted by {@link resolveModelId} / {@link searchServingEndpoints}. */
73
86
  export interface ResolveModelOptions {
74
87
  /** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
75
88
  threshold?: number;
76
89
  }
90
+ /** A serving endpoint paired with its fuzzy-match distance for a query. */
91
+ export interface ScoredEndpoint {
92
+ endpoint: ServingEndpointSummary;
93
+ /** Fuse.js distance: `0` is exact, `1` is no match. */
94
+ score: number;
95
+ }
77
96
  /**
78
- * Snap a user-supplied model name to the closest configured serving
79
- * endpoint:
97
+ * Fuzzy-rank endpoints by how closely their `name` matches `input`,
98
+ * best (lowest score) first, keeping only those within `threshold`:
80
99
  *
81
- * 1. Exact name match wins immediately (no fuzzy needed).
100
+ * 1. An exact name match short-circuits to a single `score: 0` result.
82
101
  * 2. Otherwise the input is tokenized (dashes / underscores / spaces
83
- * become separators) and fed through Fuse.js extended search,
84
- * which AND-s each token with fuzzy matching enabled. This is the
85
- * "tokenized fuzzy match" the user reaches for when they type
86
- * `"claude sonnet"` instead of the full endpoint name.
87
- * 3. If the best Fuse score is above `threshold`, return the input
88
- * unchanged and let the upstream call surface the 404. This keeps
89
- * deliberate model ids (e.g. brand new endpoints) from being
90
- * silently rewritten to a similar-looking neighbour.
102
+ * become separators) and fed through Fuse.js extended search, which
103
+ * AND-s each token with fuzzy matching enabled - the "tokenized
104
+ * fuzzy match" a caller reaches for when they type `"claude sonnet"`
105
+ * instead of the full endpoint name.
91
106
  *
92
- * Pass an empty endpoint list to short-circuit fuzzy matching - the
93
- * input is returned verbatim. This is what callers do when the
94
- * workspace client can't be reached at resolve time.
107
+ * Returns `[]` for an empty endpoint list or when `input` tokenizes to
108
+ * nothing, so callers fall back to the raw input and let Databricks
109
+ * surface a clean 404. This multi-result core is shared by
110
+ * {@link resolveModelId} (single best) and the ranked `rankModels`
111
+ * selector.
112
+ */
113
+ export declare function searchServingEndpoints(input: string, endpoints: readonly ServingEndpointSummary[], options?: ResolveModelOptions): ScoredEndpoint[];
114
+ /**
115
+ * Snap a user-supplied model name to the single closest configured
116
+ * serving endpoint via {@link searchServingEndpoints}. Returns the
117
+ * input unchanged with `matched: false` when nothing scores within the
118
+ * threshold (or the catalogue is empty), so a deliberate model id is
119
+ * never silently rewritten to a similar-looking neighbour and the
120
+ * upstream call surfaces the canonical 404.
95
121
  */
96
- export declare function resolveModelId(input: string, endpoints: readonly ServingEndpointSummary[], opts?: ResolveModelOptions): ResolvedModel;
122
+ export declare function resolveModelId(input: string, endpoints: readonly ServingEndpointSummary[], options?: ResolveModelOptions): ResolvedModel;
@@ -6,14 +6,23 @@
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
  import { CacheManager } from "@databricks/appkit";
15
- import { logUtils, stringUtils } from "@dbx-tools/shared";
22
+ import { classifyEndpoints, ModelClass, } from "@dbx-tools/model-shared";
23
+ import { commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
16
24
  import Fuse from "fuse.js";
25
+ import { MODEL_CLASS_ORDER } from "./classes.js";
17
26
  const log = logUtils.logger("model/serving");
18
27
  /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
19
28
  export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -49,11 +58,11 @@ const SHARED_USER_KEY = "model-shared";
49
58
  * @param host - Workspace host used as the cache key. Pass the value
50
59
  * resolved from `client.config.getHost()` so multi-host apps share
51
60
  * one entry per workspace.
52
- * @param opts.ttlMs - Override the default TTL just for this call.
61
+ * @param options.ttlMs - Override the default TTL just for this call.
53
62
  * Forwarded to `CacheManager` as seconds.
54
63
  */
55
- export async function listServingEndpoints(client, host, opts = {}) {
56
- const ttlSec = Math.max(1, Math.round((opts.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000));
64
+ export async function listServingEndpoints(client, host, options = {}) {
65
+ const ttlSec = Math.max(1, Math.round((options.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000));
57
66
  return CacheManager.getInstanceSync().getOrExecute([CACHE_KEY_NAMESPACE, host], () => fetchEndpoints(client), SHARED_USER_KEY, { ttl: ttlSec });
58
67
  }
59
68
  async function fetchEndpoints(client) {
@@ -71,9 +80,68 @@ async function fetchEndpoints(client) {
71
80
  ...(profile ? { profile } : {}),
72
81
  });
73
82
  }
83
+ stampModelClasses(out);
84
+ await measureEmbeddingDimensions(client, out);
74
85
  log.debug("listed", { count: out.length, elapsedMs: Date.now() - startedAt });
75
86
  return out;
76
87
  }
88
+ /**
89
+ * Stamp each summary's {@link ServingEndpointSummary.class} from the
90
+ * relative classification of the whole set. Mutates `summaries` in
91
+ * place. Endpoints the classifier doesn't recognize (custom, unscored,
92
+ * non-LLM) are left without a class.
93
+ */
94
+ function stampModelClasses(summaries) {
95
+ const buckets = classifyEndpoints(summaries);
96
+ const classOf = new Map();
97
+ for (const cls of MODEL_CLASS_ORDER) {
98
+ for (const ep of buckets[cls])
99
+ classOf.set(ep.name, cls);
100
+ }
101
+ for (const summary of summaries) {
102
+ const cls = classOf.get(summary.name);
103
+ if (cls !== undefined)
104
+ summary.class = cls;
105
+ }
106
+ }
107
+ /**
108
+ * Measure the embedding vector dimension of every
109
+ * {@link ModelClass.Embedding} endpoint by pinging it once, all in
110
+ * parallel. Mutates the matching summaries in place with the resulting
111
+ * `dimension`. Runs only on a cache miss (it's called from
112
+ * {@link fetchEndpoints}), so the probe cost is amortized across the
113
+ * cached TTL window. Per-endpoint failures are swallowed (logged at
114
+ * warn) so one unreachable embedding model never fails the listing.
115
+ */
116
+ async function measureEmbeddingDimensions(client, summaries) {
117
+ await Promise.all(summaries
118
+ .filter((s) => s.class === ModelClass.Embedding)
119
+ .map(async (summary) => {
120
+ const dimension = await pingEmbeddingDimension(client, summary.name);
121
+ if (dimension !== undefined)
122
+ summary.dimension = dimension;
123
+ }));
124
+ }
125
+ /**
126
+ * Best-effort embedding dimension probe: query `name` with a tiny
127
+ * `"ping"` input and return the length of the returned vector. Returns
128
+ * `undefined` (and logs at warn) when the endpoint can't be queried or
129
+ * returns no vector - the dimension is informational, never required.
130
+ */
131
+ async function pingEmbeddingDimension(client, name) {
132
+ try {
133
+ const response = await client.servingEndpoints.query({ name, input: "ping" });
134
+ const dimension = response.data?.[0]?.embedding?.length;
135
+ if (typeof dimension === "number" && dimension > 0)
136
+ return dimension;
137
+ log.warn("embedding ping returned no vector", { name });
138
+ return undefined;
139
+ }
140
+ catch (err) {
141
+ log.warn("embedding ping failed", { name, error: commonUtils.errorMessage(err) });
142
+ return undefined;
143
+ }
144
+ }
77
145
  /**
78
146
  * Pull the Foundation Model API `quality` / `speed` / `cost` scores
79
147
  * off a serving-endpoint listing entry. Databricks returns these
@@ -122,36 +190,37 @@ export async function clearServingEndpointsCache(host) {
122
190
  }
123
191
  }
124
192
  /**
125
- * Snap a user-supplied model name to the closest configured serving
126
- * endpoint:
193
+ * Fuzzy-rank endpoints by how closely their `name` matches `input`,
194
+ * best (lowest score) first, keeping only those within `threshold`:
127
195
  *
128
- * 1. Exact name match wins immediately (no fuzzy needed).
196
+ * 1. An exact name match short-circuits to a single `score: 0` result.
129
197
  * 2. Otherwise the input is tokenized (dashes / underscores / spaces
130
- * become separators) and fed through Fuse.js extended search,
131
- * which AND-s each token with fuzzy matching enabled. This is the
132
- * "tokenized fuzzy match" the user reaches for when they type
133
- * `"claude sonnet"` instead of the full endpoint name.
134
- * 3. If the best Fuse score is above `threshold`, return the input
135
- * unchanged and let the upstream call surface the 404. This keeps
136
- * deliberate model ids (e.g. brand new endpoints) from being
137
- * silently rewritten to a similar-looking neighbour.
198
+ * become separators) and fed through Fuse.js extended search, which
199
+ * AND-s each token with fuzzy matching enabled - the "tokenized
200
+ * fuzzy match" a caller reaches for when they type `"claude sonnet"`
201
+ * instead of the full endpoint name.
138
202
  *
139
- * Pass an empty endpoint list to short-circuit fuzzy matching - the
140
- * input is returned verbatim. This is what callers do when the
141
- * workspace client can't be reached at resolve time.
203
+ * Returns `[]` for an empty endpoint list or when `input` tokenizes to
204
+ * nothing, so callers fall back to the raw input and let Databricks
205
+ * surface a clean 404. This multi-result core is shared by
206
+ * {@link resolveModelId} (single best) and the ranked `rankModels`
207
+ * selector.
142
208
  */
143
- export function resolveModelId(input, endpoints, opts = {}) {
144
- if (endpoints.length === 0) {
145
- log.debug("resolve:no-endpoints", { input });
146
- return { modelId: input, matched: false };
147
- }
209
+ export function searchServingEndpoints(input, endpoints, options = {}) {
210
+ if (endpoints.length === 0)
211
+ return [];
148
212
  for (const ep of endpoints) {
149
- if (ep.name === input) {
150
- log.debug("resolve:exact", { input });
151
- return { modelId: ep.name, matched: true, score: 0 };
152
- }
213
+ if (ep.name === input)
214
+ return [{ endpoint: ep, score: 0 }];
153
215
  }
154
- const threshold = opts.threshold ?? DEFAULT_FUZZY_THRESHOLD;
216
+ const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
217
+ // Fuse 7.3 has no built-in tokenize hook; in extended search,
218
+ // space-separated tokens are AND-ed with fuzzy matching enabled. We
219
+ // lean on the shared tokenizer so the splitting rules stay
220
+ // consistent with the rest of the toolkit.
221
+ const query = Array.from(stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input)).join(" ");
222
+ if (!query)
223
+ return [];
155
224
  const fuse = new Fuse(endpoints, {
156
225
  keys: ["name"],
157
226
  threshold,
@@ -160,29 +229,23 @@ export function resolveModelId(input, endpoints, opts = {}) {
160
229
  useExtendedSearch: true,
161
230
  isCaseSensitive: false,
162
231
  });
163
- // Fuse 7.3 has no built-in tokenize hook; in extended search,
164
- // space-separated tokens are AND-ed with fuzzy matching enabled. We
165
- // lean on the shared tokenizer so the splitting rules stay
166
- // consistent with the rest of the toolkit.
167
- const query = Array.from(stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input)).join(" ");
168
- if (!query) {
169
- log.debug("resolve:empty-tokens", { input });
170
- return { modelId: input, matched: false };
171
- }
172
- const results = fuse.search(query);
173
- const best = results[0];
174
- if (best?.item.name && (best.score ?? 0) <= threshold) {
175
- log.debug("resolve:fuzzy-match", {
176
- input,
177
- modelId: best.item.name,
178
- score: best.score,
179
- });
180
- return { modelId: best.item.name, matched: true, score: best.score };
232
+ return fuse
233
+ .search(query)
234
+ .filter((r) => (r.score ?? 0) <= threshold)
235
+ .map((r) => ({ endpoint: r.item, score: r.score ?? 0 }));
236
+ }
237
+ /**
238
+ * Snap a user-supplied model name to the single closest configured
239
+ * serving endpoint via {@link searchServingEndpoints}. Returns the
240
+ * input unchanged with `matched: false` when nothing scores within the
241
+ * threshold (or the catalogue is empty), so a deliberate model id is
242
+ * never silently rewritten to a similar-looking neighbour and the
243
+ * upstream call surfaces the canonical 404.
244
+ */
245
+ export function resolveModelId(input, endpoints, options = {}) {
246
+ const [best] = searchServingEndpoints(input, endpoints, options);
247
+ if (best) {
248
+ return { modelId: best.endpoint.name, matched: true, score: best.score };
181
249
  }
182
- log.debug("resolve:no-match", {
183
- input,
184
- bestScore: best?.score,
185
- threshold,
186
- });
187
250
  return { modelId: input, matched: false };
188
251
  }