@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/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 };