@dbx-tools/shared-model 0.9.5 → 0.9.6

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/src/classify.ts DELETED
@@ -1,345 +0,0 @@
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
- * @module
27
- */
28
-
29
- import { ModelClass, type ServingEndpointSummary } from "./model.ts";
30
-
31
- /** Task hint Databricks stamps on chat completion endpoints. */
32
- const CHAT_TASK = "llm/v1/chat";
33
-
34
- /** Task hint Databricks stamps on embedding endpoints. */
35
- const EMBEDDING_TASK = "llm/v1/embeddings";
36
-
37
- type ModelFamily = "claude" | "gemini" | "gemma" | "glm" | "gpt" | "llama" | "qwen";
38
-
39
- interface ParsedFamily {
40
- family: ModelFamily;
41
- parts: ReadonlySet<string>;
42
- }
43
-
44
- function parsedFamily(name: string): ParsedFamily | undefined {
45
- const tokens = name.toLowerCase().match(/[a-z0-9]+/g) ?? [];
46
- const familyToken = tokens.find((token) => {
47
- return (
48
- token === "claude" ||
49
- token === "gemini" ||
50
- token === "gemma" ||
51
- token === "glm" ||
52
- token === "gpt" ||
53
- token === "llama" ||
54
- token === "qwen" ||
55
- /^qwen\d/.test(token)
56
- );
57
- });
58
- if (!familyToken) return undefined;
59
- const family = (familyToken.startsWith("qwen") ? "qwen" : familyToken) as ModelFamily;
60
- return {
61
- family,
62
- parts: new Set(tokens),
63
- };
64
- }
65
-
66
- /** What an endpoint can be asked to do, as derived by {@link endpointCapabilities}. */
67
- export interface EndpointCapabilities {
68
- /** OpenAI chat/completions + Responses: the surface a chat agent needs. */
69
- chat: boolean;
70
- /** Embedding (vector) endpoint. */
71
- embedding: boolean;
72
- /** Complete function-tool round-trip (call plus function_call_output replay). */
73
- tools: boolean;
74
- }
75
-
76
- /**
77
- * Provider families verified against Databricks Responses/Open Responses with
78
- * both a forced function call and a stateless `function_call_output` replay.
79
- *
80
- * Databricks' endpoint list/OpenAPI currently exposes no tools capability bit.
81
- * Keep this conservative: an unknown chat family is not agent-safe until it is
82
- * verified. Gemini is excluded because it emits a call but Open Responses
83
- * cannot replay the required thought signature; GPT-OSS rejects Responses
84
- * passthrough entirely.
85
- */
86
- export function supportsToolsByFamily(name: string): boolean {
87
- const parsed = parsedFamily(name);
88
- if (!parsed || parsed.family === "gemini") return false;
89
- if (parsed.family === "gpt" && parsed.parts.has("oss")) return false;
90
- return ["claude", "gpt", "qwen", "glm", "llama"].includes(parsed.family);
91
- }
92
-
93
- /**
94
- * Derive an endpoint's capabilities from its Databricks task hint and its
95
- * classified {@link ModelClass}, so consumers filter on capability instead of
96
- * re-deriving it from raw `task` / `class` strings.
97
- *
98
- * Either signal alone is enough: the task hint is authoritative when present,
99
- * and the class covers endpoints Databricks left untasked but the classifier
100
- * recognized. Embedding wins over chat when both point at it, since the two are
101
- * not interchangeable.
102
- */
103
- export function endpointCapabilities(endpoint: ServingEndpointSummary): EndpointCapabilities {
104
- const embedding = endpoint.task === EMBEDDING_TASK || endpoint.class === ModelClass.Embedding;
105
- // Every non-embedding class is a chat band, so "classified but not embedding"
106
- // is the class-side test for chat capability.
107
- const chat = !embedding && (endpoint.task === CHAT_TASK || endpoint.class !== undefined);
108
- const tools = chat && (endpoint.supportsTools ?? supportsToolsByFamily(endpoint.name));
109
- return { chat, embedding, tools };
110
- }
111
-
112
- /** Family-heuristic classification of a single endpoint name. */
113
- export interface FamilyClass {
114
- /** Chat capability band the family maps to (never embedding). */
115
- class: ModelClass;
116
- /** Intra-family ordering hint (higher is newer / more capable). */
117
- rank: number;
118
- }
119
-
120
- /**
121
- * Numeric `[major, minor, patch]` version parsed from an endpoint
122
- * name, used to order siblings within a family/tier. Starts at the
123
- * first digit in the name, then reads successive separator-delimited,
124
- * digit-prefixed chunks as the three components (missing ones default
125
- * to `0`):
126
- *
127
- * - `databricks-claude-opus-4-8` -> `[4, 8, 0]`
128
- * - `databricks-claude-opus-4-10` -> `[4, 10, 0]` (sorts above 4-8)
129
- * - `databricks-meta-llama-3-3-70b`-> `[3, 3, 70]`
130
- * - `databricks-bge-large-en` -> `[0, 0, 0]` (no digits)
131
- *
132
- * Component-wise comparison (not a decimal collapse) so `4.10` beats
133
- * `4.8` - the bug a `major + minor/10` score would hit.
134
- */
135
- export function versionTuple(name: string): [number, number, number] {
136
- const start = name.search(/\d/);
137
- if (start < 0) return [0, 0, 0];
138
- const nums = name
139
- .slice(start)
140
- .split(/[^a-z0-9]+/i)
141
- .map((chunk) => chunk.match(/^\d+/)?.[0])
142
- .filter((digits): digits is string => digits !== undefined)
143
- .map(Number);
144
- return [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0];
145
- }
146
-
147
- /** Compare two version tuples so the higher version sorts first (descending). */
148
- function compareVersionDesc(a: readonly number[], b: readonly number[]): number {
149
- for (let i = 0; i < 3; i++) {
150
- const diff = (b[i] ?? 0) - (a[i] ?? 0);
151
- if (diff !== 0) return diff;
152
- }
153
- return 0;
154
- }
155
-
156
- /**
157
- * Monotonic version key for ordering siblings within a family/tier
158
- * (e.g. `opus-4-8` over `opus-4-7`). Encodes the {@link versionTuple}
159
- * as a single number so callers that need a scalar rank (the family
160
- * heuristic, the static fallback ordering) keep working; the encoding
161
- * preserves component order without the decimal-collapse bug.
162
- */
163
- function versionScore(name: string): number {
164
- const [major, minor, patch] = versionTuple(name);
165
- return major * 1_000_000 + minor * 1_000 + patch;
166
- }
167
-
168
- /**
169
- * Best-effort chat capability band for an endpoint we have no live
170
- * score for, keyed off provider family and the well-known variant words
171
- * in the name (`opus`/`sonnet`/`haiku`, `pro`/`mini`/`nano`,
172
- * `flash`/`flash-lite`, Llama parameter sizes, etc). Returns `null` for
173
- * names we don't recognize so unknown custom endpoints are never
174
- * auto-selected as a default. The accompanying `rank` orders siblings
175
- * within a class. Only ever returns a chat band - embedding endpoints
176
- * are classified by task, not name.
177
- */
178
- export function classifyByFamily(name: string): FamilyClass | null {
179
- const n = name.toLowerCase();
180
- const parsed = parsedFamily(n);
181
- if (!parsed) return null;
182
- const { family, parts } = parsed;
183
- const has = (part: string): boolean => parts.has(part);
184
- const at = (cls: ModelClass): FamilyClass => ({ class: cls, rank: versionScore(n) });
185
-
186
- // Anthropic Claude
187
- if (family === "claude" && has("opus")) return at(ModelClass.ChatThinking);
188
- if (family === "claude" && has("sonnet")) return at(ModelClass.ChatBalanced);
189
- if (family === "claude" && has("haiku")) return at(ModelClass.ChatFast);
190
-
191
- // OpenAI open-weights (check before the generic gpt branch)
192
- if (family === "gpt" && has("oss")) {
193
- return at(has("120b") ? ModelClass.ChatBalanced : ModelClass.ChatFast);
194
- }
195
- // OpenAI GPT family
196
- if (family === "gpt") {
197
- if (has("pro")) return at(ModelClass.ChatThinking);
198
- if (has("mini") || has("nano")) return at(ModelClass.ChatFast);
199
- return at(ModelClass.ChatBalanced);
200
- }
201
-
202
- // Google Gemini / Gemma
203
- if (family === "gemini") {
204
- if (has("flash") && has("lite")) return at(ModelClass.ChatFast);
205
- if (has("pro")) return at(ModelClass.ChatThinking);
206
- return at(ModelClass.ChatBalanced);
207
- }
208
- if (family === "gemma") return at(ModelClass.ChatFast);
209
-
210
- // Meta Llama
211
- if (family === "llama") {
212
- if (has("maverick") || has("405b")) return at(ModelClass.ChatThinking);
213
- if (has("70b")) return at(ModelClass.ChatBalanced);
214
- if (has("8b") || has("1b")) return at(ModelClass.ChatFast);
215
- return at(ModelClass.ChatBalanced);
216
- }
217
-
218
- // Alibaba Qwen
219
- if (family === "qwen") return at(ModelClass.ChatBalanced);
220
-
221
- return null;
222
- }
223
-
224
- /** Linear-interpolated quantile of an ascending-sorted numeric array. */
225
- function quantile(sortedAsc: readonly number[], p: number): number {
226
- if (sortedAsc.length === 0) return Number.NaN;
227
- const idx = (sortedAsc.length - 1) * p;
228
- const lo = Math.floor(idx);
229
- const hi = Math.ceil(idx);
230
- if (lo === hi) return sortedAsc[lo]!;
231
- return sortedAsc[lo]! + (sortedAsc[hi]! - sortedAsc[lo]!) * (idx - lo);
232
- }
233
-
234
- /** Internal sortable wrapper carrying the ordering keys for a bucket entry. */
235
- interface Ranked {
236
- ep: ServingEndpointSummary;
237
- /** Quality (scored) or family version (unscored) - higher first. */
238
- sort: number;
239
- /** Scored endpoints rank ahead of family-only guesses. */
240
- scored: boolean;
241
- tieCost: number;
242
- tieSpeed: number;
243
- /** Parsed name version, the final tie-breaker (newer sibling first). */
244
- version: readonly number[];
245
- }
246
-
247
- function rankOrder(a: Ranked, b: Ranked): number {
248
- if (a.scored !== b.scored) return a.scored ? -1 : 1;
249
- if (b.sort !== a.sort) return b.sort - a.sort;
250
- if (a.tieCost !== b.tieCost) return a.tieCost - b.tieCost;
251
- if (b.tieSpeed !== a.tieSpeed) return b.tieSpeed - a.tieSpeed;
252
- // Same scored-ness, quality, cost, and speed (e.g. several Claude
253
- // Opus point releases): prefer the newer parsed version so
254
- // `opus-4-8` beats `opus-4-7`.
255
- return compareVersionDesc(a.version, b.version);
256
- }
257
-
258
- /**
259
- * Bucket live endpoints into {@link ModelClass}es, ranked best-first
260
- * within each chat band.
261
- *
262
- * Embedding endpoints (`task === "llm/v1/embeddings"`) go into
263
- * {@link ModelClass.Embedding} by task, in listing order (they carry no
264
- * capability score to rank on).
265
- *
266
- * Chat endpoints (`task === "llm/v1/chat"`) split into the three chat
267
- * bands. Scored endpoints (those carrying a `profile.quality`) drive
268
- * the banding: the observed quality distribution is split at its 1/3
269
- * and 2/3 quantiles, so the top third is {@link ModelClass.ChatThinking},
270
- * the bottom third {@link ModelClass.ChatFast}, and the middle
271
- * {@link ModelClass.ChatBalanced}. Because the thresholds come from the
272
- * data, the split adapts as Databricks adds or rescores models -
273
- * nothing is pinned to a fixed score band.
274
- *
275
- * Unscored chat endpoints are placed by {@link classifyByFamily} and
276
- * ranked after the scored ones in their band; unrecognized, unscored
277
- * endpoints (e.g. custom external models) are omitted entirely so they
278
- * are never picked as an automatic default.
279
- *
280
- * Within a chat band, scored models sort by `quality` desc, then `cost`
281
- * asc, then `speed` desc, then parsed name version desc; family-only
282
- * models sort by version rank then parsed version. The version
283
- * tie-break ({@link versionTuple}) is what separates point releases
284
- * that share a score profile (e.g. `opus-4-8` ahead of `opus-4-7`).
285
- */
286
- export function classifyEndpoints(
287
- endpoints: readonly ServingEndpointSummary[],
288
- ): Record<ModelClass, ServingEndpointSummary[]> {
289
- const chat = endpoints.filter((e) => e.task === CHAT_TASK);
290
- const qualities = chat
291
- .map((e) => e.profile?.quality)
292
- .filter((q): q is number => Number.isFinite(q))
293
- .sort((a, b) => a - b);
294
- const low = quantile(qualities, 1 / 3);
295
- const high = quantile(qualities, 2 / 3);
296
-
297
- const buckets: Record<ModelClass, Ranked[]> = {
298
- [ModelClass.ChatThinking]: [],
299
- [ModelClass.ChatBalanced]: [],
300
- [ModelClass.ChatFast]: [],
301
- [ModelClass.Embedding]: [],
302
- };
303
-
304
- for (const ep of chat) {
305
- const q = ep.profile?.quality;
306
- if (Number.isFinite(q)) {
307
- const quality = q as number;
308
- const cls =
309
- quality >= high
310
- ? ModelClass.ChatThinking
311
- : quality <= low
312
- ? ModelClass.ChatFast
313
- : ModelClass.ChatBalanced;
314
- buckets[cls].push({
315
- ep,
316
- sort: quality,
317
- scored: true,
318
- tieCost: ep.profile?.cost ?? Number.POSITIVE_INFINITY,
319
- tieSpeed: ep.profile?.speed ?? 0,
320
- version: versionTuple(ep.name),
321
- });
322
- continue;
323
- }
324
- const family = classifyByFamily(ep.name);
325
- if (!family) continue;
326
- buckets[family.class].push({
327
- ep,
328
- sort: family.rank,
329
- scored: false,
330
- tieCost: Number.POSITIVE_INFINITY,
331
- tieSpeed: 0,
332
- version: versionTuple(ep.name),
333
- });
334
- }
335
-
336
- // Embeddings are bucketed by task in listing order - no score to rank.
337
- const embeddings = endpoints.filter((e) => e.task === EMBEDDING_TASK);
338
-
339
- return {
340
- [ModelClass.ChatThinking]: buckets[ModelClass.ChatThinking].sort(rankOrder).map((x) => x.ep),
341
- [ModelClass.ChatBalanced]: buckets[ModelClass.ChatBalanced].sort(rankOrder).map((x) => x.ep),
342
- [ModelClass.ChatFast]: buckets[ModelClass.ChatFast].sort(rankOrder).map((x) => x.ep),
343
- [ModelClass.Embedding]: embeddings,
344
- };
345
- }