@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 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
+ }
@@ -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
+ }