@dbx-tools/model 0.1.9

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 ADDED
@@ -0,0 +1,165 @@
1
+ # @dbx-tools/node-model
2
+
3
+ Workspace-aware Databricks Model Serving selection.
4
+
5
+ Import this package when server-side code needs to turn a loose model request
6
+ like `"claude sonnet"` or `"chat-fast"` into a concrete serving endpoint in the
7
+ current workspace. It lists `/serving-endpoints`, caches and enriches the
8
+ catalogue, classifies endpoints by capability, fuzzy-matches names, and falls
9
+ back to a small static floor when the live catalogue is unavailable.
10
+
11
+ Browser-safe request/result schemas and endpoint classification types live in
12
+ [`@dbx-tools/shared-model`](../../shared/model).
13
+
14
+ Key features:
15
+
16
+ - Lists Databricks Model Serving endpoints through the SDK and normalizes them
17
+ into a stable summary shape.
18
+ - Classifies endpoints into chat-thinking, chat-balanced, chat-fast, and
19
+ embedding classes using Foundation Model API scores and family heuristics.
20
+ - Resolves loose user input such as `"sonnet"` or `"chat fast"` to a concrete
21
+ endpoint id.
22
+ - Supports class ceilings so callers can ask for a capability band without
23
+ accidentally escalating to a larger model.
24
+ - Caches enriched catalogues per workspace host through AppKit cache utilities.
25
+ - Provides a small static fallback floor for local tools and degraded workspace
26
+ access.
27
+
28
+ ## Why Not Just AppKit Serving?
29
+
30
+ Native AppKit's Model Serving plugin is the right choice when you already know
31
+ the endpoint alias you want. It gives you authenticated invoke/stream routes,
32
+ OBO execution, generated endpoint types, request-body filtering, and frontend
33
+ hooks.
34
+
35
+ Use this package before or beside that layer when the hard part is choosing the
36
+ endpoint:
37
+
38
+ - resolve loose human input such as `"sonnet"` or `"fast"` against the live
39
+ workspace catalogue;
40
+ - group endpoints into capability classes like `chat-thinking`, `chat-balanced`,
41
+ `chat-fast`, and `embedding`;
42
+ - enforce class ceilings so a caller can degrade to smaller models without
43
+ escalating to a larger one;
44
+ - build model pickers and debug routes from a cached, enriched endpoint list;
45
+ - keep local agents and CLIs working with a static fallback when catalogue
46
+ access is unavailable.
47
+
48
+ ## Select One Model
49
+
50
+ ```ts
51
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
52
+ import { resolve } from "@dbx-tools/node-model";
53
+
54
+ const client = new WorkspaceClient({});
55
+ const host = String(await client.config.getHost());
56
+
57
+ const selected = await resolve.selectModel(client, host, {
58
+ explicit: "claude sonnet",
59
+ });
60
+
61
+ console.log(selected.modelId, selected.source);
62
+ ```
63
+
64
+ `selectModel()` is the high-level helper for agents and CLIs. It reads the live
65
+ catalogue, applies fuzzy matching when an explicit string is present, then
66
+ returns a single `modelId` plus a source label explaining why that endpoint won.
67
+
68
+ The `source` label is useful for logs and debug UIs. It distinguishes explicit
69
+ matches from class-based selection, environment defaults, and fallback results,
70
+ so operators can tell whether a request used the intended model policy.
71
+
72
+ ## Build A Model Picker
73
+
74
+ ```ts
75
+ import { resolve } from "@dbx-tools/node-model";
76
+
77
+ const ranked = await resolve.searchModels(client, host, {
78
+ search: "opus",
79
+ modelClass: "chat-thinking",
80
+ limit: 5,
81
+ });
82
+ ```
83
+
84
+ Use `searchModels()` for UI pickers and debug routes. It returns ranked models
85
+ with match scores and endpoint summaries, using the same fuzzy threshold and
86
+ class ceiling logic as `selectModel()`.
87
+
88
+ ## Work With A Held Catalogue
89
+
90
+ When you already have endpoint summaries, use the pure resolver functions from
91
+ `resolve` and `serving` without another workspace call:
92
+
93
+ ```ts
94
+ import { resolve, serving } from "@dbx-tools/node-model";
95
+
96
+ const endpoints = await serving.listServingEndpoints(client, host);
97
+ const ranked = resolve.rankModels(endpoints, { search: "sonnet", limit: 3 });
98
+ const picked = resolve.resolveModel(endpoints, {
99
+ explicit: "claude sonnet",
100
+ modelClass: "chat-balanced",
101
+ });
102
+ ```
103
+
104
+ The class acts as a ceiling. `chat-balanced` may fall back to `chat-fast`, but
105
+ will not escalate to `chat-thinking`. Embedding endpoints are considered only
106
+ when the requested class is `embedding`.
107
+
108
+ ## List And Cache Serving Endpoints
109
+
110
+ ```ts
111
+ import { serving } from "@dbx-tools/node-model";
112
+
113
+ const endpoints = await serving.listServingEndpoints(client, host, {
114
+ ttlMs: 5 * 60_000,
115
+ });
116
+
117
+ const raw = await serving.listServingEndpointsUncached(client);
118
+ await serving.clearServingEndpointsCache(host);
119
+ ```
120
+
121
+ `listServingEndpoints()` uses AppKit's `CacheManager`, enriches endpoints with
122
+ classification and embedding dimensions, and keys the cache by workspace host.
123
+ `listServingEndpointsUncached()` is useful for simple scripts that only need the
124
+ SDK response and do not want a cache dependency.
125
+
126
+ ## Fuzzy Resolve Endpoint Names
127
+
128
+ ```ts
129
+ const matches = serving.searchServingEndpoints("claude sonnet", endpoints, {
130
+ threshold: 0.35,
131
+ });
132
+
133
+ const endpointName = serving.resolveModelId("sonnet", endpoints);
134
+ ```
135
+
136
+ Fuzzy matching is intentionally a server concern because it depends on the live
137
+ workspace catalogue and may re-list on misses. Disable it in callers that require
138
+ exact endpoint ids.
139
+
140
+ ## Use Static Fallbacks
141
+
142
+ ```ts
143
+ import { classes, fallback } from "@dbx-tools/node-model";
144
+ import { model } from "@dbx-tools/shared-model";
145
+
146
+ const cls = classes.parseModelClass("chat-fast") ?? model.ModelClass.ChatFast;
147
+ const modelId = fallback.modelForClass(cls);
148
+ ```
149
+
150
+ The fallback floor gives agents and local scripts a stable answer when a
151
+ workspace cannot list endpoints. Prefer live catalogue resolution for production
152
+ policy decisions; fallbacks are a last resort.
153
+
154
+ ## Modules
155
+
156
+ - `resolve` - high-level `selectModel`, ranked search, and catalogue-held
157
+ resolver functions.
158
+ - `serving` - Databricks serving-endpoint listing, cache management, fuzzy
159
+ search, and endpoint-id resolution.
160
+ - `classes` - model-class parsing, ordering, and class-ceiling helpers.
161
+ - `fallback` - static fallback model ids per class.
162
+
163
+ The AppKit-Mastra integration uses this package through
164
+ [`@dbx-tools/node-appkit-mastra`](../appkit-mastra); the local OpenAI-compatible
165
+ gateway uses it through [`@dbx-tools/model-proxy`](../../cli/model-proxy).
package/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as classes from "./src/classes";
6
+ export * as fallback from "./src/fallback";
7
+ export * as resolve from "./src/resolve";
8
+ export * as serving from "./src/serving";
9
+ export type { ResolveModelInput, ResolvedModelSelection, SelectModelInput, SearchModelsInput } from "./src/resolve";
10
+ export type { WorkspaceClientLike, ListServingEndpointsOptions, ResolvedModel, ResolveModelOptions, ScoredEndpoint } from "./src/serving";
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@dbx-tools/model",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "git+https://github.com/reggie-db/dbx-tools.git",
6
+ "directory": "workspaces/node/model"
7
+ },
8
+ "devDependencies": {
9
+ "@types/node": "^24.6.0",
10
+ "tsx": "^4.23.0",
11
+ "typescript": "^5.9.3"
12
+ },
13
+ "dependencies": {
14
+ "@databricks/appkit": "^0.43.0",
15
+ "fuse.js": "^7.4.2",
16
+ "@dbx-tools/shared-core": "0.1.9",
17
+ "@dbx-tools/appkit": "0.1.9",
18
+ "@dbx-tools/shared-model": "0.1.9"
19
+ },
20
+ "main": "index.ts",
21
+ "license": "UNLICENSED",
22
+ "version": "0.1.9",
23
+ "types": "index.ts",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./index.ts",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "dbxToolsConfig": {
30
+ "tags": [
31
+ "node"
32
+ ]
33
+ },
34
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
35
+ "scripts": {
36
+ "build": "projen build",
37
+ "compile": "projen compile",
38
+ "default": "projen default",
39
+ "package": "projen package",
40
+ "post-compile": "projen post-compile",
41
+ "pre-compile": "projen pre-compile",
42
+ "test": "projen test",
43
+ "watch": "projen watch",
44
+ "projen": "projen"
45
+ }
46
+ }
package/src/classes.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Model-class ordering for the model service.
3
+ *
4
+ * `@dbx-tools/shared-model` declares the {@link ModelClass} values and their
5
+ * schema; the *behavior* over those values - the chat capability order, the
6
+ * "this class and below" ceiling, and coercing loose request input to a class -
7
+ * lives here in the service so the shared wire-format surface stays purely
8
+ * declarative.
9
+ */
10
+
11
+ import { model } from "@dbx-tools/shared-model";
12
+
13
+ type ModelClass = model.ModelClass;
14
+ const { ModelClass, ModelClassSchema } = model;
15
+
16
+ /**
17
+ * Chat capability ladder in descending order - most capable
18
+ * ({@link ModelClass.ChatThinking}) first, least ({@link ModelClass.ChatFast})
19
+ * last. {@link ModelClass.Embedding} is a separate modality and is deliberately
20
+ * absent: the ceiling never spans it. This is the order used to filter "this
21
+ * class and below" and to break ranking ties toward the more capable model.
22
+ */
23
+ export const CHAT_CLASS_ORDER: readonly ModelClass[] = [
24
+ ModelClass.ChatThinking,
25
+ ModelClass.ChatBalanced,
26
+ ModelClass.ChatFast,
27
+ ];
28
+
29
+ /**
30
+ * Every class in display order: the chat ladder followed by
31
+ * {@link ModelClass.Embedding}. Used when flattening a full classification
32
+ * (e.g. stamping the class onto each cached endpoint).
33
+ */
34
+ export const MODEL_CLASS_ORDER: readonly ModelClass[] = [...CHAT_CLASS_ORDER, ModelClass.Embedding];
35
+
36
+ /** Whether `cls` is one of the chat capability bands (vs. embedding). */
37
+ export function isChatClass(cls: ModelClass): boolean {
38
+ return CHAT_CLASS_ORDER.includes(cls);
39
+ }
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. Lets a
44
+ * route accept a class request without throwing on junk input.
45
+ *
46
+ * Matches the full slug first (`"chat-fast"`, `"embedding"`), then - for a bare
47
+ * chat band - retries with a `chat-` prefix, so the shorthand `"thinking"` /
48
+ * `"balanced"` / `"fast"` resolves to the corresponding `chat-*` class.
49
+ */
50
+ export function parseModelClass(value: unknown): ModelClass | null {
51
+ const exact = ModelClassSchema.safeParse(value);
52
+ if (exact.success) return exact.data;
53
+ const prefixed = ModelClassSchema.safeParse(`chat-${value}`);
54
+ return prefixed.success ? prefixed.data : null;
55
+ }
56
+
57
+ /**
58
+ * Classes at or below `cls` in chat capability: the class itself plus every
59
+ * less-capable chat band, in {@link CHAT_CLASS_ORDER}. Treats a chat `cls` as a
60
+ * ceiling - requesting {@link ModelClass.ChatBalanced} yields
61
+ * `[ChatBalanced, ChatFast]`, never {@link ModelClass.ChatThinking} - so a
62
+ * class ask can degrade downward to a smaller chat model but never escalate to
63
+ * a larger one.
64
+ *
65
+ * {@link ModelClass.Embedding} is its own modality, not a rung on the chat
66
+ * ladder, so it yields just `[Embedding]`. An unrecognized class yields the
67
+ * full chat ladder.
68
+ */
69
+ export function classesAtOrBelow(cls: ModelClass): ModelClass[] {
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
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Server-side offline fallback opinion for model selection.
3
+ *
4
+ * When the live `/serving-endpoints` catalogue can't be read at all - no user
5
+ * token, the service principal can't list, or the workspace is unreachable -
6
+ * the resolver still has to name *some* endpoint. This module holds that floor:
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)
10
+ * and ordered best-first.
11
+ *
12
+ * This is deliberately *server-only*. A browser client never talks to
13
+ * Databricks directly - it always goes through this server - so it has nothing
14
+ * 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.
17
+ */
18
+
19
+ import { classify, model, type FamilyClass } from "@dbx-tools/shared-model";
20
+
21
+ type ModelClass = model.ModelClass;
22
+ const { ModelClass } = model;
23
+
24
+ /**
25
+ * Small, last-resort set of well-known Foundation Model API endpoint names,
26
+ * ordered best-first within each class by {@link classify.classifyByFamily}.
27
+ * Used only as the floor when the live `/serving-endpoints` catalogue can't be
28
+ * read at resolve time; the live, score-driven classification supersedes it
29
+ * whenever the workspace listing is available. Classes are not hard-coded here
30
+ * - each name is classified by family heuristic.
31
+ */
32
+ const FALLBACK_MODEL_NAMES: readonly string[] = [
33
+ "databricks-claude-opus-4-8",
34
+ "databricks-gpt-5-5-pro",
35
+ "databricks-gemini-3-1-pro",
36
+ "databricks-claude-sonnet-4-6",
37
+ "databricks-gpt-5-5",
38
+ "databricks-meta-llama-3-3-70b-instruct",
39
+ "databricks-claude-haiku-4-5",
40
+ "databricks-gpt-5-nano",
41
+ "databricks-meta-llama-3-1-8b-instruct",
42
+ ];
43
+
44
+ /**
45
+ * Static fallback model ids for a chat class, drawn from the small built-in
46
+ * {@link FALLBACK_MODEL_NAMES} list and ordered best-first by family rank. Sync
47
+ * and workspace-independent: this is the *fallback opinion* used to seed default
48
+ * lists or when the live catalogue is unreachable - live resolution prefers
49
+ * {@link classify.classifyEndpoints}. Returns `[]` for
50
+ * {@link ModelClass.Embedding} (the floor is chat-only).
51
+ */
52
+ export function modelsForClass(cls: ModelClass): readonly string[] {
53
+ return FALLBACK_MODEL_NAMES.map((name) => ({ name, c: classify.classifyByFamily(name) }))
54
+ .filter((x): x is { name: string; c: FamilyClass } => x.c !== null && x.c.class === cls)
55
+ .sort((a, b) => b.c.rank - a.c.rank)
56
+ .map((x) => x.name);
57
+ }
58
+
59
+ /** Top static fallback model id for a chat class. */
60
+ export function modelForClass(cls: ModelClass): string {
61
+ return modelsForClass(cls)[0]!;
62
+ }
63
+
64
+ /**
65
+ * Priority-ordered fallback chain (ChatThinking -> ChatBalanced -> ChatFast)
66
+ * over the small built-in list. The floor walked at resolve time when no agent
67
+ * / plugin / env / request model is set *and* the live catalogue yields nothing.
68
+ */
69
+ export const FALLBACK_MODEL_IDS: readonly string[] = [
70
+ ...modelsForClass(ModelClass.ChatThinking),
71
+ ...modelsForClass(ModelClass.ChatBalanced),
72
+ ...modelsForClass(ModelClass.ChatFast),
73
+ ];
package/src/resolve.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Workspace-aware model selection.
3
+ *
4
+ * Given a caller's intent - a search string, a capability {@link ModelClass}
5
+ * ceiling, both, or nothing - the toolkit returns matching endpoints ranked by
6
+ * match quality then class, or collapses to the single best id the workspace
7
+ * actually has, degrading from "best in range" down to the static fallback
8
+ * floor. Selection is chat-only: embedding endpoints surface only when
9
+ * `modelClass` is explicitly {@link ModelClass.Embedding}.
10
+ *
11
+ * Two shapes of selection, each in a pure form (over an endpoint list the
12
+ * caller already holds) and an I/O wrapper (that lists `/serving-endpoints`
13
+ * first): ranking, which returns a match- then class-ordered list, and
14
+ * single-selection, which collapses to one id plus how it was reached and
15
+ * layers the operator-pinned fallback / static-floor safety net on top. A chat
16
+ * `modelClass` acts as a ceiling: that band and the less-capable chat bands
17
+ * below it are eligible (see {@link classesAtOrBelow}), so a `chat-balanced`
18
+ * ask can fall to `chat-fast` but never escalate to `chat-thinking`.
19
+ */
20
+
21
+ import {
22
+ classify,
23
+ model,
24
+ type ModelQuery,
25
+ type RankedModel,
26
+ type ServingEndpointSummary,
27
+ } from "@dbx-tools/shared-model";
28
+
29
+ import { CHAT_CLASS_ORDER, classesAtOrBelow, MODEL_CLASS_ORDER } from "./classes";
30
+ import { FALLBACK_MODEL_IDS, modelsForClass } from "./fallback";
31
+ import { listServingEndpoints, searchServingEndpoints, type WorkspaceClientLike } from "./serving";
32
+
33
+ type ModelClass = model.ModelClass;
34
+
35
+ /** Caller intent passed to {@link resolveModel}. */
36
+ export interface ResolveModelInput {
37
+ /**
38
+ * Explicit model id / loose name (per-request override, agent / plugin
39
+ * default, or env var). When set it wins over `modelClass` and `fallbacks`.
40
+ */
41
+ explicit?: string;
42
+ /**
43
+ * Fuzzy-match an `explicit` name against the live catalogue so loose names
44
+ * like `"claude sonnet"` resolve. Default `true`. When `false` the explicit
45
+ * input is returned verbatim (Databricks surfaces the canonical 404 if it
46
+ * doesn't exist).
47
+ */
48
+ fuzzy?: boolean;
49
+ /** Fuse.js threshold forwarded to the fuzzy `search` match ({@link searchServingEndpoints}). */
50
+ threshold?: number;
51
+ /**
52
+ * Chat capability class to resolve when no `explicit` id is given. The live
53
+ * catalogue is classified by its Foundation Model API scores and the top
54
+ * available model in the class (and the chat bands below it) wins, falling
55
+ * back to the class's small static list.
56
+ */
57
+ modelClass?: ModelClass;
58
+ /**
59
+ * Operator-supplied fallback ids tried *first* in the no-explicit, no-class
60
+ * path (e.g. a regulated workspace pinned to an approved subset), ahead of
61
+ * the auto-classified catalogue.
62
+ */
63
+ fallbacks?: readonly string[];
64
+ }
65
+
66
+ /** Outcome of {@link resolveModel}: the chosen id plus how it was reached. */
67
+ export interface ResolvedModelSelection {
68
+ modelId: string;
69
+ source: "explicit" | "fuzzy-match" | "class" | "fallback";
70
+ }
71
+
72
+ /** Intent + catalogue knobs passed to {@link selectModel}. */
73
+ export interface SelectModelInput extends ResolveModelInput {
74
+ /** TTL override for the cached `/serving-endpoints` listing, in ms. */
75
+ ttlMs?: number;
76
+ }
77
+
78
+ /** TTL override merged into a {@link ModelQuery} for {@link searchModels}. */
79
+ export interface SearchModelsInput extends ModelQuery {
80
+ /** TTL override for the cached `/serving-endpoints` listing, in ms. */
81
+ ttlMs?: number;
82
+ }
83
+
84
+ /**
85
+ * Round a Fuse score to the display precision so version siblings that match a
86
+ * token identically (e.g. `opus-4-7` vs `opus-4-8` for the query `"opus"`) tie
87
+ * on match and let the class / within-class rank decide - which is what
88
+ * surfaces the newer, higher-quality sibling.
89
+ */
90
+ function matchBucket(score: number | undefined): number {
91
+ return Math.round((score ?? 0) * 1000);
92
+ }
93
+
94
+ /**
95
+ * Rank the live catalogue against a {@link ModelQuery}, best-first.
96
+ *
97
+ * Candidates are the classified endpoints in the eligible classes:
98
+ * {@link classesAtOrBelow} the requested `modelClass`, or - when none is given
99
+ * - the chat bands only ({@link CHAT_CLASS_ORDER}), so a general ask never
100
+ * surfaces an embedding endpoint. Each class bucket is already best-first from
101
+ * {@link classify.classifyEndpoints}. Ranking is **match then class**:
102
+ *
103
+ * 1. With a `search`, only endpoints matching it survive, ordered by match
104
+ * distance (bucketed via {@link matchBucket} so near-identical scores tie),
105
+ * then by class (more capable first), then by the stable within-class rank.
106
+ * 2. Without a `search`, the class-then-rank candidate order stands.
107
+ *
108
+ * A `limit` truncates the result. Returns `[]` when nothing is eligible or
109
+ * matches - callers layer their own fallback.
110
+ */
111
+ export function rankModels(
112
+ endpoints: readonly ServingEndpointSummary[],
113
+ query: ModelQuery = {},
114
+ ): RankedModel[] {
115
+ const classified = classify.classifyEndpoints(endpoints);
116
+ const eligible =
117
+ query.modelClass !== undefined ? classesAtOrBelow(query.modelClass) : CHAT_CLASS_ORDER;
118
+
119
+ // Flatten eligible classes in capability order, carrying each endpoint's
120
+ // class; bucket order is already best-first.
121
+ const candidates: RankedModel[] = [];
122
+ for (const modelClass of eligible) {
123
+ for (const endpoint of classified[modelClass]) candidates.push({ endpoint, modelClass });
124
+ }
125
+
126
+ const search = query.search?.trim();
127
+ let ranked: RankedModel[];
128
+ if (search) {
129
+ const scores = new Map<string, number>();
130
+ for (const match of searchServingEndpoints(
131
+ search,
132
+ candidates.map((c) => c.endpoint),
133
+ query.threshold !== undefined ? { threshold: query.threshold } : {},
134
+ )) {
135
+ scores.set(match.endpoint.name, match.score);
136
+ }
137
+ // `Array.prototype.sort` is stable, so endpoints equal on match and class
138
+ // keep their best-first within-class order.
139
+ ranked = candidates
140
+ .filter((c) => scores.has(c.endpoint.name))
141
+ .map((c) => ({ ...c, score: scores.get(c.endpoint.name) }))
142
+ .sort((a, b) => {
143
+ const byMatch = matchBucket(a.score) - matchBucket(b.score);
144
+ if (byMatch !== 0) return byMatch;
145
+ return MODEL_CLASS_ORDER.indexOf(a.modelClass) - MODEL_CLASS_ORDER.indexOf(b.modelClass);
146
+ });
147
+ } else {
148
+ ranked = candidates;
149
+ }
150
+
151
+ return query.limit !== undefined ? ranked.slice(0, Math.max(0, query.limit)) : ranked;
152
+ }
153
+
154
+ /**
155
+ * Rank a workspace's catalogue in one call: list its `/serving-endpoints`
156
+ * (cached) and run {@link rankModels} over the result. The list counterpart to
157
+ * {@link selectModel}, for a consumer that wants the full ranked set (a model
158
+ * picker, a CLI) rather than a single id. Catalogue fetches fail loud: network
159
+ * / auth errors propagate so the caller sees the real SDK message.
160
+ *
161
+ * @param host - Workspace host used as the cache key. Pass the value resolved
162
+ * from `client.config.getHost()`.
163
+ */
164
+ export async function searchModels(
165
+ client: WorkspaceClientLike,
166
+ host: string,
167
+ input: SearchModelsInput = {},
168
+ ): Promise<RankedModel[]> {
169
+ const endpoints = await listServingEndpoints(
170
+ client,
171
+ host,
172
+ input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {},
173
+ );
174
+ return rankModels(endpoints, input);
175
+ }
176
+
177
+ /**
178
+ * Resolve a model id for a workspace in one call: list its `/serving-endpoints`
179
+ * (cached) and run {@link resolveModel} over the result. This is the entry
180
+ * point for any consumer that holds a `WorkspaceClient` and just wants a usable
181
+ * model name - a Lakeflow job, a one-off script, or the Mastra plugin alike.
182
+ *
183
+ * Cheap exit: when an `explicit` name is given and `fuzzy` is off, the
184
+ * catalogue is never fetched - the name is returned verbatim and Databricks
185
+ * surfaces the canonical 404 if it doesn't exist. Catalogue fetches otherwise
186
+ * fail loud: network / auth errors propagate so the caller sees the real SDK
187
+ * message instead of a silent fallback.
188
+ *
189
+ * @param host - Workspace host used as the cache key. Pass the value resolved
190
+ * from `client.config.getHost()`.
191
+ */
192
+ export async function selectModel(
193
+ client: WorkspaceClientLike,
194
+ host: string,
195
+ input: SelectModelInput = {},
196
+ ): Promise<ResolvedModelSelection> {
197
+ if (input.explicit !== undefined && input.fuzzy === false) {
198
+ return { modelId: input.explicit, source: "explicit" };
199
+ }
200
+ const endpoints = await listServingEndpoints(client, host, {
201
+ ...(input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {}),
202
+ });
203
+ return resolveModel(endpoints, input);
204
+ }
205
+
206
+ /**
207
+ * Resolve a single model id from the live catalogue and caller intent,
208
+ * delegating the live selection to {@link rankModels} with `limit: 1`.
209
+ *
210
+ * 1. **Explicit ask**: with `fuzzy` off, returned verbatim; otherwise
211
+ * fuzzy-ranked within the (optional) class ceiling and the best taken,
212
+ * falling back to the input verbatim when nothing matches.
213
+ * 2. **No explicit ask**: an operator-pinned `fallback` that exists in the live
214
+ * catalogue wins first; then the ranked live catalogue (class ceiling
215
+ * applied); then the static {@link FALLBACK_MODEL_IDS} floor when the
216
+ * catalogue yields nothing in range.
217
+ */
218
+ export function resolveModel(
219
+ endpoints: readonly ServingEndpointSummary[],
220
+ input: ResolveModelInput = {},
221
+ ): ResolvedModelSelection {
222
+ if (input.explicit !== undefined) {
223
+ if (input.fuzzy === false) {
224
+ return { modelId: input.explicit, source: "explicit" };
225
+ }
226
+ const [top] = rankModels(endpoints, buildQuery(input, input.explicit));
227
+ return { modelId: top?.endpoint.name ?? input.explicit, source: "fuzzy-match" };
228
+ }
229
+
230
+ // Operator-pinned fallbacks win when present and live (e.g. a regulated
231
+ // workspace restricted to an approved subset).
232
+ if (input.modelClass === undefined && input.fallbacks && input.fallbacks.length > 0) {
233
+ const present = new Set(endpoints.map((e) => e.name));
234
+ const pinned = input.fallbacks.find((id) => present.has(id));
235
+ if (pinned) return { modelId: pinned, source: "fallback" };
236
+ }
237
+
238
+ const source = input.modelClass !== undefined ? "class" : "fallback";
239
+ const [top] = rankModels(endpoints, buildQuery(input, undefined));
240
+ if (top) return { modelId: top.endpoint.name, source };
241
+
242
+ // Live catalogue yielded nothing in range: walk the static floor.
243
+ const floor =
244
+ input.modelClass !== undefined
245
+ ? dedupe([...modelsForClass(input.modelClass), ...FALLBACK_MODEL_IDS])
246
+ : dedupe([...(input.fallbacks ?? []), ...FALLBACK_MODEL_IDS]);
247
+ return { modelId: pickFirstAvailable(floor, endpoints), source };
248
+ }
249
+
250
+ /** Build a {@link ModelQuery} from {@link ResolveModelInput} for the `limit: 1` delegation. */
251
+ function buildQuery(input: ResolveModelInput, search: string | undefined): ModelQuery {
252
+ return {
253
+ ...(search !== undefined ? { search } : {}),
254
+ ...(input.modelClass !== undefined ? { modelClass: input.modelClass } : {}),
255
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
256
+ limit: 1,
257
+ };
258
+ }
259
+
260
+ /** Drop duplicate ids while preserving first-seen order. */
261
+ function dedupe(ids: readonly string[]): string[] {
262
+ return [...new Set(ids)];
263
+ }
264
+
265
+ /**
266
+ * Find the first id in `candidates` whose endpoint is present in `endpoints`.
267
+ * Returns the top candidate when the workspace has none of them so callers
268
+ * always get a string; an offline workspace then receives a clean 404 from
269
+ * Databricks instead of a malformed config.
270
+ */
271
+ function pickFirstAvailable(
272
+ candidates: readonly string[],
273
+ endpoints: readonly ServingEndpointSummary[],
274
+ ): string {
275
+ const present = new Set(endpoints.map((e) => e.name));
276
+ for (const candidate of candidates) {
277
+ if (present.has(candidate)) return candidate;
278
+ }
279
+ return candidates[0] ?? FALLBACK_MODEL_IDS[0]!;
280
+ }
package/src/serving.ts ADDED
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Live Databricks Model Serving catalogue access.
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
8
+ * {@link ServingEndpointSummary} - including the Foundation Model API
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"` resolve to
13
+ * `databricks-claude-sonnet-4-6`.
14
+ *
15
+ * The class stamp and embedding dimension are computed once per cache load:
16
+ * every embedding endpoint is "pinged" in parallel and the resulting vector
17
+ * length recorded, so the cost is paid on a cache miss, not per read. The ping
18
+ * is best-effort - a failure logs at debug and leaves `dimension` unset rather
19
+ * than failing the whole listing.
20
+ */
21
+
22
+ import { error, log, string } from "@dbx-tools/shared-core";
23
+ import {
24
+ classify,
25
+ model,
26
+ type ModelProfile,
27
+ type ServingEndpointSummary,
28
+ } from "@dbx-tools/shared-model";
29
+ import { appkit } from "@dbx-tools/appkit";
30
+ import { CacheManager } from "@databricks/appkit";
31
+ import Fuse from "fuse.js";
32
+
33
+ import { MODEL_CLASS_ORDER } from "./classes";
34
+
35
+ const { ModelClass } = model;
36
+
37
+ const logger = log.logger("model/serving");
38
+
39
+ /**
40
+ * Structural type for the Databricks workspace client, re-exported so the rest
41
+ * of this package can keep importing it from here. See
42
+ * `appkit.WorkspaceClientLike` (node-appkit) for the canonical definition.
43
+ */
44
+ export type WorkspaceClientLike = appkit.WorkspaceClientLike;
45
+
46
+ /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
47
+ export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
48
+
49
+ /** Default Fuse.js score threshold below which a fuzzy match is accepted. */
50
+ export const DEFAULT_FUZZY_THRESHOLD = 0.4;
51
+
52
+ /** Cache key parts under which endpoint listings are stored. */
53
+ const CACHE_KEY_NAMESPACE = "serving-endpoints";
54
+
55
+ /**
56
+ * Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`. Endpoint
57
+ * visibility is effectively workspace-scoped (we cache by host in the key
58
+ * parts), so a single shared key lets every user of the same workspace share
59
+ * one cached fetch and coalesce on the in-flight promise. Permissions can
60
+ * differ in theory, but the Foundation Model API catalogue is the same view for
61
+ * every caller.
62
+ */
63
+ const SHARED_USER_KEY = "model-shared";
64
+
65
+ /** Options for {@link listServingEndpoints}. */
66
+ export interface ListServingEndpointsOptions {
67
+ /**
68
+ * Override the default cache TTL for this call, in milliseconds. Forwarded to
69
+ * `CacheManager` as seconds.
70
+ */
71
+ ttlMs?: number;
72
+ }
73
+
74
+ /**
75
+ * List Model Serving endpoints for the workspace owning `client`, routed
76
+ * through AppKit's `CacheManager`. The manager gives us everything
77
+ * `cachetools.TTLCache` provides plus what `cachetools-async` adds on top:
78
+ * per-entry TTL, in-flight request coalescing (concurrent callers share one
79
+ * fetch via the manager's internal `inFlightRequests` map), bounded size,
80
+ * telemetry spans (`cache.getOrExecute`), and optional Lakebase persistence so
81
+ * the catalogue survives restarts when the lakebase plugin is wired up.
82
+ *
83
+ * Returns plain {@link ServingEndpointSummary} objects (a stable subset of the
84
+ * SDK type) so cache hits never expose stale SDK internals. Errors from
85
+ * `CacheManager` or the SDK fetch propagate to the caller - we don't swallow
86
+ * them so users see the real auth / network issue.
87
+ *
88
+ * @param host - Workspace host used as the cache key. Pass the value resolved
89
+ * from `client.config.getHost()` so multi-host apps share one entry per
90
+ * workspace.
91
+ * @param options.ttlMs - Override the default TTL just for this call. Forwarded
92
+ * to `CacheManager` as seconds.
93
+ */
94
+ export async function listServingEndpoints(
95
+ client: WorkspaceClientLike,
96
+ host: string,
97
+ options: ListServingEndpointsOptions = {},
98
+ ): Promise<ServingEndpointSummary[]> {
99
+ const ttlSec = Math.max(1, Math.round((options.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000));
100
+ return CacheManager.getInstanceSync().getOrExecute(
101
+ [CACHE_KEY_NAMESPACE, host],
102
+ () => fetchEndpoints(client),
103
+ SHARED_USER_KEY,
104
+ { ttl: ttlSec },
105
+ );
106
+ }
107
+
108
+ /**
109
+ * List the workspace's serving endpoints as minimal
110
+ * {@link ServingEndpointSummary} objects straight from the SDK: no caching, and
111
+ * none of the cache-load enrichment ({@link listServingEndpoints} adds the
112
+ * {@link ModelClass} stamp and the embedding-dimension probe). Use this for a
113
+ * one-shot, dependency-light listing - e.g. a CLI that only needs names/tasks
114
+ * for fuzzy resolution and doesn't want AppKit's `CacheManager` or the
115
+ * per-embedding ping cost. Prefer {@link listServingEndpoints} for the cached,
116
+ * enriched view.
117
+ */
118
+ export async function listServingEndpointsUncached(
119
+ client: WorkspaceClientLike,
120
+ ): Promise<ServingEndpointSummary[]> {
121
+ const out: ServingEndpointSummary[] = [];
122
+ for await (const ep of client.servingEndpoints.list()) {
123
+ if (!ep.name) continue;
124
+ const profile = extractProfile(ep);
125
+ out.push({
126
+ name: ep.name,
127
+ ...(ep.task !== undefined ? { task: ep.task } : {}),
128
+ ...(ep.state?.ready !== undefined ? { state: String(ep.state.ready) } : {}),
129
+ ...(ep.description !== undefined ? { description: ep.description } : {}),
130
+ ...(profile ? { profile } : {}),
131
+ });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ async function fetchEndpoints(client: WorkspaceClientLike): Promise<ServingEndpointSummary[]> {
137
+ const startedAt = Date.now();
138
+ const out = await listServingEndpointsUncached(client);
139
+ stampModelClasses(out);
140
+ await measureEmbeddingDimensions(client, out);
141
+ logger.debug("listed", { count: out.length, elapsedMs: Date.now() - startedAt });
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Stamp each summary's {@link ServingEndpointSummary.class} from the relative
147
+ * classification of the whole set. Mutates `summaries` in place. Endpoints the
148
+ * classifier doesn't recognize (custom, unscored, non-LLM) are left without a
149
+ * class.
150
+ */
151
+ function stampModelClasses(summaries: ServingEndpointSummary[]): void {
152
+ const buckets = classify.classifyEndpoints(summaries);
153
+ const classOf = new Map<string, model.ModelClass>();
154
+ for (const cls of MODEL_CLASS_ORDER) {
155
+ for (const ep of buckets[cls]) classOf.set(ep.name, cls);
156
+ }
157
+ for (const summary of summaries) {
158
+ const cls = classOf.get(summary.name);
159
+ if (cls !== undefined) summary.class = cls;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Measure the embedding vector dimension of every {@link ModelClass.Embedding}
165
+ * endpoint by pinging it once, all in parallel. Mutates the matching summaries
166
+ * in place with the resulting `dimension`. Runs only on a cache miss (it's
167
+ * called from {@link fetchEndpoints}), so the probe cost is amortized across the
168
+ * cached TTL window. Per-endpoint failures are swallowed (logged at warn) so
169
+ * one unreachable embedding model never fails the listing.
170
+ */
171
+ async function measureEmbeddingDimensions(
172
+ client: WorkspaceClientLike,
173
+ summaries: ServingEndpointSummary[],
174
+ ): Promise<void> {
175
+ await Promise.all(
176
+ summaries
177
+ .filter((s) => s.class === ModelClass.Embedding)
178
+ .map(async (summary) => {
179
+ const dimension = await pingEmbeddingDimension(client, summary.name);
180
+ if (dimension !== undefined) summary.dimension = dimension;
181
+ }),
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Best-effort embedding dimension probe: query `name` with a tiny `"ping"`
187
+ * input and return the length of the returned vector. Returns `undefined` (and
188
+ * logs at warn) when the endpoint can't be queried or returns no vector - the
189
+ * dimension is informational, never required.
190
+ */
191
+ async function pingEmbeddingDimension(
192
+ client: WorkspaceClientLike,
193
+ name: string,
194
+ ): Promise<number | undefined> {
195
+ try {
196
+ const response = await client.servingEndpoints.query({ name, input: "ping" });
197
+ const dimension = response.data?.[0]?.embedding?.length;
198
+ if (typeof dimension === "number" && dimension > 0) return dimension;
199
+ logger.warn("embedding ping returned no vector", { name });
200
+ return undefined;
201
+ } catch (err) {
202
+ logger.warn("embedding ping failed", { name, error: error.errorMessage(err) });
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Pull the Foundation Model API `quality` / `speed` / `cost` scores off a
209
+ * serving-endpoint listing entry. Databricks returns these under
210
+ * `config.served_entities[].foundation_model.ai_gateway_model_profile`
211
+ * (snake_case at the wire level, preserved verbatim by the SDK), but the field
212
+ * is newer than the typed `FoundationModel` interface, so we read it through a
213
+ * structural cast. Returns `undefined` when no served entity carries a profile
214
+ * (custom models, embeddings, and brand-new endpoints that Databricks has not
215
+ * scored yet).
216
+ */
217
+ function extractProfile(ep: unknown): ModelProfile | undefined {
218
+ const entities = (
219
+ ep as {
220
+ config?: {
221
+ served_entities?: Array<{
222
+ foundation_model?: {
223
+ ai_gateway_model_profile?: {
224
+ quality?: number;
225
+ speed?: number;
226
+ cost?: number;
227
+ };
228
+ };
229
+ }>;
230
+ };
231
+ }
232
+ ).config?.served_entities;
233
+ if (!entities) return undefined;
234
+ for (const entity of entities) {
235
+ const raw = entity.foundation_model?.ai_gateway_model_profile;
236
+ if (!raw) continue;
237
+ const profile: ModelProfile = {};
238
+ if (Number.isFinite(raw.quality)) profile.quality = raw.quality;
239
+ if (Number.isFinite(raw.speed)) profile.speed = raw.speed;
240
+ if (Number.isFinite(raw.cost)) profile.cost = raw.cost;
241
+ if (Object.keys(profile).length > 0) return profile;
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ /**
247
+ * Force-evict cached endpoint listings via AppKit's `CacheManager`. With a
248
+ * `host` deletes that one workspace's entry; without one clears every cache
249
+ * entry on the manager (since `CacheManager` doesn't expose a namespace-scoped
250
+ * clear, this is the brute-force path - fine for tests, avoid in steady-state
251
+ * code).
252
+ */
253
+ export async function clearServingEndpointsCache(host?: string): Promise<void> {
254
+ const cache = CacheManager.getInstanceSync();
255
+ if (host) {
256
+ const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
257
+ await cache.delete(key);
258
+ } else {
259
+ await cache.clear();
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Result of fuzzy-resolving a user-supplied model name against the live
265
+ * endpoint list. `score` is Fuse.js's distance (`0` is exact, `1` is no match);
266
+ * `matched` is `false` when the score exceeds the configured threshold so
267
+ * callers can fall back to the original input (Databricks will then return a
268
+ * clean 404).
269
+ */
270
+ export interface ResolvedModel {
271
+ modelId: string;
272
+ matched: boolean;
273
+ score?: number;
274
+ }
275
+
276
+ /** Options accepted by {@link resolveModelId} / {@link searchServingEndpoints}. */
277
+ export interface ResolveModelOptions {
278
+ /** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
279
+ threshold?: number;
280
+ }
281
+
282
+ /** A serving endpoint paired with its fuzzy-match distance for a query. */
283
+ export interface ScoredEndpoint {
284
+ endpoint: ServingEndpointSummary;
285
+ /** Fuse.js distance: `0` is exact, `1` is no match. */
286
+ score: number;
287
+ }
288
+
289
+ /**
290
+ * Fuzzy-rank endpoints by how closely their `name` matches `input`, best
291
+ * (lowest score) first, keeping only those within `threshold`:
292
+ *
293
+ * 1. An exact name match short-circuits to a single `score: 0` result.
294
+ * 2. Otherwise the input is tokenized (dashes / underscores / spaces become
295
+ * separators) and fed through Fuse.js extended search, which AND-s each
296
+ * token with fuzzy matching enabled - the "tokenized fuzzy match" a caller
297
+ * reaches for when they type `"claude sonnet"` instead of the full endpoint
298
+ * name.
299
+ *
300
+ * Returns `[]` for an empty endpoint list or when `input` tokenizes to nothing,
301
+ * so callers fall back to the raw input and let Databricks surface a clean 404.
302
+ * This multi-result core is shared by {@link resolveModelId} (single best) and
303
+ * the ranked `rankModels` selector.
304
+ */
305
+ export function searchServingEndpoints(
306
+ input: string,
307
+ endpoints: readonly ServingEndpointSummary[],
308
+ options: ResolveModelOptions = {},
309
+ ): ScoredEndpoint[] {
310
+ if (endpoints.length === 0) return [];
311
+ for (const ep of endpoints) {
312
+ if (ep.name === input) return [{ endpoint: ep, score: 0 }];
313
+ }
314
+ const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
315
+ // Fuse 7.3 has no built-in tokenize hook; in extended search, space-separated
316
+ // tokens are AND-ed with fuzzy matching enabled. We lean on the shared
317
+ // tokenizer so the splitting rules stay consistent with the rest of the toolkit.
318
+ const query = Array.from(
319
+ string.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input),
320
+ ).join(" ");
321
+ if (!query) return [];
322
+ const fuse = new Fuse(endpoints, {
323
+ keys: ["name"],
324
+ threshold,
325
+ ignoreLocation: true,
326
+ includeScore: true,
327
+ useExtendedSearch: true,
328
+ isCaseSensitive: false,
329
+ });
330
+ return fuse
331
+ .search(query)
332
+ .filter((r) => (r.score ?? 0) <= threshold)
333
+ .map((r) => ({ endpoint: r.item, score: r.score ?? 0 }));
334
+ }
335
+
336
+ /**
337
+ * Snap a user-supplied model name to the single closest configured serving
338
+ * endpoint via {@link searchServingEndpoints}. Returns the input unchanged with
339
+ * `matched: false` when nothing scores within the threshold (or the catalogue
340
+ * is empty), so a deliberate model id is never silently rewritten to a
341
+ * similar-looking neighbour and the upstream call surfaces the canonical 404.
342
+ */
343
+ export function resolveModelId(
344
+ input: string,
345
+ endpoints: readonly ServingEndpointSummary[],
346
+ options: ResolveModelOptions = {},
347
+ ): ResolvedModel {
348
+ const [best] = searchServingEndpoints(input, endpoints, options);
349
+ if (best) {
350
+ return { modelId: best.endpoint.name, matched: true, score: best.score };
351
+ }
352
+ return { modelId: input, matched: false };
353
+ }
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import { model } from "@dbx-tools/shared-model";
5
+
6
+ import {
7
+ CHAT_CLASS_ORDER,
8
+ classesAtOrBelow,
9
+ isChatClass,
10
+ MODEL_CLASS_ORDER,
11
+ parseModelClass,
12
+ } from "../src/classes";
13
+
14
+ const { ModelClass } = model;
15
+
16
+ describe("CHAT_CLASS_ORDER / MODEL_CLASS_ORDER", () => {
17
+ it("orders chat bands most-capable first, embedding excluded from the ladder", () => {
18
+ assert.deepEqual(CHAT_CLASS_ORDER, [
19
+ ModelClass.ChatThinking,
20
+ ModelClass.ChatBalanced,
21
+ ModelClass.ChatFast,
22
+ ]);
23
+ assert.deepEqual(MODEL_CLASS_ORDER, [
24
+ ModelClass.ChatThinking,
25
+ ModelClass.ChatBalanced,
26
+ ModelClass.ChatFast,
27
+ ModelClass.Embedding,
28
+ ]);
29
+ });
30
+ });
31
+
32
+ describe("isChatClass", () => {
33
+ it("is true for chat bands and false for embedding", () => {
34
+ assert.equal(isChatClass(ModelClass.ChatThinking), true);
35
+ assert.equal(isChatClass(ModelClass.ChatFast), true);
36
+ assert.equal(isChatClass(ModelClass.Embedding), false);
37
+ });
38
+ });
39
+
40
+ describe("classesAtOrBelow", () => {
41
+ it("treats a chat band as a ceiling - the band and everything below it", () => {
42
+ assert.deepEqual(classesAtOrBelow(ModelClass.ChatThinking), [
43
+ ModelClass.ChatThinking,
44
+ ModelClass.ChatBalanced,
45
+ ModelClass.ChatFast,
46
+ ]);
47
+ assert.deepEqual(classesAtOrBelow(ModelClass.ChatBalanced), [
48
+ ModelClass.ChatBalanced,
49
+ ModelClass.ChatFast,
50
+ ]);
51
+ assert.deepEqual(classesAtOrBelow(ModelClass.ChatFast), [ModelClass.ChatFast]);
52
+ });
53
+
54
+ it("keeps embedding to itself - it is not a rung on the chat ladder", () => {
55
+ assert.deepEqual(classesAtOrBelow(ModelClass.Embedding), [ModelClass.Embedding]);
56
+ });
57
+ });
58
+
59
+ describe("parseModelClass", () => {
60
+ it("accepts full slugs and rejects junk", () => {
61
+ assert.equal(parseModelClass("chat-thinking"), ModelClass.ChatThinking);
62
+ assert.equal(parseModelClass("chat-balanced"), ModelClass.ChatBalanced);
63
+ assert.equal(parseModelClass("chat-fast"), ModelClass.ChatFast);
64
+ assert.equal(parseModelClass("embedding"), ModelClass.Embedding);
65
+ assert.equal(parseModelClass("medium"), null);
66
+ assert.equal(parseModelClass(undefined), null);
67
+ });
68
+
69
+ it("resolves a bare chat band via the chat- prefix shorthand", () => {
70
+ assert.equal(parseModelClass("thinking"), ModelClass.ChatThinking);
71
+ assert.equal(parseModelClass("balanced"), ModelClass.ChatBalanced);
72
+ assert.equal(parseModelClass("fast"), ModelClass.ChatFast);
73
+ });
74
+ });
@@ -0,0 +1,169 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import { model, type ServingEndpointSummary } from "@dbx-tools/shared-model";
5
+
6
+ import { FALLBACK_MODEL_IDS, modelsForClass } from "../src/fallback";
7
+ import { rankModels, resolveModel } from "../src/resolve";
8
+ import { resolveModelId, searchServingEndpoints } from "../src/serving";
9
+
10
+ const { ModelClass } = model;
11
+
12
+ const CHAT_TASK = "llm/v1/chat";
13
+ const EMBEDDING_TASK = "llm/v1/embeddings";
14
+
15
+ /**
16
+ * Unscored chat endpoints classify deterministically by family name (opus ->
17
+ * ChatThinking, sonnet -> ChatBalanced, haiku -> ChatFast), so class membership
18
+ * in these tests doesn't depend on quality quantiles.
19
+ */
20
+ function chat(name: string): ServingEndpointSummary {
21
+ return { name, task: CHAT_TASK };
22
+ }
23
+
24
+ /** An embedding endpoint - classified into ModelClass.Embedding by task. */
25
+ function embedding(name: string): ServingEndpointSummary {
26
+ return { name, task: EMBEDDING_TASK };
27
+ }
28
+
29
+ const OPUS_8 = "databricks-claude-opus-4-8";
30
+ const OPUS_7 = "databricks-claude-opus-4-7";
31
+ const OPUS_6 = "databricks-claude-opus-4-6";
32
+ const SONNET = "databricks-claude-sonnet-4-6";
33
+ const HAIKU_5 = "databricks-claude-haiku-4-5";
34
+ const HAIKU_3 = "databricks-claude-haiku-4-3";
35
+ const GTE = "databricks-gte-large-en";
36
+ const BGE = "databricks-bge-large-en";
37
+
38
+ /** opus (ChatThinking) / sonnet (ChatBalanced) / haiku (ChatFast) - one per band. */
39
+ const TIERED = [chat(OPUS_8), chat(SONNET), chat(HAIKU_5)];
40
+
41
+ function names(models: { endpoint: ServingEndpointSummary }[]): string[] {
42
+ return models.map((m) => m.endpoint.name);
43
+ }
44
+
45
+ describe("searchServingEndpoints / resolveModelId", () => {
46
+ const endpoints = [chat(OPUS_8), chat(SONNET), chat(HAIKU_5)];
47
+
48
+ it("short-circuits an exact name to score 0", () => {
49
+ const [best] = searchServingEndpoints(SONNET, endpoints);
50
+ assert.equal(best?.endpoint.name, SONNET);
51
+ assert.equal(best?.score, 0);
52
+ });
53
+
54
+ it("tokenized fuzzy-matches a loose name", () => {
55
+ const result = resolveModelId("claude sonnet", endpoints);
56
+ assert.equal(result.matched, true);
57
+ assert.equal(result.modelId, SONNET);
58
+ });
59
+
60
+ it("returns the input verbatim when nothing matches", () => {
61
+ const result = resolveModelId("zzz-no-such-model", endpoints);
62
+ assert.equal(result.matched, false);
63
+ assert.equal(result.modelId, "zzz-no-such-model");
64
+ });
65
+
66
+ it("returns [] for an empty catalogue", () => {
67
+ assert.deepEqual(searchServingEndpoints("opus", []), []);
68
+ });
69
+ });
70
+
71
+ describe("rankModels", () => {
72
+ it("ranks a search match-then-class, version breaking the tie", () => {
73
+ const ranked = rankModels([chat(OPUS_6), chat(OPUS_8), chat(OPUS_7)], {
74
+ search: "opus",
75
+ });
76
+ assert.deepEqual(names(ranked), [OPUS_8, OPUS_7, OPUS_6]);
77
+ });
78
+
79
+ it("with no search, orders by class then within-class rank", () => {
80
+ const ranked = rankModels(TIERED);
81
+ assert.deepEqual(names(ranked), [OPUS_8, SONNET, HAIKU_5]);
82
+ assert.deepEqual(
83
+ ranked.map((m) => m.modelClass),
84
+ [ModelClass.ChatThinking, ModelClass.ChatBalanced, ModelClass.ChatFast],
85
+ );
86
+ });
87
+
88
+ it("excludes embeddings from the default (chat-only) ranking", () => {
89
+ const ranked = rankModels([chat(OPUS_8), embedding(GTE), embedding(BGE)]);
90
+ assert.deepEqual(names(ranked), [OPUS_8]);
91
+ });
92
+
93
+ it("ranks embeddings only when ModelClass.Embedding is requested", () => {
94
+ const ranked = rankModels([chat(OPUS_8), embedding(GTE), embedding(BGE)], {
95
+ modelClass: ModelClass.Embedding,
96
+ });
97
+ assert.deepEqual(names(ranked), [GTE, BGE]); // no chat model leaks in
98
+ });
99
+
100
+ it("treats a chat class as a ceiling: that band and below, never above", () => {
101
+ const ranked = rankModels(TIERED, { modelClass: ModelClass.ChatBalanced });
102
+ assert.deepEqual(names(ranked), [SONNET, HAIKU_5]); // no opus (ChatThinking)
103
+ });
104
+
105
+ it("degrades to a lower band when the requested band is empty", () => {
106
+ // "medium" requested but only "small" exists -> the highest small is
107
+ // returned, never a "large".
108
+ const ranked = rankModels([chat(OPUS_8), chat(HAIKU_5), chat(HAIKU_3)], {
109
+ modelClass: ModelClass.ChatBalanced,
110
+ limit: 1,
111
+ });
112
+ assert.deepEqual(names(ranked), [HAIKU_5]);
113
+ });
114
+
115
+ it("scopes a search to the class ceiling", () => {
116
+ const ranked = rankModels(TIERED, {
117
+ search: "claude",
118
+ modelClass: ModelClass.ChatFast,
119
+ });
120
+ assert.deepEqual(names(ranked), [HAIKU_5]); // opus / sonnet excluded by ceiling
121
+ });
122
+
123
+ it("applies a limit", () => {
124
+ assert.equal(rankModels(TIERED, { limit: 2 }).length, 2);
125
+ });
126
+ });
127
+
128
+ describe("resolveModel", () => {
129
+ it("fuzzy-resolves an explicit name to the best ranked match (limit 1)", () => {
130
+ const result = resolveModel([chat(OPUS_6), chat(OPUS_8), chat(OPUS_7)], {
131
+ explicit: "opus",
132
+ });
133
+ assert.deepEqual(result, { modelId: OPUS_8, source: "fuzzy-match" });
134
+ });
135
+
136
+ it("returns an explicit name verbatim when fuzzy is off", () => {
137
+ const result = resolveModel(TIERED, { explicit: "my-pinned-model", fuzzy: false });
138
+ assert.deepEqual(result, { modelId: "my-pinned-model", source: "explicit" });
139
+ });
140
+
141
+ it("resolves a class ask to the top of that band and below", () => {
142
+ const result = resolveModel(TIERED, { modelClass: ModelClass.ChatBalanced });
143
+ assert.deepEqual(result, { modelId: SONNET, source: "class" });
144
+ });
145
+
146
+ it("never selects an embedding model for a general (chat) ask", () => {
147
+ const result = resolveModel([embedding(GTE), chat(SONNET)]);
148
+ assert.equal(result.modelId, SONNET);
149
+ });
150
+
151
+ it("lets an operator-pinned fallback present in the catalogue win", () => {
152
+ const pinned = "databricks-approved-custom";
153
+ const result = resolveModel([...TIERED, chat(pinned)], { fallbacks: [pinned] });
154
+ assert.deepEqual(result, { modelId: pinned, source: "fallback" });
155
+ });
156
+
157
+ it("falls back to the class's static floor for an empty catalogue", () => {
158
+ const result = resolveModel([], { modelClass: ModelClass.ChatBalanced });
159
+ assert.deepEqual(result, {
160
+ modelId: modelsForClass(ModelClass.ChatBalanced)[0]!,
161
+ source: "class",
162
+ });
163
+ });
164
+
165
+ it("falls back to the static floor with no intent and an empty catalogue", () => {
166
+ const result = resolveModel([], {});
167
+ assert.deepEqual(result, { modelId: FALLBACK_MODEL_IDS[0]!, source: "fallback" });
168
+ });
169
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,41 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
2
+ {
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib",
6
+ "alwaysStrict": true,
7
+ "declaration": true,
8
+ "esModuleInterop": true,
9
+ "experimentalDecorators": true,
10
+ "inlineSourceMap": true,
11
+ "inlineSources": true,
12
+ "lib": [
13
+ "ES2022"
14
+ ],
15
+ "module": "ESNext",
16
+ "noEmitOnError": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noImplicitAny": true,
19
+ "noImplicitReturns": true,
20
+ "noImplicitThis": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "resolveJsonModule": true,
24
+ "strict": true,
25
+ "strictNullChecks": true,
26
+ "strictPropertyInitialization": true,
27
+ "stripInternal": true,
28
+ "target": "ES2022",
29
+ "types": [
30
+ "node"
31
+ ],
32
+ "moduleResolution": "bundler",
33
+ "skipLibCheck": true
34
+ },
35
+ "include": [
36
+ "src/**/*.ts"
37
+ ],
38
+ "exclude": [
39
+ "node_modules"
40
+ ]
41
+ }