@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.
- package/README.md +76 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/src/classes.d.ts +51 -0
- package/dist/src/classes.js +71 -0
- package/dist/src/fallback.d.ts +17 -16
- package/dist/src/fallback.js +24 -23
- package/dist/src/resolve.d.ts +79 -28
- package/dist/src/resolve.js +142 -56
- package/dist/src/serving.d.ts +57 -31
- package/dist/src/serving.js +118 -55
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/index.ts +2 -1
- package/package.json +3 -3
- package/src/classes.ts +75 -0
- package/src/fallback.ts +28 -23
- package/src/resolve.ts +182 -73
- package/src/serving.ts +160 -67
package/dist/src/resolve.js
CHANGED
|
@@ -1,21 +1,116 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workspace-aware model selection.
|
|
3
3
|
*
|
|
4
|
-
* Given a caller's intent
|
|
5
|
-
* {@link
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* the
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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)];
|
package/dist/src/serving.d.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
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
|
|
15
|
-
import type {
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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[],
|
|
122
|
+
export declare function resolveModelId(input: string, endpoints: readonly ServingEndpointSummary[], options?: ResolveModelOptions): ResolvedModel;
|
package/dist/src/serving.js
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
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 {
|
|
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
|
|
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,
|
|
56
|
-
const ttlSec = Math.max(1, Math.round((
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
144
|
-
if (endpoints.length === 0)
|
|
145
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
}
|