@dbx-tools/model 0.9.5 → 0.9.6

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
@@ -25,11 +25,10 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@databricks/appkit": "0.81.0",
28
- "@dbx-tools/appkit": "^0.9.5",
29
- "@dbx-tools/model-rs": "^0.9.5",
30
- "@dbx-tools/shared-core": "^0.9.5",
31
- "@dbx-tools/shared-model": "^0.9.5",
32
- "fuse.js": "^7.4.2"
28
+ "@dbx-tools/appkit": "^0.9.6",
29
+ "@dbx-tools/model-rs": "^0.9.6",
30
+ "@dbx-tools/shared-core": "^0.9.6",
31
+ "@dbx-tools/shared-model": "^0.9.6"
33
32
  },
34
33
  "main": "./lib/index.js",
35
34
  "license": "Apache-2.0",
@@ -45,7 +44,7 @@
45
44
  "./package.json": "./package.json"
46
45
  }
47
46
  },
48
- "version": "0.9.5",
47
+ "version": "0.9.6",
49
48
  "types": "./lib/index.d.ts",
50
49
  "type": "module",
51
50
  "exports": {
package/src/_native.ts ADDED
@@ -0,0 +1,174 @@
1
+ import {
2
+ ModelClass as NativeModelClass,
3
+ ReasoningEffort as NativeReasoningEffort,
4
+ normalizeServingEndpointsJson,
5
+ rankModels as rankModelsWithRust,
6
+ type ModelQuery as NativeModelQuery,
7
+ type ServingEndpointSummary as NativeServingEndpointSummary,
8
+ } from "@dbx-tools/model-rs";
9
+ import {
10
+ model,
11
+ type ModelQuery,
12
+ type RankedModel,
13
+ type ReasoningEffort,
14
+ type ServingEndpointSummary,
15
+ } from "@dbx-tools/shared-model";
16
+
17
+ type ModelClass = model.ModelClass;
18
+
19
+ const MODEL_CLASS_TO_NATIVE: Readonly<Record<ModelClass, NativeModelClass>> = {
20
+ [model.ModelClass.ChatThinking]: NativeModelClass.ChatThinking,
21
+ [model.ModelClass.ChatBalanced]: NativeModelClass.ChatBalanced,
22
+ [model.ModelClass.ChatFast]: NativeModelClass.ChatFast,
23
+ [model.ModelClass.Embedding]: NativeModelClass.Embedding,
24
+ };
25
+
26
+ const REASONING_EFFORT_TO_NATIVE: Readonly<Record<string, NativeReasoningEffort>> = {
27
+ none: NativeReasoningEffort.None,
28
+ minimal: NativeReasoningEffort.Minimal,
29
+ low: NativeReasoningEffort.Low,
30
+ medium: NativeReasoningEffort.Medium,
31
+ high: NativeReasoningEffort.High,
32
+ xhigh: NativeReasoningEffort.Xhigh,
33
+ max: NativeReasoningEffort.Max,
34
+ };
35
+
36
+ const REASONING_EFFORT_FROM_NATIVE: Readonly<Record<NativeReasoningEffort, ReasoningEffort>> = {
37
+ [NativeReasoningEffort.None]: "none",
38
+ [NativeReasoningEffort.Minimal]: "minimal",
39
+ [NativeReasoningEffort.Low]: "low",
40
+ [NativeReasoningEffort.Medium]: "medium",
41
+ [NativeReasoningEffort.High]: "high",
42
+ [NativeReasoningEffort.Xhigh]: "xhigh",
43
+ [NativeReasoningEffort.Max]: "max",
44
+ };
45
+
46
+ interface NativeRankingOptions {
47
+ readonly includeDeprecated?: boolean;
48
+ readonly modelClass?: ModelClass;
49
+ readonly task?: string;
50
+ }
51
+
52
+ /** Rank public endpoint records through the Rust-owned model policy. */
53
+ export function rankEndpoints(
54
+ endpoints: readonly ServingEndpointSummary[],
55
+ query: ModelQuery = {},
56
+ options: NativeRankingOptions = {},
57
+ ): RankedModel[] {
58
+ const endpointsByName = new Map(endpoints.map((endpoint) => [endpoint.name, endpoint]));
59
+ return rankModelsWithRust(
60
+ endpoints.map((endpoint) => toNativeEndpoint(endpoint, options)),
61
+ toNativeQuery(query, options.includeDeprecated ?? false),
62
+ ).map((ranked) => {
63
+ const endpoint = endpointsByName.get(ranked.endpoint.name);
64
+ if (!endpoint) {
65
+ throw new Error(`Rust model ranking returned unknown endpoint "${ranked.endpoint.name}"`);
66
+ }
67
+ return {
68
+ endpoint,
69
+ modelClass: fromNativeClass(ranked.modelClass),
70
+ ...(ranked.score !== undefined ? { score: ranked.score } : {}),
71
+ };
72
+ });
73
+ }
74
+
75
+ /** Classify chat and embedding endpoints through the Rust-owned policy. */
76
+ export function classifyEndpointClasses(
77
+ endpoints: readonly ServingEndpointSummary[],
78
+ ): ReadonlyMap<string, ModelClass> {
79
+ const chat = rankEndpoints(endpoints, {}, { includeDeprecated: true });
80
+ const embeddings = rankEndpoints(
81
+ endpoints,
82
+ { modelClass: model.ModelClass.Embedding },
83
+ { includeDeprecated: true },
84
+ );
85
+ return new Map(
86
+ [...chat, ...embeddings].map((ranked) => [ranked.endpoint.name, ranked.modelClass]),
87
+ );
88
+ }
89
+
90
+ /** Normalize serialized SDK endpoint records through the Rust-owned catalogue policy. */
91
+ export function normalizeEndpoints(endpoints: readonly unknown[]): ServingEndpointSummary[] {
92
+ return normalizeServingEndpointsJson(JSON.stringify({ endpoints })).map(fromNativeEndpoint);
93
+ }
94
+
95
+ function toNativeQuery(query: ModelQuery, includeDeprecated: boolean): NativeModelQuery {
96
+ return {
97
+ search: query.search,
98
+ modelClass:
99
+ query.modelClass === undefined ? undefined : MODEL_CLASS_TO_NATIVE[query.modelClass],
100
+ requiresTools: query.requiresTools,
101
+ includeDeprecated: includeDeprecated || undefined,
102
+ limit: query.limit,
103
+ threshold: query.threshold,
104
+ };
105
+ }
106
+
107
+ function toNativeEndpoint(
108
+ endpoint: ServingEndpointSummary,
109
+ options: NativeRankingOptions,
110
+ ): NativeServingEndpointSummary {
111
+ const modelClass = options.modelClass ?? endpoint.class;
112
+ return {
113
+ name: endpoint.name,
114
+ displayName: endpoint.displayName,
115
+ family: endpoint.family,
116
+ task: options.task ?? endpoint.task,
117
+ state: endpoint.state,
118
+ description: endpoint.description,
119
+ supportsTools: endpoint.supportsTools,
120
+ profile: endpoint.profile,
121
+ modelClass: modelClass === undefined ? undefined : MODEL_CLASS_TO_NATIVE[modelClass],
122
+ serviceNames: new Map(Object.entries(endpoint.serviceNames ?? {})),
123
+ modelServiceName: endpoint.modelServiceName,
124
+ reasoningEfforts: (endpoint.reasoningEfforts ?? []).map((effort) => {
125
+ const native = REASONING_EFFORT_TO_NATIVE[effort];
126
+ if (native === undefined) throw new Error(`Unknown reasoning effort "${effort}"`);
127
+ return native;
128
+ }),
129
+ status: { deprecated: endpoint.status?.deprecated ?? false },
130
+ dimension: endpoint.dimension,
131
+ };
132
+ }
133
+
134
+ function fromNativeEndpoint(endpoint: NativeServingEndpointSummary): ServingEndpointSummary {
135
+ return {
136
+ name: endpoint.name,
137
+ ...(endpoint.displayName !== undefined ? { displayName: endpoint.displayName } : {}),
138
+ ...(endpoint.family !== undefined ? { family: endpoint.family } : {}),
139
+ ...(endpoint.task !== undefined ? { task: endpoint.task } : {}),
140
+ ...(endpoint.state !== undefined ? { state: endpoint.state } : {}),
141
+ ...(endpoint.description !== undefined ? { description: endpoint.description } : {}),
142
+ ...(endpoint.supportsTools !== undefined ? { supportsTools: endpoint.supportsTools } : {}),
143
+ ...(endpoint.profile !== undefined ? { profile: endpoint.profile } : {}),
144
+ ...(endpoint.modelClass !== undefined ? { class: fromNativeClass(endpoint.modelClass) } : {}),
145
+ ...(endpoint.serviceNames.size > 0
146
+ ? { serviceNames: Object.fromEntries(endpoint.serviceNames) }
147
+ : {}),
148
+ ...(endpoint.modelServiceName !== undefined
149
+ ? { modelServiceName: endpoint.modelServiceName }
150
+ : {}),
151
+ ...(endpoint.reasoningEfforts.length > 0
152
+ ? {
153
+ reasoningEfforts: endpoint.reasoningEfforts.map(
154
+ (effort) => REASONING_EFFORT_FROM_NATIVE[effort],
155
+ ),
156
+ }
157
+ : {}),
158
+ status: { deprecated: endpoint.status.deprecated },
159
+ ...(endpoint.dimension !== undefined ? { dimension: endpoint.dimension } : {}),
160
+ };
161
+ }
162
+
163
+ function fromNativeClass(modelClass: NativeModelClass): ModelClass {
164
+ switch (modelClass) {
165
+ case NativeModelClass.ChatThinking:
166
+ return model.ModelClass.ChatThinking;
167
+ case NativeModelClass.ChatBalanced:
168
+ return model.ModelClass.ChatBalanced;
169
+ case NativeModelClass.ChatFast:
170
+ return model.ModelClass.ChatFast;
171
+ case NativeModelClass.Embedding:
172
+ return model.ModelClass.Embedding;
173
+ }
174
+ }
package/src/fallback.ts CHANGED
@@ -5,27 +5,27 @@
5
5
  * token, the service principal can't list, or the workspace is unreachable -
6
6
  * the resolver still has to name *some* endpoint. This module holds that floor:
7
7
  * a small, hard-coded set of well-known Foundation Model API endpoint names,
8
- * each bucketed into a chat {@link ModelClass} by the shared
9
- * {@link classify.classifyByFamily} heuristic (no classes are hard-coded here)
8
+ * each bucketed into a chat {@link ModelClass} by the Rust family policy
10
9
  * and ordered best-first.
11
10
  *
12
11
  * This is deliberately *server-only*. A browser client never talks to
13
12
  * Databricks directly - it always goes through this server - so it has nothing
14
13
  * to fall back to and must not assume a stale, baked-in model list; it consumes
15
- * the live `/models` response instead. The pure classifier
16
- * ({@link classify.classifyEndpoints}) is what the client shares.
14
+ * the classified live `/models` response instead.
17
15
  *
18
16
  * @module
19
17
  */
20
18
 
21
- import { classify, model, type FamilyClass } from "@dbx-tools/shared-model";
19
+ import { model, type ServingEndpointSummary } from "@dbx-tools/shared-model";
20
+
21
+ import { rankEndpoints } from "./_native.ts";
22
22
 
23
23
  type ModelClass = model.ModelClass;
24
24
  const { ModelClass } = model;
25
25
 
26
26
  /**
27
27
  * Small, last-resort set of well-known Foundation Model API endpoint names,
28
- * ordered best-first within each class by {@link classify.classifyByFamily}.
28
+ * ordered best-first within each class by the Rust model ranker.
29
29
  * Used only as the floor when the live `/serving-endpoints` catalogue can't be
30
30
  * read at resolve time; the live, score-driven classification supersedes it
31
31
  * whenever the workspace listing is available. Classes are not hard-coded here
@@ -43,19 +43,23 @@ const FALLBACK_MODEL_NAMES: readonly string[] = [
43
43
  "databricks-meta-llama-3-1-8b-instruct",
44
44
  ];
45
45
 
46
+ const FALLBACK_ENDPOINTS: readonly ServingEndpointSummary[] = FALLBACK_MODEL_NAMES.map((name) => ({
47
+ name,
48
+ task: "llm/v1/chat",
49
+ }));
50
+
46
51
  /**
47
52
  * Static fallback model ids for a chat class, drawn from the small built-in
48
53
  * {@link FALLBACK_MODEL_NAMES} list and ordered best-first by family rank. Sync
49
54
  * and workspace-independent: this is the *fallback opinion* used to seed default
50
55
  * lists or when the live catalogue is unreachable - live resolution prefers
51
- * {@link classify.classifyEndpoints}. Returns `[]` for
56
+ * the Rust-classified workspace catalogue. Returns `[]` for
52
57
  * {@link ModelClass.Embedding} (the floor is chat-only).
53
58
  */
54
59
  export function modelsForClass(cls: ModelClass): readonly string[] {
55
- return FALLBACK_MODEL_NAMES.map((name) => ({ name, c: classify.classifyByFamily(name) }))
56
- .filter((x): x is { name: string; c: FamilyClass } => x.c !== null && x.c.class === cls)
57
- .sort((a, b) => b.c.rank - a.c.rank)
58
- .map((x) => x.name);
60
+ return rankEndpoints(FALLBACK_ENDPOINTS, { modelClass: cls })
61
+ .filter((ranked) => ranked.modelClass === cls)
62
+ .map((ranked) => ranked.endpoint.name);
59
63
  }
60
64
 
61
65
  /** Top static fallback model id for a chat class. */
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Live Databricks Model Serving catalogue access.
3
3
  *
4
- * Lists the workspace's `/serving-endpoints` once per host and caches the
5
- * result with a TTL via AppKit's `CacheManager`, with concurrent callers
6
- * sharing one in-flight promise (the coalescing pattern of Python's
7
- * `cachetools-async`). Surfaces each endpoint as a stable
4
+ * Lists the workspace's `/serving-endpoints` and, when the caller supplies a
5
+ * trusted credential identity, caches the result per host and identity with a
6
+ * TTL via AppKit's `CacheManager`. Concurrent callers in the same scope share
7
+ * one in-flight promise. Surfaces each endpoint as a stable
8
8
  * {@link ServingEndpointSummary} - including the Foundation Model API
9
9
  * `quality` / `speed` / `cost` profile when present, the classified
10
10
  * {@link ModelClass}, and (for embedding endpoints) the measured vector
11
11
  * `dimension` - and snaps loose, human-typed names to real endpoint ids
12
- * through `fuse.js` extended search so tokens like `"claude sonnet"` resolve to
12
+ * through the Rust model ranker so tokens like `"claude sonnet"` resolve to
13
13
  * `databricks-claude-sonnet-4-6`.
14
14
  *
15
15
  * The class stamp and embedding dimension are computed once per cache load:
@@ -21,20 +21,13 @@
21
21
  * @module
22
22
  */
23
23
 
24
+ import { createHash } from "node:crypto";
24
25
  import { CacheManager } from "@databricks/appkit";
25
26
  import { appkit } from "@dbx-tools/appkit";
26
- import { error, log, string } from "@dbx-tools/shared-core";
27
- import {
28
- classify,
29
- display,
30
- model,
31
- type ModelProfile,
32
- type ServingEndpointSummary,
33
- } from "@dbx-tools/shared-model";
34
- import Fuse from "fuse.js";
27
+ import { errorUtils, log } from "@dbx-tools/shared-core";
28
+ import { model, type ServingEndpointSummary } from "@dbx-tools/shared-model";
35
29
 
36
- import { MODEL_CLASS_ORDER } from "./classes.ts";
37
- import { modelFamily, modelReasoningEfforts } from "./policy.ts";
30
+ import { classifyEndpointClasses, normalizeEndpoints, rankEndpoints } from "./_native.ts";
38
31
 
39
32
  const { ModelClass } = model;
40
33
 
@@ -50,22 +43,12 @@ export type WorkspaceClientLike = Pick<appkit.WorkspaceClientLike, "servingEndpo
50
43
  /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
51
44
  export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
52
45
 
53
- /** Default Fuse.js score threshold below which a fuzzy match is accepted. */
46
+ /** Default Rust distance threshold below which a fuzzy match is accepted. */
54
47
  export const DEFAULT_FUZZY_THRESHOLD = 0.4;
55
48
 
56
49
  /** Cache key parts under which endpoint listings are stored. */
57
50
  const CACHE_KEY_NAMESPACE = "serving-endpoints";
58
51
 
59
- /**
60
- * Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`. Endpoint
61
- * visibility is effectively workspace-scoped (we cache by host in the key
62
- * parts), so a single shared key lets every user of the same workspace share
63
- * one cached fetch and coalesce on the in-flight promise. Permissions can
64
- * differ in theory, but the Foundation Model API catalogue is the same view for
65
- * every caller.
66
- */
67
- const SHARED_USER_KEY = "model-shared";
68
-
69
52
  /** Options for {@link listServingEndpoints}. */
70
53
  export interface ListServingEndpointsOptions {
71
54
  /**
@@ -73,6 +56,20 @@ export interface ListServingEndpointsOptions {
73
56
  * `CacheManager` as seconds.
74
57
  */
75
58
  ttlMs?: number;
59
+ /**
60
+ * Trusted identity of the credential used by `client`.
61
+ *
62
+ * The value is hashed before it enters the cache key and is never logged.
63
+ * Omit it when the caller cannot prove the credential identity; that call
64
+ * bypasses shared caching so one principal can never read another's
65
+ * catalogue.
66
+ */
67
+ cacheIdentity?: string;
68
+ }
69
+
70
+ /** Hash an opaque credential identity before handing it to CacheManager. */
71
+ function cacheUserKey(identity: string): string {
72
+ return `model-${createHash("sha256").update(identity).digest("hex")}`;
76
73
  }
77
74
 
78
75
  /**
@@ -100,11 +97,12 @@ export async function listServingEndpoints(
100
97
  host: string,
101
98
  options: ListServingEndpointsOptions = {},
102
99
  ): Promise<ServingEndpointSummary[]> {
100
+ if (!options.cacheIdentity) return fetchEndpoints(client);
103
101
  const ttlSec = Math.max(1, Math.round((options.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000));
104
102
  return CacheManager.getInstanceSync().getOrExecute(
105
103
  [CACHE_KEY_NAMESPACE, host],
106
104
  () => fetchEndpoints(client),
107
- SHARED_USER_KEY,
105
+ cacheUserKey(options.cacheIdentity),
108
106
  { ttl: ttlSec },
109
107
  );
110
108
  }
@@ -122,51 +120,11 @@ export async function listServingEndpoints(
122
120
  export async function listServingEndpointsUncached(
123
121
  client: WorkspaceClientLike,
124
122
  ): Promise<ServingEndpointSummary[]> {
125
- const out: ServingEndpointSummary[] = [];
123
+ const endpoints: unknown[] = [];
126
124
  for await (const ep of client.servingEndpoints.list()) {
127
- if (!ep.name) continue;
128
- const profile = extractProfile(ep);
129
- const family = modelFamily(ep.name);
130
- const reasoningEfforts = modelReasoningEfforts(ep.name);
131
- out.push({
132
- name: ep.name,
133
- // Prefer a Databricks-provided human name (a display-name tag or an
134
- // external-model name); else derive a title-cased label from the id.
135
- displayName: display.toModelDisplayName(ep.name, providedDisplayName(ep)),
136
- ...(family !== undefined ? { family } : {}),
137
- ...(ep.task !== undefined ? { task: ep.task } : {}),
138
- ...(ep.state?.ready !== undefined ? { state: String(ep.state.ready) } : {}),
139
- ...(ep.description !== undefined ? { description: ep.description } : {}),
140
- supportsTools: classify.supportsToolsByFamily(ep.name),
141
- ...(profile ? { profile } : {}),
142
- ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
143
- });
144
- }
145
- return out;
146
- }
147
-
148
- /**
149
- * Pull a Databricks-provided human name off a serving endpoint, if any:
150
- * a `display_name` / `displayName` / `name` tag first, then an
151
- * external-model `name` from the served-entity config. Returns `null`
152
- * when the endpoint carries no explicit name (the common case for
153
- * Foundation Model API endpoints), so the caller falls back to deriving
154
- * one from the id.
155
- */
156
- function providedDisplayName(ep: {
157
- tags?: Array<{ key: string; value?: string }>;
158
- config?: { served_entities?: Array<{ external_model?: { name?: string } }> };
159
- }): string | null {
160
- const tag = ep.tags?.find(
161
- (t) => t.key === "display_name" || t.key === "displayName" || t.key === "name",
162
- );
163
- const fromTag = string.trimToNull(tag?.value);
164
- if (fromTag) return fromTag;
165
- for (const entity of ep.config?.served_entities ?? []) {
166
- const external = string.trimToNull(entity.external_model?.name);
167
- if (external) return external;
125
+ endpoints.push(ep);
168
126
  }
169
- return null;
127
+ return normalizeEndpoints(endpoints);
170
128
  }
171
129
 
172
130
  async function fetchEndpoints(client: WorkspaceClientLike): Promise<ServingEndpointSummary[]> {
@@ -185,11 +143,7 @@ async function fetchEndpoints(client: WorkspaceClientLike): Promise<ServingEndpo
185
143
  * class.
186
144
  */
187
145
  function stampModelClasses(summaries: ServingEndpointSummary[]): void {
188
- const buckets = classify.classifyEndpoints(summaries);
189
- const classOf = new Map<string, model.ModelClass>();
190
- for (const cls of MODEL_CLASS_ORDER) {
191
- for (const ep of buckets[cls]) classOf.set(ep.name, cls);
192
- }
146
+ const classOf = classifyEndpointClasses(summaries);
193
147
  for (const summary of summaries) {
194
148
  const cls = classOf.get(summary.name);
195
149
  if (cls !== undefined) summary.class = cls;
@@ -235,61 +189,24 @@ async function pingEmbeddingDimension(
235
189
  logger.warn("embedding ping returned no vector", { name });
236
190
  return undefined;
237
191
  } catch (err) {
238
- logger.warn("embedding ping failed", { name, error: error.errorMessage(err) });
192
+ logger.warn("embedding ping failed", { name, error: errorUtils.errorMessage(err) });
239
193
  return undefined;
240
194
  }
241
195
  }
242
196
 
243
197
  /**
244
- * Pull the Foundation Model API `quality` / `speed` / `cost` scores off a
245
- * serving-endpoint listing entry. Databricks returns these under
246
- * `config.served_entities[].foundation_model.ai_gateway_model_profile`
247
- * (snake_case at the wire level, preserved verbatim by the SDK), but the field
248
- * is newer than the typed `FoundationModel` interface, so we read it through a
249
- * structural cast. Returns `undefined` when no served entity carries a profile
250
- * (custom models, embeddings, and brand-new endpoints that Databricks has not
251
- * scored yet).
198
+ * Force-evict cached endpoint listings via AppKit's `CacheManager`. A host and
199
+ * identity delete only that principal's workspace entry. Because CacheManager
200
+ * cannot enumerate a namespace, omitting either value clears the complete
201
+ * manager and should be reserved for tests or administrative refreshes.
252
202
  */
253
- function extractProfile(ep: unknown): ModelProfile | undefined {
254
- const entities = (
255
- ep as {
256
- config?: {
257
- served_entities?: Array<{
258
- foundation_model?: {
259
- ai_gateway_model_profile?: {
260
- quality?: number;
261
- speed?: number;
262
- cost?: number;
263
- };
264
- };
265
- }>;
266
- };
267
- }
268
- ).config?.served_entities;
269
- if (!entities) return undefined;
270
- for (const entity of entities) {
271
- const raw = entity.foundation_model?.ai_gateway_model_profile;
272
- if (!raw) continue;
273
- const profile: ModelProfile = {};
274
- if (Number.isFinite(raw.quality)) profile.quality = raw.quality;
275
- if (Number.isFinite(raw.speed)) profile.speed = raw.speed;
276
- if (Number.isFinite(raw.cost)) profile.cost = raw.cost;
277
- if (Object.keys(profile).length > 0) return profile;
278
- }
279
- return undefined;
280
- }
281
-
282
- /**
283
- * Force-evict cached endpoint listings via AppKit's `CacheManager`. With a
284
- * `host` deletes that one workspace's entry; without one clears every cache
285
- * entry on the manager (since `CacheManager` doesn't expose a namespace-scoped
286
- * clear, this is the brute-force path - fine for tests, avoid in steady-state
287
- * code).
288
- */
289
- export async function clearServingEndpointsCache(host?: string): Promise<void> {
203
+ export async function clearServingEndpointsCache(
204
+ host?: string,
205
+ cacheIdentity?: string,
206
+ ): Promise<void> {
290
207
  const cache = CacheManager.getInstanceSync();
291
- if (host) {
292
- const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
208
+ if (host && cacheIdentity) {
209
+ const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], cacheUserKey(cacheIdentity));
293
210
  await cache.delete(key);
294
211
  } else {
295
212
  await cache.clear();
@@ -298,7 +215,7 @@ export async function clearServingEndpointsCache(host?: string): Promise<void> {
298
215
 
299
216
  /**
300
217
  * Result of fuzzy-resolving a user-supplied model name against the live
301
- * endpoint list. `score` is Fuse.js's distance (`0` is exact, `1` is no match);
218
+ * endpoint list. `score` is the Rust ranker's distance (`0` is exact, `1` is no match);
302
219
  * `matched` is `false` when the score exceeds the configured threshold so
303
220
  * callers can fall back to the original input (Databricks will then return a
304
221
  * clean 404).
@@ -311,7 +228,7 @@ export interface ResolvedModel {
311
228
 
312
229
  /** Options accepted by {@link resolveModelId} / {@link searchServingEndpoints}. */
313
230
  export interface ResolveModelOptions {
314
- /** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
231
+ /** Fuzzy distance threshold (0 = exact, 1 = anything). Default `0.4`. */
315
232
  threshold?: number;
316
233
  /** Require a model verified for a complete function-tool round-trip. */
317
234
  requiresTools?: boolean;
@@ -320,7 +237,7 @@ export interface ResolveModelOptions {
320
237
  /** A serving endpoint paired with its fuzzy-match distance for a query. */
321
238
  export interface ScoredEndpoint {
322
239
  endpoint: ServingEndpointSummary;
323
- /** Fuse.js distance: `0` is exact, `1` is no match. */
240
+ /** Fuzzy distance: `0` is exact, `1` is no match. */
324
241
  score: number;
325
242
  }
326
243
 
@@ -329,11 +246,8 @@ export interface ScoredEndpoint {
329
246
  * (lowest score) first, keeping only those within `threshold`:
330
247
  *
331
248
  * 1. An exact name match short-circuits to a single `score: 0` result.
332
- * 2. Otherwise the input is tokenized (dashes / underscores / spaces become
333
- * separators) and fed through Fuse.js extended search, which AND-s each
334
- * token with fuzzy matching enabled - the "tokenized fuzzy match" a caller
335
- * reaches for when they type `"claude sonnet"` instead of the full endpoint
336
- * name.
249
+ * 2. Otherwise Rust tokenizes the input and scores every endpoint through the
250
+ * same fuzzy-distance policy used by {@link lookupModels}.
337
251
  *
338
252
  * Returns `[]` for an empty endpoint list or when `input` tokenizes to nothing,
339
253
  * so callers fall back to the raw input and let Databricks surface a clean 404.
@@ -345,30 +259,20 @@ export function searchServingEndpoints(
345
259
  endpoints: readonly ServingEndpointSummary[],
346
260
  options: ResolveModelOptions = {},
347
261
  ): ScoredEndpoint[] {
348
- if (endpoints.length === 0) return [];
349
- for (const ep of endpoints) {
350
- if (ep.name === input) return [{ endpoint: ep, score: 0 }];
351
- }
352
- const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
353
- // Fuse 7.3 has no built-in tokenize hook; in extended search, space-separated
354
- // tokens are AND-ed with fuzzy matching enabled. We lean on the shared
355
- // tokenizer so the splitting rules stay consistent with the rest of the toolkit.
356
- const query = Array.from(
357
- string.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input),
358
- ).join(" ");
359
- if (!query) return [];
360
- const fuse = new Fuse(endpoints, {
361
- keys: ["name"],
362
- threshold,
363
- ignoreLocation: true,
364
- includeScore: true,
365
- useExtendedSearch: true,
366
- isCaseSensitive: false,
367
- });
368
- return fuse
369
- .search(query)
370
- .filter((r) => (r.score ?? 0) <= threshold)
371
- .map((r) => ({ endpoint: r.item, score: r.score ?? 0 }));
262
+ if (endpoints.length === 0 || !input.trim()) return [];
263
+ return rankEndpoints(
264
+ endpoints,
265
+ {
266
+ search: input,
267
+ ...(options.requiresTools !== undefined ? { requiresTools: options.requiresTools } : {}),
268
+ threshold: options.threshold ?? DEFAULT_FUZZY_THRESHOLD,
269
+ },
270
+ {
271
+ includeDeprecated: true,
272
+ modelClass: model.ModelClass.ChatBalanced,
273
+ task: "llm/v1/chat",
274
+ },
275
+ ).map((ranked) => ({ endpoint: ranked.endpoint, score: ranked.score ?? 0 }));
372
276
  }
373
277
 
374
278
  /**
package/src/policy.ts CHANGED
@@ -7,8 +7,14 @@
7
7
  import {
8
8
  modelFamily as modelFamilyWithRust,
9
9
  reasoningEffortNamesByFamily,
10
+ supportsToolsByFamily as supportsToolsByFamilyWithRust,
10
11
  } from "@dbx-tools/model-rs";
11
- import type { ReasoningEffort } from "@dbx-tools/shared-model";
12
+ import {
13
+ model,
14
+ ReasoningEffortSchema,
15
+ type ReasoningEffort,
16
+ type ServingEndpointSummary,
17
+ } from "@dbx-tools/shared-model";
12
18
 
13
19
  /** Return the normalized family parsed by the Rust model-name policy. */
14
20
  export function modelFamily(name: string): string | undefined {
@@ -17,5 +23,18 @@ export function modelFamily(name: string): string | undefined {
17
23
 
18
24
  /** Return the reasoning efforts accepted by a model family. */
19
25
  export function modelReasoningEfforts(name: string): ReasoningEffort[] {
20
- return reasoningEffortNamesByFamily(name);
26
+ return ReasoningEffortSchema.array().parse(reasoningEffortNamesByFamily(name));
27
+ }
28
+
29
+ /** Return whether Rust policy verifies a complete tool-calling round trip. */
30
+ export function modelSupportsTools(name: string): boolean {
31
+ return supportsToolsByFamilyWithRust(name);
32
+ }
33
+
34
+ /** Return whether a discovered endpoint can be used for a tool-calling chat. */
35
+ export function endpointSupportsTools(endpoint: ServingEndpointSummary): boolean {
36
+ const embedding =
37
+ endpoint.task === "llm/v1/embeddings" || endpoint.class === model.ModelClass.Embedding;
38
+ const chat = !embedding && (endpoint.task === "llm/v1/chat" || endpoint.class !== undefined);
39
+ return chat && (endpoint.supportsTools ?? modelSupportsTools(endpoint.name));
21
40
  }