@dbx-tools/shared-model 0.1.2
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 +81 -0
- package/index.ts +8 -0
- package/package.json +38 -0
- package/src/classify.ts +264 -0
- package/src/model.ts +147 -0
- package/test/classify.test.ts +159 -0
- package/tsconfig.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @dbx-tools/shared-model
|
|
2
|
+
|
|
3
|
+
Browser-safe model-selection contract and classifier.
|
|
4
|
+
|
|
5
|
+
Import this package when UI code, route handlers, tools, or tests need to
|
|
6
|
+
validate model lookup requests, type ranked model responses, or classify serving
|
|
7
|
+
endpoints without talking to Databricks. Live workspace listing and fuzzy
|
|
8
|
+
resolution live in [`@dbx-tools/node-model`](../../node/model).
|
|
9
|
+
|
|
10
|
+
Key features:
|
|
11
|
+
|
|
12
|
+
- Shared `ModelClass` taxonomy for chat-thinking, chat-balanced, chat-fast, and
|
|
13
|
+
embedding workloads.
|
|
14
|
+
- Browser-safe zod schemas for lookup requests, endpoint summaries, ranked
|
|
15
|
+
results, and profile metadata.
|
|
16
|
+
- Endpoint classifier that groups serving endpoints by score profile and family
|
|
17
|
+
naming conventions.
|
|
18
|
+
- Version/family parsing helpers for model catalogues and tests.
|
|
19
|
+
- Types that match the server selection API without depending on the Databricks
|
|
20
|
+
SDK.
|
|
21
|
+
|
|
22
|
+
## Validate A Model Lookup Request
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { model } from "@dbx-tools/shared-model";
|
|
26
|
+
|
|
27
|
+
const query = model.ModelQuerySchema.parse({
|
|
28
|
+
search: "claude sonnet",
|
|
29
|
+
modelClass: "chat-balanced",
|
|
30
|
+
limit: 5,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Use `model.ModelQuerySchema` for route query/body validation and agent tool
|
|
35
|
+
inputs. It keeps client model pickers and backend resolution endpoints on the
|
|
36
|
+
same request shape.
|
|
37
|
+
|
|
38
|
+
## Type Ranked Results
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { model, type RankedModel } from "@dbx-tools/shared-model";
|
|
42
|
+
|
|
43
|
+
const ranked: RankedModel = model.RankedModelSchema.parse(response);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`model.ServingEndpointSummarySchema` describes the stable endpoint fields exposed
|
|
47
|
+
to clients: endpoint name, task, state, optional profile scores, classified
|
|
48
|
+
class, and embedding dimension.
|
|
49
|
+
|
|
50
|
+
## Classify Endpoint Catalogues
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { classify, model } from "@dbx-tools/shared-model";
|
|
54
|
+
|
|
55
|
+
const byClass = classify.classifyEndpoints(endpoints);
|
|
56
|
+
const fast = byClass[model.ModelClass.ChatFast];
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The classifier uses Foundation Model API quality/speed/cost scores when present
|
|
60
|
+
and family-name heuristics when scores are missing. This is useful for client
|
|
61
|
+
grouping, tests, and offline catalogue analysis.
|
|
62
|
+
|
|
63
|
+
## Parse Model Families
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const family = classify.classifyByFamily("databricks-claude-sonnet-4-6");
|
|
67
|
+
const version = classify.versionTuple("llama-3-1-70b");
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Family parsing helps callers bucket custom lists or explain why an endpoint
|
|
71
|
+
landed in a class before the live workspace scores are available.
|
|
72
|
+
|
|
73
|
+
## Modules
|
|
74
|
+
|
|
75
|
+
- `model` - `ModelClass`, zod schemas, and inferred types for profiles,
|
|
76
|
+
endpoint summaries, lookup requests, and ranked results.
|
|
77
|
+
- `classify` - family parsing, version tuple parsing, and endpoint
|
|
78
|
+
classification.
|
|
79
|
+
|
|
80
|
+
Server-side selection, cache, and fuzzy endpoint matching are in
|
|
81
|
+
[`@dbx-tools/node-model`](../../node/model).
|
package/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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 classify from "./src/classify";
|
|
6
|
+
export * as model from "./src/model";
|
|
7
|
+
export type { FamilyClass } from "./src/classify";
|
|
8
|
+
export type { ModelProfile, ServingEndpointSummary, ModelQuery, RankedModel } from "./src/model";
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbx-tools/shared-model",
|
|
3
|
+
"devDependencies": {
|
|
4
|
+
"@types/node": "^24.6.0",
|
|
5
|
+
"tsx": "^4.23.0",
|
|
6
|
+
"typescript": "^5.9.3"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"zod": "^4.3.6",
|
|
10
|
+
"@dbx-tools/shared-core": "0.1.2"
|
|
11
|
+
},
|
|
12
|
+
"main": "index.ts",
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
|
+
"version": "0.1.2",
|
|
15
|
+
"types": "index.ts",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.ts",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"dbxToolsConfig": {
|
|
22
|
+
"tags": [
|
|
23
|
+
"shared"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "projen build",
|
|
29
|
+
"compile": "projen compile",
|
|
30
|
+
"default": "projen default",
|
|
31
|
+
"package": "projen package",
|
|
32
|
+
"post-compile": "projen post-compile",
|
|
33
|
+
"pre-compile": "projen pre-compile",
|
|
34
|
+
"test": "projen test",
|
|
35
|
+
"watch": "projen watch",
|
|
36
|
+
"projen": "projen"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/classify.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-class classification for Databricks Model Serving endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Chat capability bands are derived from the live workspace catalogue
|
|
5
|
+
* rather than a hand-maintained table. Databricks publishes
|
|
6
|
+
* per-endpoint `quality` / `speed` / `cost` scores (the AI Playground
|
|
7
|
+
* bars) on the serving list; {@link classifyEndpoints} buckets scored
|
|
8
|
+
* chat models into the chat {@link ModelClass} bands by the *relative*
|
|
9
|
+
* distribution of those scores (quantiles, not fixed cut-offs) so a
|
|
10
|
+
* brand-new model that lands outside today's score range still slots in
|
|
11
|
+
* next to its peers. Embedding endpoints (`task === "llm/v1/embeddings"`)
|
|
12
|
+
* are bucketed into {@link ModelClass.Embedding} by task, independent of
|
|
13
|
+
* any score.
|
|
14
|
+
*
|
|
15
|
+
* Unscored-but-recognizable chat endpoints are still placed by a small
|
|
16
|
+
* family heuristic ({@link classifyByFamily}) so a workspace whose
|
|
17
|
+
* models predate Foundation Model API scoring keeps working. The
|
|
18
|
+
* offline fallback floor - the hard-coded model list reached for when
|
|
19
|
+
* the live catalogue can't be read at all - is a server concern and
|
|
20
|
+
* lives in `@dbx-tools/model`, not here: a browser client never talks
|
|
21
|
+
* to Databricks directly, so it has nothing to fall back to.
|
|
22
|
+
*
|
|
23
|
+
* Pure (no Node-only imports), so a client can classify a `/models`
|
|
24
|
+
* response without server dependencies.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ModelClass, type ServingEndpointSummary } from "./model";
|
|
28
|
+
|
|
29
|
+
/** Task hint Databricks stamps on chat completion endpoints. */
|
|
30
|
+
const CHAT_TASK = "llm/v1/chat";
|
|
31
|
+
|
|
32
|
+
/** Task hint Databricks stamps on embedding endpoints. */
|
|
33
|
+
const EMBEDDING_TASK = "llm/v1/embeddings";
|
|
34
|
+
|
|
35
|
+
/** Family-heuristic classification of a single endpoint name. */
|
|
36
|
+
export interface FamilyClass {
|
|
37
|
+
/** Chat capability band the family maps to (never embedding). */
|
|
38
|
+
class: ModelClass;
|
|
39
|
+
/** Intra-family ordering hint (higher is newer / more capable). */
|
|
40
|
+
rank: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Numeric `[major, minor, patch]` version parsed from an endpoint
|
|
45
|
+
* name, used to order siblings within a family/tier. Starts at the
|
|
46
|
+
* first digit in the name, then reads successive separator-delimited,
|
|
47
|
+
* digit-prefixed chunks as the three components (missing ones default
|
|
48
|
+
* to `0`):
|
|
49
|
+
*
|
|
50
|
+
* - `databricks-claude-opus-4-8` -> `[4, 8, 0]`
|
|
51
|
+
* - `databricks-claude-opus-4-10` -> `[4, 10, 0]` (sorts above 4-8)
|
|
52
|
+
* - `databricks-meta-llama-3-3-70b`-> `[3, 3, 70]`
|
|
53
|
+
* - `databricks-bge-large-en` -> `[0, 0, 0]` (no digits)
|
|
54
|
+
*
|
|
55
|
+
* Component-wise comparison (not a decimal collapse) so `4.10` beats
|
|
56
|
+
* `4.8` - the bug a `major + minor/10` score would hit.
|
|
57
|
+
*/
|
|
58
|
+
export function versionTuple(name: string): [number, number, number] {
|
|
59
|
+
const start = name.search(/\d/);
|
|
60
|
+
if (start < 0) return [0, 0, 0];
|
|
61
|
+
const nums = name
|
|
62
|
+
.slice(start)
|
|
63
|
+
.split(/[^a-z0-9]+/i)
|
|
64
|
+
.map((chunk) => chunk.match(/^\d+/)?.[0])
|
|
65
|
+
.filter((digits): digits is string => digits !== undefined)
|
|
66
|
+
.map(Number);
|
|
67
|
+
return [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Compare two version tuples so the higher version sorts first (descending). */
|
|
71
|
+
function compareVersionDesc(a: readonly number[], b: readonly number[]): number {
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
const diff = (b[i] ?? 0) - (a[i] ?? 0);
|
|
74
|
+
if (diff !== 0) return diff;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Monotonic version key for ordering siblings within a family/tier
|
|
81
|
+
* (e.g. `opus-4-8` over `opus-4-7`). Encodes the {@link versionTuple}
|
|
82
|
+
* as a single number so callers that need a scalar rank (the family
|
|
83
|
+
* heuristic, the static fallback ordering) keep working; the encoding
|
|
84
|
+
* preserves component order without the decimal-collapse bug.
|
|
85
|
+
*/
|
|
86
|
+
function versionScore(name: string): number {
|
|
87
|
+
const [major, minor, patch] = versionTuple(name);
|
|
88
|
+
return major * 1_000_000 + minor * 1_000 + patch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Best-effort chat capability band for an endpoint we have no live
|
|
93
|
+
* score for, keyed off provider family and the well-known variant words
|
|
94
|
+
* in the name (`opus`/`sonnet`/`haiku`, `pro`/`mini`/`nano`,
|
|
95
|
+
* `flash`/`flash-lite`, Llama parameter sizes, etc). Returns `null` for
|
|
96
|
+
* names we don't recognize so unknown custom endpoints are never
|
|
97
|
+
* auto-selected as a default. The accompanying `rank` orders siblings
|
|
98
|
+
* within a class. Only ever returns a chat band - embedding endpoints
|
|
99
|
+
* are classified by task, not name.
|
|
100
|
+
*/
|
|
101
|
+
export function classifyByFamily(name: string): FamilyClass | null {
|
|
102
|
+
const n = name.toLowerCase();
|
|
103
|
+
const at = (cls: ModelClass): FamilyClass => ({ class: cls, rank: versionScore(n) });
|
|
104
|
+
|
|
105
|
+
// Anthropic Claude
|
|
106
|
+
if (n.includes("opus")) return at(ModelClass.ChatThinking);
|
|
107
|
+
if (n.includes("sonnet")) return at(ModelClass.ChatBalanced);
|
|
108
|
+
if (n.includes("haiku")) return at(ModelClass.ChatFast);
|
|
109
|
+
|
|
110
|
+
// OpenAI open-weights (check before the generic gpt branch)
|
|
111
|
+
if (n.includes("gpt-oss")) {
|
|
112
|
+
return at(n.includes("120b") ? ModelClass.ChatBalanced : ModelClass.ChatFast);
|
|
113
|
+
}
|
|
114
|
+
// OpenAI GPT family
|
|
115
|
+
if (n.includes("gpt")) {
|
|
116
|
+
if (n.includes("pro")) return at(ModelClass.ChatThinking);
|
|
117
|
+
if (n.includes("mini") || n.includes("nano")) return at(ModelClass.ChatFast);
|
|
118
|
+
return at(ModelClass.ChatBalanced);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Google Gemini / Gemma
|
|
122
|
+
if (n.includes("gemini")) {
|
|
123
|
+
if (n.includes("flash-lite")) return at(ModelClass.ChatFast);
|
|
124
|
+
if (n.includes("pro")) return at(ModelClass.ChatThinking);
|
|
125
|
+
return at(ModelClass.ChatBalanced);
|
|
126
|
+
}
|
|
127
|
+
if (n.includes("gemma")) return at(ModelClass.ChatFast);
|
|
128
|
+
|
|
129
|
+
// Meta Llama
|
|
130
|
+
if (n.includes("llama")) {
|
|
131
|
+
if (n.includes("maverick") || n.includes("405b")) return at(ModelClass.ChatThinking);
|
|
132
|
+
if (n.includes("70b")) return at(ModelClass.ChatBalanced);
|
|
133
|
+
if (n.includes("8b") || n.includes("1b")) return at(ModelClass.ChatFast);
|
|
134
|
+
return at(ModelClass.ChatBalanced);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Alibaba Qwen
|
|
138
|
+
if (n.includes("qwen")) return at(ModelClass.ChatBalanced);
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Linear-interpolated quantile of an ascending-sorted numeric array. */
|
|
144
|
+
function quantile(sortedAsc: readonly number[], p: number): number {
|
|
145
|
+
if (sortedAsc.length === 0) return Number.NaN;
|
|
146
|
+
const idx = (sortedAsc.length - 1) * p;
|
|
147
|
+
const lo = Math.floor(idx);
|
|
148
|
+
const hi = Math.ceil(idx);
|
|
149
|
+
if (lo === hi) return sortedAsc[lo]!;
|
|
150
|
+
return sortedAsc[lo]! + (sortedAsc[hi]! - sortedAsc[lo]!) * (idx - lo);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Internal sortable wrapper carrying the ordering keys for a bucket entry. */
|
|
154
|
+
interface Ranked {
|
|
155
|
+
ep: ServingEndpointSummary;
|
|
156
|
+
/** Quality (scored) or family version (unscored) - higher first. */
|
|
157
|
+
sort: number;
|
|
158
|
+
/** Scored endpoints rank ahead of family-only guesses. */
|
|
159
|
+
scored: boolean;
|
|
160
|
+
tieCost: number;
|
|
161
|
+
tieSpeed: number;
|
|
162
|
+
/** Parsed name version, the final tie-breaker (newer sibling first). */
|
|
163
|
+
version: readonly number[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function rankOrder(a: Ranked, b: Ranked): number {
|
|
167
|
+
if (a.scored !== b.scored) return a.scored ? -1 : 1;
|
|
168
|
+
if (b.sort !== a.sort) return b.sort - a.sort;
|
|
169
|
+
if (a.tieCost !== b.tieCost) return a.tieCost - b.tieCost;
|
|
170
|
+
if (b.tieSpeed !== a.tieSpeed) return b.tieSpeed - a.tieSpeed;
|
|
171
|
+
// Same scored-ness, quality, cost, and speed (e.g. several Claude
|
|
172
|
+
// Opus point releases): prefer the newer parsed version so
|
|
173
|
+
// `opus-4-8` beats `opus-4-7`.
|
|
174
|
+
return compareVersionDesc(a.version, b.version);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Bucket live endpoints into {@link ModelClass}es, ranked best-first
|
|
179
|
+
* within each chat band.
|
|
180
|
+
*
|
|
181
|
+
* Embedding endpoints (`task === "llm/v1/embeddings"`) go into
|
|
182
|
+
* {@link ModelClass.Embedding} by task, in listing order (they carry no
|
|
183
|
+
* capability score to rank on).
|
|
184
|
+
*
|
|
185
|
+
* Chat endpoints (`task === "llm/v1/chat"`) split into the three chat
|
|
186
|
+
* bands. Scored endpoints (those carrying a `profile.quality`) drive
|
|
187
|
+
* the banding: the observed quality distribution is split at its 1/3
|
|
188
|
+
* and 2/3 quantiles, so the top third is {@link ModelClass.ChatThinking},
|
|
189
|
+
* the bottom third {@link ModelClass.ChatFast}, and the middle
|
|
190
|
+
* {@link ModelClass.ChatBalanced}. Because the thresholds come from the
|
|
191
|
+
* data, the split adapts as Databricks adds or rescores models -
|
|
192
|
+
* nothing is pinned to a fixed score band.
|
|
193
|
+
*
|
|
194
|
+
* Unscored chat endpoints are placed by {@link classifyByFamily} and
|
|
195
|
+
* ranked after the scored ones in their band; unrecognized, unscored
|
|
196
|
+
* endpoints (e.g. custom external models) are omitted entirely so they
|
|
197
|
+
* are never picked as an automatic default.
|
|
198
|
+
*
|
|
199
|
+
* Within a chat band, scored models sort by `quality` desc, then `cost`
|
|
200
|
+
* asc, then `speed` desc, then parsed name version desc; family-only
|
|
201
|
+
* models sort by version rank then parsed version. The version
|
|
202
|
+
* tie-break ({@link versionTuple}) is what separates point releases
|
|
203
|
+
* that share a score profile (e.g. `opus-4-8` ahead of `opus-4-7`).
|
|
204
|
+
*/
|
|
205
|
+
export function classifyEndpoints(
|
|
206
|
+
endpoints: readonly ServingEndpointSummary[],
|
|
207
|
+
): Record<ModelClass, ServingEndpointSummary[]> {
|
|
208
|
+
const chat = endpoints.filter((e) => e.task === CHAT_TASK);
|
|
209
|
+
const qualities = chat
|
|
210
|
+
.map((e) => e.profile?.quality)
|
|
211
|
+
.filter((q): q is number => Number.isFinite(q))
|
|
212
|
+
.sort((a, b) => a - b);
|
|
213
|
+
const low = quantile(qualities, 1 / 3);
|
|
214
|
+
const high = quantile(qualities, 2 / 3);
|
|
215
|
+
|
|
216
|
+
const buckets: Record<ModelClass, Ranked[]> = {
|
|
217
|
+
[ModelClass.ChatThinking]: [],
|
|
218
|
+
[ModelClass.ChatBalanced]: [],
|
|
219
|
+
[ModelClass.ChatFast]: [],
|
|
220
|
+
[ModelClass.Embedding]: [],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
for (const ep of chat) {
|
|
224
|
+
const q = ep.profile?.quality;
|
|
225
|
+
if (Number.isFinite(q)) {
|
|
226
|
+
const quality = q as number;
|
|
227
|
+
const cls =
|
|
228
|
+
quality >= high
|
|
229
|
+
? ModelClass.ChatThinking
|
|
230
|
+
: quality <= low
|
|
231
|
+
? ModelClass.ChatFast
|
|
232
|
+
: ModelClass.ChatBalanced;
|
|
233
|
+
buckets[cls].push({
|
|
234
|
+
ep,
|
|
235
|
+
sort: quality,
|
|
236
|
+
scored: true,
|
|
237
|
+
tieCost: ep.profile?.cost ?? Number.POSITIVE_INFINITY,
|
|
238
|
+
tieSpeed: ep.profile?.speed ?? 0,
|
|
239
|
+
version: versionTuple(ep.name),
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const family = classifyByFamily(ep.name);
|
|
244
|
+
if (!family) continue;
|
|
245
|
+
buckets[family.class].push({
|
|
246
|
+
ep,
|
|
247
|
+
sort: family.rank,
|
|
248
|
+
scored: false,
|
|
249
|
+
tieCost: Number.POSITIVE_INFINITY,
|
|
250
|
+
tieSpeed: 0,
|
|
251
|
+
version: versionTuple(ep.name),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Embeddings are bucketed by task in listing order - no score to rank.
|
|
256
|
+
const embeddings = endpoints.filter((e) => e.task === EMBEDDING_TASK);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
[ModelClass.ChatThinking]: buckets[ModelClass.ChatThinking].sort(rankOrder).map((x) => x.ep),
|
|
260
|
+
[ModelClass.ChatBalanced]: buckets[ModelClass.ChatBalanced].sort(rankOrder).map((x) => x.ep),
|
|
261
|
+
[ModelClass.ChatFast]: buckets[ModelClass.ChatFast].sort(rankOrder).map((x) => x.ep),
|
|
262
|
+
[ModelClass.Embedding]: embeddings,
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-format types for the model toolkit: the model class taxonomy,
|
|
3
|
+
* the Foundation Model API score profile, the minimal serving-endpoint
|
|
4
|
+
* descriptor, and the model-lookup request / ranked-result contract.
|
|
5
|
+
* Pure types and zod schemas - no Node-only imports - so a browser
|
|
6
|
+
* client (or an agent tool's `inputSchema`) can validate a lookup
|
|
7
|
+
* request and type a ranked response without dragging in server
|
|
8
|
+
* dependencies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Class of a Databricks Model Serving endpoint - its modality plus,
|
|
15
|
+
* for chat models, a capability band. Chat endpoints split by measured
|
|
16
|
+
* `quality` into a three-rung ladder; embeddings are a separate
|
|
17
|
+
* modality, not a rung on that ladder.
|
|
18
|
+
*
|
|
19
|
+
* String enum so the value is the slug used in cache keys, logs, query
|
|
20
|
+
* strings, and serialized configs - and is what a client sends to
|
|
21
|
+
* request a class.
|
|
22
|
+
*/
|
|
23
|
+
export enum ModelClass {
|
|
24
|
+
ChatThinking = "chat-thinking",
|
|
25
|
+
ChatBalanced = "chat-balanced",
|
|
26
|
+
ChatFast = "chat-fast",
|
|
27
|
+
Embedding = "embedding",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ModelClassDescriptions: Record<ModelClass, string> = {
|
|
31
|
+
[ModelClass.ChatThinking]:
|
|
32
|
+
"Most capable reasoning for hard, multi-step tasks; slowest and most expensive.",
|
|
33
|
+
[ModelClass.ChatBalanced]:
|
|
34
|
+
"Good default for most chat and agent work; balances quality, speed, and cost.",
|
|
35
|
+
[ModelClass.ChatFast]:
|
|
36
|
+
"Fastest and cheapest; best for simple tasks like classification, routing, and short summaries.",
|
|
37
|
+
[ModelClass.Embedding]:
|
|
38
|
+
"Produces text embeddings (vectors), not chat replies; not interchangeable with chat models.",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const ModelClassSchema = z.enum(ModelClass).describe(
|
|
42
|
+
`Endpoint class slug: ${(Object.values(ModelClass) as ModelClass[])
|
|
43
|
+
.map((modelClass) => {
|
|
44
|
+
return `'${modelClass}' (${ModelClassDescriptions[modelClass]})`;
|
|
45
|
+
})
|
|
46
|
+
.join(", ")}.`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Foundation Model API quality / speed / cost scores Databricks
|
|
51
|
+
* publishes per pay-per-token endpoint (the bars shown in the AI
|
|
52
|
+
* Playground). Surfaced from the serving list under
|
|
53
|
+
* `config.served_entities[].foundation_model.ai_gateway_model_profile`
|
|
54
|
+
* so the class classifier can rank models from live, workspace-local
|
|
55
|
+
* numbers instead of a hand-maintained table. Scales are relative and
|
|
56
|
+
* unitless; all axes are absent for custom or brand-new endpoints that
|
|
57
|
+
* have no profile yet.
|
|
58
|
+
*/
|
|
59
|
+
export const ModelProfileSchema = z.object({
|
|
60
|
+
quality: z.number().optional().describe("Relative quality score; higher is better."),
|
|
61
|
+
speed: z.number().optional().describe("Relative throughput score; higher is faster."),
|
|
62
|
+
cost: z.number().optional().describe("Relative price score; lower is cheaper."),
|
|
63
|
+
});
|
|
64
|
+
export type ModelProfile = z.infer<typeof ModelProfileSchema>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Minimal descriptor for a Databricks Model Serving endpoint - a
|
|
68
|
+
* stable subset of the SDK type so cache hits and `/models` responses
|
|
69
|
+
* never expose SDK internals.
|
|
70
|
+
*/
|
|
71
|
+
export const ServingEndpointSummarySchema = z.object({
|
|
72
|
+
name: z
|
|
73
|
+
.string()
|
|
74
|
+
.describe(
|
|
75
|
+
"Endpoint name as listed by the Model Serving REST API; the id used to invoke the model.",
|
|
76
|
+
),
|
|
77
|
+
task: z.string().optional().describe("Task hint, e.g. 'llm/v1/chat' or 'llm/v1/embeddings'."),
|
|
78
|
+
state: z.string().optional().describe("Deployment state, e.g. ready, updating, or failed."),
|
|
79
|
+
description: z.string().optional().describe("Free-form endpoint description; informational."),
|
|
80
|
+
profile: ModelProfileSchema.optional().describe(
|
|
81
|
+
"Foundation Model API quality/speed/cost scores when the endpoint is a scored pay-per-token model; absent otherwise.",
|
|
82
|
+
),
|
|
83
|
+
class: ModelClassSchema.optional().describe(
|
|
84
|
+
"Class the endpoint was classified into; absent when the classifier doesn't recognize it.",
|
|
85
|
+
),
|
|
86
|
+
dimension: z
|
|
87
|
+
.number()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe(
|
|
90
|
+
"Embedding vector length, measured by a best-effort server-side ping. Only set for embedding endpoints, and only when the probe succeeded.",
|
|
91
|
+
),
|
|
92
|
+
});
|
|
93
|
+
export type ServingEndpointSummary = z.infer<typeof ServingEndpointSummarySchema>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A model-lookup request: search string, capability ceiling, both, or
|
|
97
|
+
* nothing. This is the caller-facing query the `@dbx-tools/model`
|
|
98
|
+
* ranker reads - shaped as a zod schema (with per-field descriptions)
|
|
99
|
+
* so an agent tool can adopt it directly as its `inputSchema` and a
|
|
100
|
+
* browser client can validate args before calling the model service.
|
|
101
|
+
*
|
|
102
|
+
* Every field is optional: an empty query ranks the live catalogue by
|
|
103
|
+
* class alone. The "this class and below" ceiling semantics and the
|
|
104
|
+
* match-then-class ordering live in the service, not here - this
|
|
105
|
+
* surface is declarative.
|
|
106
|
+
*/
|
|
107
|
+
export const ModelQuerySchema = z.object({
|
|
108
|
+
search: z
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe(
|
|
112
|
+
"Loose name fuzzy-matched against endpoint names, e.g. 'claude sonnet'. Omit to rank by class alone.",
|
|
113
|
+
),
|
|
114
|
+
modelClass: ModelClassSchema.optional().describe(
|
|
115
|
+
"Capability ceiling: a chat class restricts to that band and the less-capable chat bands below it; 'embedding' restricts to embedding endpoints. Omit to consider every chat band.",
|
|
116
|
+
),
|
|
117
|
+
limit: z
|
|
118
|
+
.number()
|
|
119
|
+
.int()
|
|
120
|
+
.positive()
|
|
121
|
+
.max(50)
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Maximum number of ranked results to return."),
|
|
124
|
+
threshold: z
|
|
125
|
+
.number()
|
|
126
|
+
.min(0)
|
|
127
|
+
.max(1)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe("Fuzzy-match distance cutoff (0 = exact, 1 = match anything). Default 0.4."),
|
|
130
|
+
});
|
|
131
|
+
export type ModelQuery = z.infer<typeof ModelQuerySchema>;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One ranked lookup result. Backs an agent tool's `outputSchema` and a
|
|
135
|
+
* `/models` ranked response.
|
|
136
|
+
*/
|
|
137
|
+
export const RankedModelSchema = z.object({
|
|
138
|
+
endpoint: ServingEndpointSummarySchema.describe("The matched serving endpoint."),
|
|
139
|
+
modelClass: ModelClassSchema.describe("Class the endpoint was classified into."),
|
|
140
|
+
score: z
|
|
141
|
+
.number()
|
|
142
|
+
.optional()
|
|
143
|
+
.describe(
|
|
144
|
+
"Fuzzy-match distance (0 = exact .. 1 = worst) when the query carried a search; absent for class-only ranking.",
|
|
145
|
+
),
|
|
146
|
+
});
|
|
147
|
+
export type RankedModel = z.infer<typeof RankedModelSchema>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { classifyByFamily, classifyEndpoints, versionTuple } from "../src/classify";
|
|
4
|
+
import { ModelClass, type ModelProfile, type ServingEndpointSummary } from "../src/model";
|
|
5
|
+
|
|
6
|
+
const CHAT_TASK = "llm/v1/chat";
|
|
7
|
+
const EMBEDDING_TASK = "llm/v1/embeddings";
|
|
8
|
+
|
|
9
|
+
/** Build a chat serving endpoint, optionally scored, for classifier fixtures. */
|
|
10
|
+
function chat(name: string, profile?: ModelProfile): ServingEndpointSummary {
|
|
11
|
+
return { name, task: CHAT_TASK, ...(profile ? { profile } : {}) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Build an embedding serving endpoint for classifier fixtures. */
|
|
15
|
+
function embedding(name: string): ServingEndpointSummary {
|
|
16
|
+
return { name, task: EMBEDDING_TASK };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Names of the endpoints classified into `cls`, in ranked order. */
|
|
20
|
+
function classNames(
|
|
21
|
+
buckets: Record<ModelClass, ServingEndpointSummary[]>,
|
|
22
|
+
cls: ModelClass,
|
|
23
|
+
): string[] {
|
|
24
|
+
return buckets[cls].map((e) => e.name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("versionTuple", () => {
|
|
28
|
+
it("reads major.minor.patch starting at the first digit", () => {
|
|
29
|
+
assert.deepEqual(versionTuple("databricks-claude-opus-4-8"), [4, 8, 0]);
|
|
30
|
+
assert.deepEqual(versionTuple("databricks-claude-opus-4-7"), [4, 7, 0]);
|
|
31
|
+
assert.deepEqual(versionTuple("databricks-gpt-5-5"), [5, 5, 0]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps double-digit components numeric (no decimal collapse)", () => {
|
|
35
|
+
assert.deepEqual(versionTuple("databricks-claude-opus-4-10"), [4, 10, 0]);
|
|
36
|
+
// 4.10 must sort ABOVE 4.8 - the bug a `major + minor/10` score hit.
|
|
37
|
+
const [, tenMinor] = versionTuple("databricks-claude-opus-4-10");
|
|
38
|
+
const [, eightMinor] = versionTuple("databricks-claude-opus-4-8");
|
|
39
|
+
assert.ok(tenMinor > eightMinor);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("reads a parameter-size suffix as the patch component", () => {
|
|
43
|
+
assert.deepEqual(versionTuple("databricks-meta-llama-3-3-70b-instruct"), [3, 3, 70]);
|
|
44
|
+
assert.deepEqual(versionTuple("databricks-gpt-oss-120b"), [120, 0, 0]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns all-zero for names with no digits", () => {
|
|
48
|
+
assert.deepEqual(versionTuple("databricks-bge-large-en"), [0, 0, 0]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("classifyEndpoints", () => {
|
|
53
|
+
it("buckets scored models by quality quantiles, best-first", () => {
|
|
54
|
+
// Three clear quality bands (gaps keep the 1/3 and 2/3 quantiles
|
|
55
|
+
// between bands), two models per chat band.
|
|
56
|
+
const buckets = classifyEndpoints([
|
|
57
|
+
chat("databricks-claude-opus-4-8", { quality: 90, cost: 10, speed: 44 }),
|
|
58
|
+
chat("databricks-claude-opus-4-7", { quality: 88, cost: 10, speed: 44 }),
|
|
59
|
+
chat("databricks-claude-sonnet-4-6", { quality: 50, cost: 18, speed: 55 }),
|
|
60
|
+
chat("databricks-gpt-5-5", { quality: 48, cost: 20, speed: 50 }),
|
|
61
|
+
chat("databricks-claude-haiku-4-5", { quality: 10, cost: 5, speed: 80 }),
|
|
62
|
+
chat("databricks-meta-llama-3-1-8b-instruct", { quality: 8, cost: 3, speed: 90 }),
|
|
63
|
+
]);
|
|
64
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatThinking), [
|
|
65
|
+
"databricks-claude-opus-4-8",
|
|
66
|
+
"databricks-claude-opus-4-7",
|
|
67
|
+
]);
|
|
68
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatBalanced), [
|
|
69
|
+
"databricks-claude-sonnet-4-6",
|
|
70
|
+
"databricks-gpt-5-5",
|
|
71
|
+
]);
|
|
72
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatFast), [
|
|
73
|
+
"databricks-claude-haiku-4-5",
|
|
74
|
+
"databricks-meta-llama-3-1-8b-instruct",
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("breaks a scored tie (same quality/cost/speed) by parsed version", () => {
|
|
79
|
+
// All identical except the trailing version: only versionTuple can
|
|
80
|
+
// order them. This is the live `q=46` Opus case.
|
|
81
|
+
const buckets = classifyEndpoints([
|
|
82
|
+
chat("databricks-claude-opus-4-6", { quality: 90, cost: 10, speed: 44 }),
|
|
83
|
+
chat("databricks-claude-opus-4-8", { quality: 90, cost: 10, speed: 44 }),
|
|
84
|
+
chat("databricks-claude-opus-4-7", { quality: 90, cost: 10, speed: 44 }),
|
|
85
|
+
]);
|
|
86
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatThinking), [
|
|
87
|
+
"databricks-claude-opus-4-8",
|
|
88
|
+
"databricks-claude-opus-4-7",
|
|
89
|
+
"databricks-claude-opus-4-6",
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("orders a double-digit point release above a single-digit one", () => {
|
|
94
|
+
const buckets = classifyEndpoints([
|
|
95
|
+
chat("databricks-claude-opus-4-9", { quality: 90, cost: 10, speed: 44 }),
|
|
96
|
+
chat("databricks-claude-opus-4-10", { quality: 90, cost: 10, speed: 44 }),
|
|
97
|
+
]);
|
|
98
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatThinking), [
|
|
99
|
+
"databricks-claude-opus-4-10",
|
|
100
|
+
"databricks-claude-opus-4-9",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("places unscored but recognizable chat endpoints by family heuristic", () => {
|
|
105
|
+
const buckets = classifyEndpoints([
|
|
106
|
+
chat("databricks-claude-opus-4-1"),
|
|
107
|
+
chat("databricks-claude-sonnet-4-0"),
|
|
108
|
+
chat("databricks-claude-haiku-3-5"),
|
|
109
|
+
]);
|
|
110
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatThinking), [
|
|
111
|
+
"databricks-claude-opus-4-1",
|
|
112
|
+
]);
|
|
113
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatBalanced), [
|
|
114
|
+
"databricks-claude-sonnet-4-0",
|
|
115
|
+
]);
|
|
116
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatFast), [
|
|
117
|
+
"databricks-claude-haiku-3-5",
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("buckets embedding endpoints by task, regardless of name or score", () => {
|
|
122
|
+
const buckets = classifyEndpoints([
|
|
123
|
+
chat("databricks-claude-opus-4-1"),
|
|
124
|
+
embedding("databricks-gte-large-en"),
|
|
125
|
+
embedding("databricks-bge-large-en"),
|
|
126
|
+
]);
|
|
127
|
+
assert.deepEqual(classNames(buckets, ModelClass.Embedding), [
|
|
128
|
+
"databricks-gte-large-en",
|
|
129
|
+
"databricks-bge-large-en",
|
|
130
|
+
]);
|
|
131
|
+
// Embeddings never leak into a chat band.
|
|
132
|
+
assert.deepEqual(classNames(buckets, ModelClass.ChatThinking), [
|
|
133
|
+
"databricks-claude-opus-4-1",
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("omits unrecognized unscored chat endpoints from every chat band", () => {
|
|
138
|
+
const buckets = classifyEndpoints([
|
|
139
|
+
chat("databricks-claude-opus-4-1"),
|
|
140
|
+
chat("databricks-some-custom-thing"),
|
|
141
|
+
]);
|
|
142
|
+
const chatNames = [
|
|
143
|
+
...buckets[ModelClass.ChatThinking],
|
|
144
|
+
...buckets[ModelClass.ChatBalanced],
|
|
145
|
+
...buckets[ModelClass.ChatFast],
|
|
146
|
+
].map((e) => e.name);
|
|
147
|
+
assert.deepEqual(chatNames, ["databricks-claude-opus-4-1"]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("classifyByFamily", () => {
|
|
152
|
+
it("maps well-known variants to chat classes and returns null for unknowns", () => {
|
|
153
|
+
assert.equal(classifyByFamily("databricks-claude-opus-4-8")?.class, ModelClass.ChatThinking);
|
|
154
|
+
assert.equal(classifyByFamily("databricks-claude-sonnet-4-6")?.class, ModelClass.ChatBalanced);
|
|
155
|
+
assert.equal(classifyByFamily("databricks-claude-haiku-4-5")?.class, ModelClass.ChatFast);
|
|
156
|
+
assert.equal(classifyByFamily("databricks-gpt-5-nano")?.class, ModelClass.ChatFast);
|
|
157
|
+
assert.equal(classifyByFamily("databricks-some-custom-thing"), null);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
"WebWorker"
|
|
15
|
+
],
|
|
16
|
+
"module": "ESNext",
|
|
17
|
+
"noEmitOnError": false,
|
|
18
|
+
"noFallthroughCasesInSwitch": true,
|
|
19
|
+
"noImplicitAny": true,
|
|
20
|
+
"noImplicitReturns": true,
|
|
21
|
+
"noImplicitThis": true,
|
|
22
|
+
"noUnusedLocals": true,
|
|
23
|
+
"noUnusedParameters": true,
|
|
24
|
+
"resolveJsonModule": true,
|
|
25
|
+
"strict": true,
|
|
26
|
+
"strictNullChecks": true,
|
|
27
|
+
"strictPropertyInitialization": true,
|
|
28
|
+
"stripInternal": true,
|
|
29
|
+
"target": "ES2022",
|
|
30
|
+
"types": [],
|
|
31
|
+
"moduleResolution": "bundler",
|
|
32
|
+
"skipLibCheck": true
|
|
33
|
+
},
|
|
34
|
+
"include": [
|
|
35
|
+
"src/**/*.ts"
|
|
36
|
+
],
|
|
37
|
+
"exclude": [
|
|
38
|
+
"node_modules"
|
|
39
|
+
]
|
|
40
|
+
}
|