@dbx-tools/model 0.1.112 → 0.3.1
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/README.md +134 -74
- package/index.ts +10 -0
- package/package.json +41 -20
- package/src/classes.ts +73 -0
- package/src/fallback.ts +73 -0
- package/src/resolve.ts +275 -0
- package/src/serving.ts +381 -0
- package/test/classes.test.ts +74 -0
- package/test/resolve.test.ts +169 -0
- package/tsconfig.json +41 -0
- package/dist/index.d.ts +0 -291
- package/dist/index.js +0 -576
package/dist/index.js
DELETED
|
@@ -1,576 +0,0 @@
|
|
|
1
|
-
import { ModelClass, ModelClassSchema, classifyByFamily, classifyEndpoints } from "@dbx-tools/model-shared";
|
|
2
|
-
import { CacheManager } from "@databricks/appkit";
|
|
3
|
-
import { commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
|
|
4
|
-
import Fuse from "fuse.js";
|
|
5
|
-
|
|
6
|
-
export * from "@dbx-tools/model-shared"
|
|
7
|
-
|
|
8
|
-
//#region packages/model/src/classes.ts
|
|
9
|
-
/**
|
|
10
|
-
* Model-class ordering for the model service.
|
|
11
|
-
*
|
|
12
|
-
* `@dbx-tools/model-shared` declares the {@link ModelClass} values and
|
|
13
|
-
* their schema; the *behavior* over those values - the chat capability
|
|
14
|
-
* order, the "this class and below" ceiling, and coercing loose request
|
|
15
|
-
* input to a class - lives here in the service so the shared
|
|
16
|
-
* wire-format surface stays purely declarative.
|
|
17
|
-
*/
|
|
18
|
-
/**
|
|
19
|
-
* Chat capability ladder in descending order - most capable
|
|
20
|
-
* ({@link ModelClass.ChatThinking}) first, least
|
|
21
|
-
* ({@link ModelClass.ChatFast}) last. {@link ModelClass.Embedding} is a
|
|
22
|
-
* separate modality and is deliberately absent: the ceiling never spans
|
|
23
|
-
* it. This is the order used to filter "this class and below" and to
|
|
24
|
-
* break ranking ties toward the more capable model.
|
|
25
|
-
*/
|
|
26
|
-
const CHAT_CLASS_ORDER = [
|
|
27
|
-
ModelClass.ChatThinking,
|
|
28
|
-
ModelClass.ChatBalanced,
|
|
29
|
-
ModelClass.ChatFast
|
|
30
|
-
];
|
|
31
|
-
/**
|
|
32
|
-
* Every class in display order: the chat ladder followed by
|
|
33
|
-
* {@link ModelClass.Embedding}. Used when flattening a full
|
|
34
|
-
* classification (e.g. stamping the class onto each cached endpoint).
|
|
35
|
-
*/
|
|
36
|
-
const MODEL_CLASS_ORDER = [...CHAT_CLASS_ORDER, ModelClass.Embedding];
|
|
37
|
-
/** Whether `cls` is one of the chat capability bands (vs. embedding). */
|
|
38
|
-
function isChatClass(cls) {
|
|
39
|
-
return CHAT_CLASS_ORDER.includes(cls);
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Coerce an arbitrary value (query string, header, body field) to a
|
|
43
|
-
* {@link ModelClass}, returning `null` when it isn't a known class.
|
|
44
|
-
* Lets a route accept a class request without throwing on junk input.
|
|
45
|
-
*
|
|
46
|
-
* Matches the full slug first (`"chat-fast"`, `"embedding"`), then -
|
|
47
|
-
* for a bare chat band - retries with a `chat-` prefix, so the shorthand
|
|
48
|
-
* `"thinking"` / `"balanced"` / `"fast"` resolves to the corresponding
|
|
49
|
-
* `chat-*` class.
|
|
50
|
-
*/
|
|
51
|
-
function parseModelClass(value) {
|
|
52
|
-
const exact = ModelClassSchema.safeParse(value);
|
|
53
|
-
if (exact.success) return exact.data;
|
|
54
|
-
const prefixed = ModelClassSchema.safeParse(`chat-${value}`);
|
|
55
|
-
return prefixed.success ? prefixed.data : null;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Classes at or below `cls` in chat capability: the class itself plus
|
|
59
|
-
* every less-capable chat band, in {@link CHAT_CLASS_ORDER}. Treats a
|
|
60
|
-
* chat `cls` as a ceiling - requesting {@link ModelClass.ChatBalanced}
|
|
61
|
-
* yields `[ChatBalanced, ChatFast]`, never
|
|
62
|
-
* {@link ModelClass.ChatThinking} - so a class ask can degrade downward
|
|
63
|
-
* to a smaller chat model but never escalate to a larger one.
|
|
64
|
-
*
|
|
65
|
-
* {@link ModelClass.Embedding} is its own modality, not a rung on the
|
|
66
|
-
* chat ladder, so it yields just `[Embedding]`. An unrecognized class
|
|
67
|
-
* yields the full chat ladder.
|
|
68
|
-
*/
|
|
69
|
-
function classesAtOrBelow(cls) {
|
|
70
|
-
if (cls === ModelClass.Embedding) return [ModelClass.Embedding];
|
|
71
|
-
const index = CHAT_CLASS_ORDER.indexOf(cls);
|
|
72
|
-
return index < 0 ? [...CHAT_CLASS_ORDER] : [...CHAT_CLASS_ORDER.slice(index)];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
//#endregion
|
|
76
|
-
//#region packages/model/src/fallback.ts
|
|
77
|
-
/**
|
|
78
|
-
* Server-side offline fallback opinion for model selection.
|
|
79
|
-
*
|
|
80
|
-
* When the live `/serving-endpoints` catalogue can't be read at all -
|
|
81
|
-
* no user token, the service principal can't list, or the workspace is
|
|
82
|
-
* unreachable - the resolver still has to name *some* endpoint. This
|
|
83
|
-
* module holds that floor: a small, hard-coded set of well-known
|
|
84
|
-
* Foundation Model API endpoint names, each bucketed into a chat
|
|
85
|
-
* {@link ModelClass} by the shared {@link classifyByFamily} heuristic
|
|
86
|
-
* (no classes are hard-coded here) and ordered best-first.
|
|
87
|
-
*
|
|
88
|
-
* This is deliberately *server-only*. A browser client never talks to
|
|
89
|
-
* Databricks directly - it always goes through this server - so it has
|
|
90
|
-
* nothing to fall back to and must not assume a stale, baked-in model
|
|
91
|
-
* list; it consumes the live `/models` response instead. The pure
|
|
92
|
-
* classifier ({@link classifyEndpoints}) is what the client shares.
|
|
93
|
-
*/
|
|
94
|
-
/**
|
|
95
|
-
* Small, last-resort set of well-known Foundation Model API endpoint
|
|
96
|
-
* names, ordered best-first within each class by {@link classifyByFamily}.
|
|
97
|
-
* Used only as the floor when the live `/serving-endpoints` catalogue
|
|
98
|
-
* can't be read at resolve time; the live, score-driven classification
|
|
99
|
-
* supersedes it whenever the workspace listing is available. Classes are
|
|
100
|
-
* not hard-coded here - each name is classified by family heuristic.
|
|
101
|
-
*/
|
|
102
|
-
const FALLBACK_MODEL_NAMES = [
|
|
103
|
-
"databricks-claude-opus-4-8",
|
|
104
|
-
"databricks-gpt-5-5-pro",
|
|
105
|
-
"databricks-gemini-3-1-pro",
|
|
106
|
-
"databricks-claude-sonnet-4-6",
|
|
107
|
-
"databricks-gpt-5-5",
|
|
108
|
-
"databricks-meta-llama-3-3-70b-instruct",
|
|
109
|
-
"databricks-claude-haiku-4-5",
|
|
110
|
-
"databricks-gpt-5-nano",
|
|
111
|
-
"databricks-meta-llama-3-1-8b-instruct"
|
|
112
|
-
];
|
|
113
|
-
/**
|
|
114
|
-
* Static fallback model ids for a chat class, drawn from the small
|
|
115
|
-
* built-in {@link FALLBACK_MODEL_NAMES} list and ordered best-first by
|
|
116
|
-
* family rank. Sync and workspace-independent: this is the *fallback
|
|
117
|
-
* opinion* used to seed default lists or when the live catalogue is
|
|
118
|
-
* unreachable - live resolution prefers `classifyEndpoints`. Returns
|
|
119
|
-
* `[]` for {@link ModelClass.Embedding} (the floor is chat-only).
|
|
120
|
-
*/
|
|
121
|
-
function modelsForClass(cls) {
|
|
122
|
-
return FALLBACK_MODEL_NAMES.map((name) => ({
|
|
123
|
-
name,
|
|
124
|
-
c: classifyByFamily(name)
|
|
125
|
-
})).filter((x) => x.c !== null && x.c.class === cls).sort((a, b) => b.c.rank - a.c.rank).map((x) => x.name);
|
|
126
|
-
}
|
|
127
|
-
/** Top static fallback model id for a chat class. */
|
|
128
|
-
function modelForClass(cls) {
|
|
129
|
-
return modelsForClass(cls)[0];
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Priority-ordered fallback chain (ChatThinking -> ChatBalanced ->
|
|
133
|
-
* ChatFast) over the small built-in list. The floor walked at resolve
|
|
134
|
-
* time when no agent / plugin / env / request model is set *and* the
|
|
135
|
-
* live catalogue yields nothing.
|
|
136
|
-
*/
|
|
137
|
-
const FALLBACK_MODEL_IDS = [
|
|
138
|
-
...modelsForClass(ModelClass.ChatThinking),
|
|
139
|
-
...modelsForClass(ModelClass.ChatBalanced),
|
|
140
|
-
...modelsForClass(ModelClass.ChatFast)
|
|
141
|
-
];
|
|
142
|
-
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region packages/model/src/serving.ts
|
|
145
|
-
/**
|
|
146
|
-
* Live Databricks Model Serving catalogue access.
|
|
147
|
-
*
|
|
148
|
-
* Lists the workspace's `/serving-endpoints` once per host and caches
|
|
149
|
-
* the result with a TTL via AppKit's `CacheManager`, with concurrent
|
|
150
|
-
* callers sharing one in-flight promise (the coalescing pattern of
|
|
151
|
-
* Python's `cachetools-async`). Surfaces each endpoint as a stable
|
|
152
|
-
* {@link ServingEndpointSummary} - including the Foundation Model API
|
|
153
|
-
* `quality` / `speed` / `cost` profile when present, the classified
|
|
154
|
-
* {@link ModelClass}, and (for embedding endpoints) the measured vector
|
|
155
|
-
* `dimension` - and snaps loose, human-typed names to real endpoint ids
|
|
156
|
-
* through `fuse.js` extended search so tokens like `"claude sonnet"`
|
|
157
|
-
* resolve to `databricks-claude-sonnet-4-6`.
|
|
158
|
-
*
|
|
159
|
-
* The class stamp and embedding dimension are computed once per cache
|
|
160
|
-
* load: every embedding endpoint is "pinged" in parallel and the
|
|
161
|
-
* resulting vector length recorded, so the cost is paid on a cache miss,
|
|
162
|
-
* not per read. The ping is best-effort - a failure logs at debug and
|
|
163
|
-
* leaves `dimension` unset rather than failing the whole listing.
|
|
164
|
-
*/
|
|
165
|
-
const log = logUtils.logger("model/serving");
|
|
166
|
-
/** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
|
|
167
|
-
const DEFAULT_MODEL_CACHE_TTL_MS = 300 * 1e3;
|
|
168
|
-
/** Default Fuse.js score threshold below which a fuzzy match is accepted. */
|
|
169
|
-
const DEFAULT_FUZZY_THRESHOLD = .4;
|
|
170
|
-
/** Cache key parts under which endpoint listings are stored. */
|
|
171
|
-
const CACHE_KEY_NAMESPACE = "serving-endpoints";
|
|
172
|
-
/**
|
|
173
|
-
* Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`.
|
|
174
|
-
* Endpoint visibility is effectively workspace-scoped (we cache by
|
|
175
|
-
* host in the key parts), so a single shared key lets every user of
|
|
176
|
-
* the same workspace share one cached fetch and coalesce on the
|
|
177
|
-
* in-flight promise. Permissions can differ in theory, but the
|
|
178
|
-
* Foundation Model API catalogue is the same view for every caller.
|
|
179
|
-
*/
|
|
180
|
-
const SHARED_USER_KEY = "model-shared";
|
|
181
|
-
/**
|
|
182
|
-
* List Model Serving endpoints for the workspace owning `client`,
|
|
183
|
-
* routed through AppKit's `CacheManager`. The manager gives us
|
|
184
|
-
* everything `cachetools.TTLCache` provides plus what
|
|
185
|
-
* `cachetools-async` adds on top: per-entry TTL, in-flight request
|
|
186
|
-
* coalescing (concurrent callers share one fetch via the manager's
|
|
187
|
-
* internal `inFlightRequests` map), bounded size, telemetry spans
|
|
188
|
-
* (`cache.getOrExecute`), and optional Lakebase persistence so the
|
|
189
|
-
* catalogue survives restarts when the lakebase plugin is wired up.
|
|
190
|
-
*
|
|
191
|
-
* Returns plain {@link ServingEndpointSummary} objects (a stable
|
|
192
|
-
* subset of the SDK type) so cache hits never expose stale SDK
|
|
193
|
-
* internals. Errors from `CacheManager` or the SDK fetch propagate
|
|
194
|
-
* to the caller - we don't swallow them so users see the real
|
|
195
|
-
* auth / network issue.
|
|
196
|
-
*
|
|
197
|
-
* @param host - Workspace host used as the cache key. Pass the value
|
|
198
|
-
* resolved from `client.config.getHost()` so multi-host apps share
|
|
199
|
-
* one entry per workspace.
|
|
200
|
-
* @param options.ttlMs - Override the default TTL just for this call.
|
|
201
|
-
* Forwarded to `CacheManager` as seconds.
|
|
202
|
-
*/
|
|
203
|
-
async function listServingEndpoints(client, host, options = {}) {
|
|
204
|
-
const ttlSec = Math.max(1, Math.round((options.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1e3));
|
|
205
|
-
return CacheManager.getInstanceSync().getOrExecute([CACHE_KEY_NAMESPACE, host], () => fetchEndpoints(client), SHARED_USER_KEY, { ttl: ttlSec });
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* List the workspace's serving endpoints as minimal
|
|
209
|
-
* {@link ServingEndpointSummary} objects straight from the SDK: no
|
|
210
|
-
* caching, and none of the cache-load enrichment ({@link listServingEndpoints}
|
|
211
|
-
* adds the {@link ModelClass} stamp and the embedding-dimension probe).
|
|
212
|
-
* Use this for a one-shot, dependency-light listing - e.g. a CLI that
|
|
213
|
-
* only needs names/tasks for fuzzy resolution and doesn't want AppKit's
|
|
214
|
-
* `CacheManager` or the per-embedding ping cost. Prefer
|
|
215
|
-
* {@link listServingEndpoints} for the cached, enriched view.
|
|
216
|
-
*/
|
|
217
|
-
async function listServingEndpointsUncached(client) {
|
|
218
|
-
const out = [];
|
|
219
|
-
for await (const ep of client.servingEndpoints.list()) {
|
|
220
|
-
if (!ep.name) continue;
|
|
221
|
-
const profile = extractProfile(ep);
|
|
222
|
-
out.push({
|
|
223
|
-
name: ep.name,
|
|
224
|
-
...ep.task !== void 0 ? { task: ep.task } : {},
|
|
225
|
-
...ep.state?.ready !== void 0 ? { state: String(ep.state.ready) } : {},
|
|
226
|
-
...ep.description !== void 0 ? { description: ep.description } : {},
|
|
227
|
-
...profile ? { profile } : {}
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
return out;
|
|
231
|
-
}
|
|
232
|
-
async function fetchEndpoints(client) {
|
|
233
|
-
const startedAt = Date.now();
|
|
234
|
-
const out = await listServingEndpointsUncached(client);
|
|
235
|
-
stampModelClasses(out);
|
|
236
|
-
await measureEmbeddingDimensions(client, out);
|
|
237
|
-
log.debug("listed", {
|
|
238
|
-
count: out.length,
|
|
239
|
-
elapsedMs: Date.now() - startedAt
|
|
240
|
-
});
|
|
241
|
-
return out;
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Stamp each summary's {@link ServingEndpointSummary.class} from the
|
|
245
|
-
* relative classification of the whole set. Mutates `summaries` in
|
|
246
|
-
* place. Endpoints the classifier doesn't recognize (custom, unscored,
|
|
247
|
-
* non-LLM) are left without a class.
|
|
248
|
-
*/
|
|
249
|
-
function stampModelClasses(summaries) {
|
|
250
|
-
const buckets = classifyEndpoints(summaries);
|
|
251
|
-
const classOf = /* @__PURE__ */ new Map();
|
|
252
|
-
for (const cls of MODEL_CLASS_ORDER) for (const ep of buckets[cls]) classOf.set(ep.name, cls);
|
|
253
|
-
for (const summary of summaries) {
|
|
254
|
-
const cls = classOf.get(summary.name);
|
|
255
|
-
if (cls !== void 0) summary.class = cls;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Measure the embedding vector dimension of every
|
|
260
|
-
* {@link ModelClass.Embedding} endpoint by pinging it once, all in
|
|
261
|
-
* parallel. Mutates the matching summaries in place with the resulting
|
|
262
|
-
* `dimension`. Runs only on a cache miss (it's called from
|
|
263
|
-
* {@link fetchEndpoints}), so the probe cost is amortized across the
|
|
264
|
-
* cached TTL window. Per-endpoint failures are swallowed (logged at
|
|
265
|
-
* warn) so one unreachable embedding model never fails the listing.
|
|
266
|
-
*/
|
|
267
|
-
async function measureEmbeddingDimensions(client, summaries) {
|
|
268
|
-
await Promise.all(summaries.filter((s) => s.class === ModelClass.Embedding).map(async (summary) => {
|
|
269
|
-
const dimension = await pingEmbeddingDimension(client, summary.name);
|
|
270
|
-
if (dimension !== void 0) summary.dimension = dimension;
|
|
271
|
-
}));
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Best-effort embedding dimension probe: query `name` with a tiny
|
|
275
|
-
* `"ping"` input and return the length of the returned vector. Returns
|
|
276
|
-
* `undefined` (and logs at warn) when the endpoint can't be queried or
|
|
277
|
-
* returns no vector - the dimension is informational, never required.
|
|
278
|
-
*/
|
|
279
|
-
async function pingEmbeddingDimension(client, name) {
|
|
280
|
-
try {
|
|
281
|
-
const dimension = (await client.servingEndpoints.query({
|
|
282
|
-
name,
|
|
283
|
-
input: "ping"
|
|
284
|
-
})).data?.[0]?.embedding?.length;
|
|
285
|
-
if (typeof dimension === "number" && dimension > 0) return dimension;
|
|
286
|
-
log.warn("embedding ping returned no vector", { name });
|
|
287
|
-
return;
|
|
288
|
-
} catch (err) {
|
|
289
|
-
log.warn("embedding ping failed", {
|
|
290
|
-
name,
|
|
291
|
-
error: commonUtils.errorMessage(err)
|
|
292
|
-
});
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Pull the Foundation Model API `quality` / `speed` / `cost` scores
|
|
298
|
-
* off a serving-endpoint listing entry. Databricks returns these
|
|
299
|
-
* under `config.served_entities[].foundation_model.ai_gateway_model_profile`
|
|
300
|
-
* (snake_case at the wire level, preserved verbatim by the SDK), but
|
|
301
|
-
* the field is newer than the typed `FoundationModel` interface, so we
|
|
302
|
-
* read it through a structural cast. Returns `undefined` when no
|
|
303
|
-
* served entity carries a profile (custom models, embeddings, and
|
|
304
|
-
* brand-new endpoints that Databricks has not scored yet).
|
|
305
|
-
*/
|
|
306
|
-
function extractProfile(ep) {
|
|
307
|
-
const entities = ep.config?.served_entities;
|
|
308
|
-
if (!entities) return void 0;
|
|
309
|
-
for (const entity of entities) {
|
|
310
|
-
const raw = entity.foundation_model?.ai_gateway_model_profile;
|
|
311
|
-
if (!raw) continue;
|
|
312
|
-
const profile = {};
|
|
313
|
-
if (Number.isFinite(raw.quality)) profile.quality = raw.quality;
|
|
314
|
-
if (Number.isFinite(raw.speed)) profile.speed = raw.speed;
|
|
315
|
-
if (Number.isFinite(raw.cost)) profile.cost = raw.cost;
|
|
316
|
-
if (Object.keys(profile).length > 0) return profile;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Force-evict cached endpoint listings via AppKit's `CacheManager`.
|
|
321
|
-
* With a `host` deletes that one workspace's entry; without one
|
|
322
|
-
* clears every cache entry on the manager (since `CacheManager`
|
|
323
|
-
* doesn't expose a namespace-scoped clear, this is the brute-force
|
|
324
|
-
* path - fine for tests, avoid in steady-state code).
|
|
325
|
-
*/
|
|
326
|
-
async function clearServingEndpointsCache(host) {
|
|
327
|
-
const cache = CacheManager.getInstanceSync();
|
|
328
|
-
if (host) {
|
|
329
|
-
const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
|
|
330
|
-
await cache.delete(key);
|
|
331
|
-
} else await cache.clear();
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* Fuzzy-rank endpoints by how closely their `name` matches `input`,
|
|
335
|
-
* best (lowest score) first, keeping only those within `threshold`:
|
|
336
|
-
*
|
|
337
|
-
* 1. An exact name match short-circuits to a single `score: 0` result.
|
|
338
|
-
* 2. Otherwise the input is tokenized (dashes / underscores / spaces
|
|
339
|
-
* become separators) and fed through Fuse.js extended search, which
|
|
340
|
-
* AND-s each token with fuzzy matching enabled - the "tokenized
|
|
341
|
-
* fuzzy match" a caller reaches for when they type `"claude sonnet"`
|
|
342
|
-
* instead of the full endpoint name.
|
|
343
|
-
*
|
|
344
|
-
* Returns `[]` for an empty endpoint list or when `input` tokenizes to
|
|
345
|
-
* nothing, so callers fall back to the raw input and let Databricks
|
|
346
|
-
* surface a clean 404. This multi-result core is shared by
|
|
347
|
-
* {@link resolveModelId} (single best) and the ranked `rankModels`
|
|
348
|
-
* selector.
|
|
349
|
-
*/
|
|
350
|
-
function searchServingEndpoints(input, endpoints, options = {}) {
|
|
351
|
-
if (endpoints.length === 0) return [];
|
|
352
|
-
for (const ep of endpoints) if (ep.name === input) return [{
|
|
353
|
-
endpoint: ep,
|
|
354
|
-
score: 0
|
|
355
|
-
}];
|
|
356
|
-
const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
|
|
357
|
-
const query = Array.from(stringUtils.tokenizeWithOptions({
|
|
358
|
-
lowerCase: true,
|
|
359
|
-
camelCase: false
|
|
360
|
-
}, input)).join(" ");
|
|
361
|
-
if (!query) return [];
|
|
362
|
-
return new Fuse(endpoints, {
|
|
363
|
-
keys: ["name"],
|
|
364
|
-
threshold,
|
|
365
|
-
ignoreLocation: true,
|
|
366
|
-
includeScore: true,
|
|
367
|
-
useExtendedSearch: true,
|
|
368
|
-
isCaseSensitive: false
|
|
369
|
-
}).search(query).filter((r) => (r.score ?? 0) <= threshold).map((r) => ({
|
|
370
|
-
endpoint: r.item,
|
|
371
|
-
score: r.score ?? 0
|
|
372
|
-
}));
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Snap a user-supplied model name to the single closest configured
|
|
376
|
-
* serving endpoint via {@link searchServingEndpoints}. Returns the
|
|
377
|
-
* input unchanged with `matched: false` when nothing scores within the
|
|
378
|
-
* threshold (or the catalogue is empty), so a deliberate model id is
|
|
379
|
-
* never silently rewritten to a similar-looking neighbour and the
|
|
380
|
-
* upstream call surfaces the canonical 404.
|
|
381
|
-
*/
|
|
382
|
-
function resolveModelId(input, endpoints, options = {}) {
|
|
383
|
-
const [best] = searchServingEndpoints(input, endpoints, options);
|
|
384
|
-
if (best) return {
|
|
385
|
-
modelId: best.endpoint.name,
|
|
386
|
-
matched: true,
|
|
387
|
-
score: best.score
|
|
388
|
-
};
|
|
389
|
-
return {
|
|
390
|
-
modelId: input,
|
|
391
|
-
matched: false
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
//#endregion
|
|
396
|
-
//#region packages/model/src/resolve.ts
|
|
397
|
-
/**
|
|
398
|
-
* Workspace-aware model selection.
|
|
399
|
-
*
|
|
400
|
-
* Given a caller's intent - a search string, a capability
|
|
401
|
-
* {@link ModelClass} ceiling, both, or nothing - the toolkit returns
|
|
402
|
-
* matching endpoints ranked by match quality then class, or collapses
|
|
403
|
-
* to the single best id the workspace actually has, degrading from
|
|
404
|
-
* "best in range" down to the static fallback floor. Selection is
|
|
405
|
-
* chat-only: embedding endpoints surface only when `modelClass` is
|
|
406
|
-
* explicitly {@link ModelClass.Embedding}.
|
|
407
|
-
*
|
|
408
|
-
* Two shapes of selection, each in a pure form (over an endpoint list
|
|
409
|
-
* the caller already holds) and an I/O wrapper (that lists
|
|
410
|
-
* `/serving-endpoints` first): ranking, which returns a match- then
|
|
411
|
-
* class-ordered list, and single-selection, which collapses to one id
|
|
412
|
-
* plus how it was reached and layers the operator-pinned fallback /
|
|
413
|
-
* static-floor safety net on top. A chat `modelClass` acts as a
|
|
414
|
-
* ceiling: that band and the less-capable chat bands below it are
|
|
415
|
-
* eligible (see {@link classesAtOrBelow}), so a `chat-balanced` ask can
|
|
416
|
-
* fall to `chat-fast` but never escalate to `chat-thinking`.
|
|
417
|
-
*/
|
|
418
|
-
/**
|
|
419
|
-
* Round a Fuse score to the display precision so version siblings that
|
|
420
|
-
* match a token identically (e.g. `opus-4-7` vs `opus-4-8` for the
|
|
421
|
-
* query `"opus"`) tie on match and let the class / within-class rank
|
|
422
|
-
* decide - which is what surfaces the newer, higher-quality sibling.
|
|
423
|
-
*/
|
|
424
|
-
function matchBucket(score) {
|
|
425
|
-
return Math.round((score ?? 0) * 1e3);
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Rank the live catalogue against a {@link ModelQuery}, best-first.
|
|
429
|
-
*
|
|
430
|
-
* Candidates are the classified endpoints in the eligible classes:
|
|
431
|
-
* {@link classesAtOrBelow} the requested `modelClass`, or - when none is
|
|
432
|
-
* given - the chat bands only ({@link CHAT_CLASS_ORDER}), so a general
|
|
433
|
-
* ask never surfaces an embedding endpoint. Each class bucket is
|
|
434
|
-
* already best-first from {@link classifyEndpoints}. Ranking is **match
|
|
435
|
-
* then class**:
|
|
436
|
-
*
|
|
437
|
-
* 1. With a `search`, only endpoints matching it survive, ordered by
|
|
438
|
-
* match distance (bucketed via {@link matchBucket} so near-identical
|
|
439
|
-
* scores tie), then by class (more capable first), then by the stable
|
|
440
|
-
* within-class rank.
|
|
441
|
-
* 2. Without a `search`, the class-then-rank candidate order stands.
|
|
442
|
-
*
|
|
443
|
-
* A `limit` truncates the result. Returns `[]` when nothing is
|
|
444
|
-
* eligible or matches - callers layer their own fallback.
|
|
445
|
-
*/
|
|
446
|
-
function rankModels(endpoints, query = {}) {
|
|
447
|
-
const classified = classifyEndpoints(endpoints);
|
|
448
|
-
const eligible = query.modelClass !== void 0 ? classesAtOrBelow(query.modelClass) : CHAT_CLASS_ORDER;
|
|
449
|
-
const candidates = [];
|
|
450
|
-
for (const modelClass of eligible) for (const endpoint of classified[modelClass]) candidates.push({
|
|
451
|
-
endpoint,
|
|
452
|
-
modelClass
|
|
453
|
-
});
|
|
454
|
-
const search = query.search?.trim();
|
|
455
|
-
let ranked;
|
|
456
|
-
if (search) {
|
|
457
|
-
const scores = /* @__PURE__ */ new Map();
|
|
458
|
-
for (const match of searchServingEndpoints(search, candidates.map((c) => c.endpoint), query.threshold !== void 0 ? { threshold: query.threshold } : {})) scores.set(match.endpoint.name, match.score);
|
|
459
|
-
ranked = candidates.filter((c) => scores.has(c.endpoint.name)).map((c) => ({
|
|
460
|
-
...c,
|
|
461
|
-
score: scores.get(c.endpoint.name)
|
|
462
|
-
})).sort((a, b) => {
|
|
463
|
-
const byMatch = matchBucket(a.score) - matchBucket(b.score);
|
|
464
|
-
if (byMatch !== 0) return byMatch;
|
|
465
|
-
return MODEL_CLASS_ORDER.indexOf(a.modelClass) - MODEL_CLASS_ORDER.indexOf(b.modelClass);
|
|
466
|
-
});
|
|
467
|
-
} else ranked = candidates;
|
|
468
|
-
return query.limit !== void 0 ? ranked.slice(0, Math.max(0, query.limit)) : ranked;
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Rank a workspace's catalogue in one call: list its
|
|
472
|
-
* `/serving-endpoints` (cached) and run {@link rankModels} over the
|
|
473
|
-
* result. The list counterpart to {@link selectModel}, for a consumer
|
|
474
|
-
* that wants the full ranked set (a model picker, a CLI) rather than a
|
|
475
|
-
* single id. Catalogue fetches fail loud: network / auth errors
|
|
476
|
-
* propagate so the caller sees the real SDK message.
|
|
477
|
-
*
|
|
478
|
-
* @param host - Workspace host used as the cache key. Pass the value
|
|
479
|
-
* resolved from `client.config.getHost()`.
|
|
480
|
-
*/
|
|
481
|
-
async function searchModels(client, host, input = {}) {
|
|
482
|
-
return rankModels(await listServingEndpoints(client, host, input.ttlMs !== void 0 ? { ttlMs: input.ttlMs } : {}), input);
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* Resolve a model id for a workspace in one call: list its
|
|
486
|
-
* `/serving-endpoints` (cached) and run {@link resolveModel} over the
|
|
487
|
-
* result. This is the entry point for any consumer that holds a
|
|
488
|
-
* `WorkspaceClient` and just wants a usable model name - a Lakeflow
|
|
489
|
-
* job, a one-off script, or the Mastra plugin alike.
|
|
490
|
-
*
|
|
491
|
-
* Cheap exit: when an `explicit` name is given and `fuzzy` is off, the
|
|
492
|
-
* catalogue is never fetched - the name is returned verbatim and
|
|
493
|
-
* Databricks surfaces the canonical 404 if it doesn't exist. Catalogue
|
|
494
|
-
* fetches otherwise fail loud: network / auth errors propagate so the
|
|
495
|
-
* caller sees the real SDK message instead of a silent fallback.
|
|
496
|
-
*
|
|
497
|
-
* @param host - Workspace host used as the cache key. Pass the value
|
|
498
|
-
* resolved from `client.config.getHost()`.
|
|
499
|
-
*/
|
|
500
|
-
async function selectModel(client, host, input = {}) {
|
|
501
|
-
if (input.explicit !== void 0 && input.fuzzy === false) return {
|
|
502
|
-
modelId: input.explicit,
|
|
503
|
-
source: "explicit"
|
|
504
|
-
};
|
|
505
|
-
return resolveModel(await listServingEndpoints(client, host, { ...input.ttlMs !== void 0 ? { ttlMs: input.ttlMs } : {} }), input);
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Resolve a single model id from the live catalogue and caller intent,
|
|
509
|
-
* delegating the live selection to {@link rankModels} with `limit: 1`.
|
|
510
|
-
*
|
|
511
|
-
* 1. **Explicit ask**: with `fuzzy` off, returned verbatim; otherwise
|
|
512
|
-
* fuzzy-ranked within the (optional) class ceiling and the best taken,
|
|
513
|
-
* falling back to the input verbatim when nothing matches.
|
|
514
|
-
* 2. **No explicit ask**: an operator-pinned `fallback` that exists in
|
|
515
|
-
* the live catalogue wins first; then the ranked live catalogue
|
|
516
|
-
* (class ceiling applied); then the static {@link FALLBACK_MODEL_IDS}
|
|
517
|
-
* floor when the catalogue yields nothing in range.
|
|
518
|
-
*/
|
|
519
|
-
function resolveModel(endpoints, input = {}) {
|
|
520
|
-
if (input.explicit !== void 0) {
|
|
521
|
-
if (input.fuzzy === false) return {
|
|
522
|
-
modelId: input.explicit,
|
|
523
|
-
source: "explicit"
|
|
524
|
-
};
|
|
525
|
-
const [top] = rankModels(endpoints, buildQuery(input, input.explicit));
|
|
526
|
-
return {
|
|
527
|
-
modelId: top?.endpoint.name ?? input.explicit,
|
|
528
|
-
source: "fuzzy-match"
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
if (input.modelClass === void 0 && input.fallbacks && input.fallbacks.length > 0) {
|
|
532
|
-
const present = new Set(endpoints.map((e) => e.name));
|
|
533
|
-
const pinned = input.fallbacks.find((id) => present.has(id));
|
|
534
|
-
if (pinned) return {
|
|
535
|
-
modelId: pinned,
|
|
536
|
-
source: "fallback"
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
const source = input.modelClass !== void 0 ? "class" : "fallback";
|
|
540
|
-
const [top] = rankModels(endpoints, buildQuery(input, void 0));
|
|
541
|
-
if (top) return {
|
|
542
|
-
modelId: top.endpoint.name,
|
|
543
|
-
source
|
|
544
|
-
};
|
|
545
|
-
return {
|
|
546
|
-
modelId: pickFirstAvailable(input.modelClass !== void 0 ? dedupe([...modelsForClass(input.modelClass), ...FALLBACK_MODEL_IDS]) : dedupe([...input.fallbacks ?? [], ...FALLBACK_MODEL_IDS]), endpoints),
|
|
547
|
-
source
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
/** Build a {@link ModelQuery} from {@link ResolveModelInput} for the `limit: 1` delegation. */
|
|
551
|
-
function buildQuery(input, search) {
|
|
552
|
-
return {
|
|
553
|
-
...search !== void 0 ? { search } : {},
|
|
554
|
-
...input.modelClass !== void 0 ? { modelClass: input.modelClass } : {},
|
|
555
|
-
...input.threshold !== void 0 ? { threshold: input.threshold } : {},
|
|
556
|
-
limit: 1
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
/** Drop duplicate ids while preserving first-seen order. */
|
|
560
|
-
function dedupe(ids) {
|
|
561
|
-
return [...new Set(ids)];
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* Find the first id in `candidates` whose endpoint is present in
|
|
565
|
-
* `endpoints`. Returns the top candidate when the workspace has none
|
|
566
|
-
* of them so callers always get a string; an offline workspace then
|
|
567
|
-
* receives a clean 404 from Databricks instead of a malformed config.
|
|
568
|
-
*/
|
|
569
|
-
function pickFirstAvailable(candidates, endpoints) {
|
|
570
|
-
const present = new Set(endpoints.map((e) => e.name));
|
|
571
|
-
for (const candidate of candidates) if (present.has(candidate)) return candidate;
|
|
572
|
-
return candidates[0] ?? FALLBACK_MODEL_IDS[0];
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
//#endregion
|
|
576
|
-
export { CHAT_CLASS_ORDER, DEFAULT_FUZZY_THRESHOLD, DEFAULT_MODEL_CACHE_TTL_MS, FALLBACK_MODEL_IDS, MODEL_CLASS_ORDER, classesAtOrBelow, clearServingEndpointsCache, isChatClass, listServingEndpoints, listServingEndpointsUncached, modelForClass, modelsForClass, parseModelClass, rankModels, resolveModel, resolveModelId, searchModels, searchServingEndpoints, selectModel };
|