@dbx-tools/model 0.1.42 → 0.1.48

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/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Bundles the package's Node-side Model Serving access (cached
5
5
  * `/serving-endpoints` listing plus fuzzy name resolution), the
6
6
  * workspace-aware {@link resolveModel} selector, and the server-only
7
- * offline fallback opinion (`FALLBACK_MODEL_IDS` / `modelsForTier`)
7
+ * offline fallback opinion (`FALLBACK_MODEL_IDS` / `modelsForClass`)
8
8
  * with a re-export of the pure `@dbx-tools/model-shared` surface
9
9
  * (capability tiers, the score profile, the endpoint descriptor, and
10
10
  * the tier classifier) so a single `from "@dbx-tools/model"` import
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  export * from "@dbx-tools/model-shared";
20
+ export * from "./src/classes.js";
20
21
  export * from "./src/fallback.js";
21
22
  export * from "./src/resolve.js";
22
23
  export * from "./src/serving.js";
package/package.json CHANGED
@@ -9,10 +9,10 @@
9
9
  }
10
10
  },
11
11
  "name": "@dbx-tools/model",
12
- "version": "0.1.42",
12
+ "version": "0.1.48",
13
13
  "dependencies": {
14
- "@dbx-tools/model-shared": "0.1.42",
15
- "@dbx-tools/shared": "0.1.42",
14
+ "@dbx-tools/model-shared": "0.1.48",
15
+ "@dbx-tools/shared": "0.1.48",
16
16
  "fuse.js": "^7.0.0"
17
17
  },
18
18
  "module": "index.ts",
package/src/classes.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Model-class ordering for the model service.
3
+ *
4
+ * `@dbx-tools/model-shared` declares the {@link ModelClass} values and
5
+ * their schema; the *behavior* over those values - the chat capability
6
+ * order, the "this class and below" ceiling, and coercing loose request
7
+ * input to a class - lives here in the service so the shared
8
+ * wire-format surface stays purely declarative.
9
+ */
10
+
11
+ import { ModelClass, ModelClassSchema } from "@dbx-tools/model-shared";
12
+
13
+ /**
14
+ * Chat capability ladder in descending order - most capable
15
+ * ({@link ModelClass.ChatThinking}) first, least
16
+ * ({@link ModelClass.ChatFast}) last. {@link ModelClass.Embedding} is a
17
+ * separate modality and is deliberately absent: the ceiling never spans
18
+ * it. This is the order used to filter "this class and below" and to
19
+ * break ranking ties toward the more capable model.
20
+ */
21
+ export const CHAT_CLASS_ORDER: readonly ModelClass[] = [
22
+ ModelClass.ChatThinking,
23
+ ModelClass.ChatBalanced,
24
+ ModelClass.ChatFast,
25
+ ];
26
+
27
+ /**
28
+ * Every class in display order: the chat ladder followed by
29
+ * {@link ModelClass.Embedding}. Used when flattening a full
30
+ * classification (e.g. stamping the class onto each cached endpoint).
31
+ */
32
+ export const MODEL_CLASS_ORDER: readonly ModelClass[] = [
33
+ ...CHAT_CLASS_ORDER,
34
+ ModelClass.Embedding,
35
+ ];
36
+
37
+ /** Whether `cls` is one of the chat capability bands (vs. embedding). */
38
+ export function isChatClass(cls: ModelClass): boolean {
39
+ return CHAT_CLASS_ORDER.includes(cls);
40
+ }
41
+
42
+ /**
43
+ * Coerce an arbitrary value (query string, header, body field) to a
44
+ * {@link ModelClass}, returning `null` when it isn't a known class.
45
+ * Lets a route accept a class request without throwing on junk input.
46
+ *
47
+ * Matches the full slug first (`"chat-fast"`, `"embedding"`), then -
48
+ * for a bare chat band - retries with a `chat-` prefix, so the shorthand
49
+ * `"thinking"` / `"balanced"` / `"fast"` resolves to the corresponding
50
+ * `chat-*` class.
51
+ */
52
+ export function parseModelClass(value: unknown): ModelClass | null {
53
+ const exact = ModelClassSchema.safeParse(value);
54
+ if (exact.success) return exact.data;
55
+ const prefixed = ModelClassSchema.safeParse(`chat-${value}`);
56
+ return prefixed.success ? prefixed.data : null;
57
+ }
58
+
59
+ /**
60
+ * Classes at or below `cls` in chat capability: the class itself plus
61
+ * every less-capable chat band, in {@link CHAT_CLASS_ORDER}. Treats a
62
+ * chat `cls` as a ceiling - requesting {@link ModelClass.ChatBalanced}
63
+ * yields `[ChatBalanced, ChatFast]`, never
64
+ * {@link ModelClass.ChatThinking} - so a class ask can degrade downward
65
+ * to a smaller chat model but never escalate to a larger one.
66
+ *
67
+ * {@link ModelClass.Embedding} is its own modality, not a rung on the
68
+ * chat ladder, so it yields just `[Embedding]`. An unrecognized class
69
+ * yields the full chat ladder.
70
+ */
71
+ export function classesAtOrBelow(cls: ModelClass): ModelClass[] {
72
+ if (cls === ModelClass.Embedding) return [ModelClass.Embedding];
73
+ const index = CHAT_CLASS_ORDER.indexOf(cls);
74
+ return index < 0 ? [...CHAT_CLASS_ORDER] : [...CHAT_CLASS_ORDER.slice(index)];
75
+ }
package/src/fallback.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  * no user token, the service principal can't list, or the workspace is
6
6
  * unreachable - the resolver still has to name *some* endpoint. This
7
7
  * module holds that floor: a small, hard-coded set of well-known
8
- * Foundation Model API endpoint names, each bucketed into a
9
- * {@link ModelTier} by the shared {@link classifyByFamily} heuristic
10
- * (no tiers are hard-coded here) and ordered best-first.
8
+ * Foundation Model API endpoint names, each bucketed into a chat
9
+ * {@link ModelClass} by the shared {@link classifyByFamily} heuristic
10
+ * (no classes are hard-coded here) and ordered best-first.
11
11
  *
12
12
  * This is deliberately *server-only*. A browser client never talks to
13
13
  * Databricks directly - it always goes through this server - so it has
@@ -16,14 +16,18 @@
16
16
  * classifier ({@link classifyEndpoints}) is what the client shares.
17
17
  */
18
18
 
19
- import { classifyByFamily, type FamilyClass, ModelTier } from "@dbx-tools/model-shared";
19
+ import {
20
+ classifyByFamily,
21
+ type FamilyClass,
22
+ ModelClass,
23
+ } from "@dbx-tools/model-shared";
20
24
 
21
25
  /**
22
26
  * Small, last-resort set of well-known Foundation Model API endpoint
23
- * names, ordered best-first within each tier by {@link classifyByFamily}.
27
+ * names, ordered best-first within each class by {@link classifyByFamily}.
24
28
  * Used only as the floor when the live `/serving-endpoints` catalogue
25
29
  * can't be read at resolve time; the live, score-driven classification
26
- * supersedes it whenever the workspace listing is available. Tiers are
30
+ * supersedes it whenever the workspace listing is available. Classes are
27
31
  * not hard-coded here - each name is classified by family heuristic.
28
32
  */
29
33
  const FALLBACK_MODEL_NAMES: readonly string[] = [
@@ -39,34 +43,35 @@ const FALLBACK_MODEL_NAMES: readonly string[] = [
39
43
  ];
40
44
 
41
45
  /**
42
- * Static fallback model ids for a tier, drawn from the small built-in
43
- * {@link FALLBACK_MODEL_NAMES} list and ordered best-first by family
44
- * rank. Sync and workspace-independent: this is the *fallback opinion*
45
- * used to seed default lists or when the live catalogue is unreachable
46
- * - live resolution prefers `classifyEndpoints`.
46
+ * Static fallback model ids for a chat class, drawn from the small
47
+ * built-in {@link FALLBACK_MODEL_NAMES} list and ordered best-first by
48
+ * family rank. Sync and workspace-independent: this is the *fallback
49
+ * opinion* used to seed default lists or when the live catalogue is
50
+ * unreachable - live resolution prefers `classifyEndpoints`. Returns
51
+ * `[]` for {@link ModelClass.Embedding} (the floor is chat-only).
47
52
  */
48
- export function modelsForTier(tier: ModelTier): readonly string[] {
53
+ export function modelsForClass(cls: ModelClass): readonly string[] {
49
54
  return FALLBACK_MODEL_NAMES.map((name) => ({ name, c: classifyByFamily(name) }))
50
55
  .filter(
51
- (x): x is { name: string; c: FamilyClass } => x.c !== null && x.c.tier === tier,
56
+ (x): x is { name: string; c: FamilyClass } => x.c !== null && x.c.class === cls,
52
57
  )
53
58
  .sort((a, b) => b.c.rank - a.c.rank)
54
59
  .map((x) => x.name);
55
60
  }
56
61
 
57
- /** Top static fallback model id for a tier. */
58
- export function modelForTier(tier: ModelTier): string {
59
- return modelsForTier(tier)[0]!;
62
+ /** Top static fallback model id for a chat class. */
63
+ export function modelForClass(cls: ModelClass): string {
64
+ return modelsForClass(cls)[0]!;
60
65
  }
61
66
 
62
67
  /**
63
- * Priority-ordered fallback chain (Thinking -> Balanced -> Fast) over
64
- * the small built-in list. The floor walked at resolve time when no
65
- * agent / plugin / env / request model is set *and* the live
66
- * catalogue yields nothing.
68
+ * Priority-ordered fallback chain (ChatThinking -> ChatBalanced ->
69
+ * ChatFast) over the small built-in list. The floor walked at resolve
70
+ * time when no agent / plugin / env / request model is set *and* the
71
+ * live catalogue yields nothing.
67
72
  */
68
73
  export const FALLBACK_MODEL_IDS: readonly string[] = [
69
- ...modelsForTier(ModelTier.Thinking),
70
- ...modelsForTier(ModelTier.Balanced),
71
- ...modelsForTier(ModelTier.Fast),
74
+ ...modelsForClass(ModelClass.ChatThinking),
75
+ ...modelsForClass(ModelClass.ChatBalanced),
76
+ ...modelsForClass(ModelClass.ChatFast),
72
77
  ];
package/src/resolve.ts CHANGED
@@ -1,29 +1,46 @@
1
1
  /**
2
2
  * Workspace-aware model selection.
3
3
  *
4
- * Given a caller's intent (an explicit name, a capability
5
- * {@link ModelTier}, or nothing), the resolver returns the endpoint id
6
- * to use - always one the workspace actually has, degrading from "best
7
- * in tier" down to the static fallback floor. Two entry points:
4
+ * Given a caller's intent - a search string, a capability
5
+ * {@link ModelClass} ceiling, both, or nothing - the toolkit returns
6
+ * matching endpoints ranked by match quality then class, or collapses
7
+ * to the single best id the workspace actually has, degrading from
8
+ * "best in range" down to the static fallback floor. Selection is
9
+ * chat-only: embedding endpoints surface only when `modelClass` is
10
+ * explicitly {@link ModelClass.Embedding}.
8
11
  *
9
- * - {@link selectModel} is the high-level, I/O-doing call: hand it a
10
- * `WorkspaceClient` and intent and it lists `/serving-endpoints`
11
- * (cached) and resolves in one step. This is what a non-Mastra
12
- * consumer (e.g. a job that just needs a model name) reaches for.
13
- * - {@link resolveModel} is the pure form over an endpoint list you
14
- * already hold; `selectModel` is a thin wrapper over it.
12
+ * Ranking entry points (return a ranked list):
13
+ *
14
+ * - {@link rankModels} is the pure ranker over an endpoint list you
15
+ * already hold. A chat `modelClass` acts as a ceiling: only that
16
+ * band and the less-capable chat bands below it are eligible (see
17
+ * {@link classesAtOrBelow}), so a `chat-balanced` ask can fall to
18
+ * `chat-fast` but never escalate to `chat-thinking`.
19
+ * - {@link searchModels} is the I/O wrapper: hand it a
20
+ * `WorkspaceClient` and it lists `/serving-endpoints` (cached) then
21
+ * ranks in one step.
22
+ *
23
+ * Single-selection entry points (return one id + how it was reached):
24
+ *
25
+ * - {@link resolveModel} is the pure selector; it delegates to
26
+ * {@link rankModels} with `limit: 1` and adds the operator-pinned
27
+ * fallback / static-floor safety net.
28
+ * - {@link selectModel} is the I/O wrapper over {@link resolveModel}.
15
29
  */
16
30
 
17
31
  import {
18
32
  classifyEndpoints,
19
- ModelTier,
33
+ ModelClass,
34
+ type ModelQuery,
35
+ type RankedModel,
20
36
  type ServingEndpointSummary,
21
37
  } from "@dbx-tools/model-shared";
22
38
 
23
- import { FALLBACK_MODEL_IDS, modelsForTier } from "./fallback.js";
39
+ import { CHAT_CLASS_ORDER, classesAtOrBelow, MODEL_CLASS_ORDER } from "./classes.js";
40
+ import { FALLBACK_MODEL_IDS, modelsForClass } from "./fallback.js";
24
41
  import {
25
42
  listServingEndpoints,
26
- resolveModelId,
43
+ searchServingEndpoints,
27
44
  type WorkspaceClientLike,
28
45
  } from "./serving.js";
29
46
 
@@ -31,8 +48,8 @@ import {
31
48
  export interface ResolveModelInput {
32
49
  /**
33
50
  * Explicit model id / loose name (per-request override, agent /
34
- * plugin default, or env var). When set it wins over `tier` and
35
- * `fallbacks`.
51
+ * plugin default, or env var). When set it wins over `modelClass`
52
+ * and `fallbacks`.
36
53
  */
37
54
  explicit?: string;
38
55
  /**
@@ -45,15 +62,15 @@ export interface ResolveModelInput {
45
62
  /** Fuse.js threshold forwarded to {@link resolveModelId}. */
46
63
  threshold?: number;
47
64
  /**
48
- * Capability tier to resolve when no `explicit` id is given. The
49
- * live catalogue is classified by its Foundation Model API scores
50
- * and the top available model in the tier wins, falling back to the
51
- * tier's small static list.
65
+ * Chat capability class to resolve when no `explicit` id is given.
66
+ * The live catalogue is classified by its Foundation Model API scores
67
+ * and the top available model in the class (and the chat bands below
68
+ * it) wins, falling back to the class's small static list.
52
69
  */
53
- tier?: ModelTier;
70
+ modelClass?: ModelClass;
54
71
  /**
55
72
  * Operator-supplied fallback ids tried *first* in the no-explicit,
56
- * no-tier path (e.g. a regulated workspace pinned to an approved
73
+ * no-class path (e.g. a regulated workspace pinned to an approved
57
74
  * subset), ahead of the auto-classified catalogue.
58
75
  */
59
76
  fallbacks?: readonly string[];
@@ -62,7 +79,7 @@ export interface ResolveModelInput {
62
79
  /** Outcome of {@link resolveModel}: the chosen id plus how it was reached. */
63
80
  export interface ResolvedModelSelection {
64
81
  modelId: string;
65
- source: "explicit" | "fuzzy-match" | "tier" | "fallback";
82
+ source: "explicit" | "fuzzy-match" | "class" | "fallback";
66
83
  }
67
84
 
68
85
  /** Intent + catalogue knobs passed to {@link selectModel}. */
@@ -71,6 +88,114 @@ export interface SelectModelInput extends ResolveModelInput {
71
88
  ttlMs?: number;
72
89
  }
73
90
 
91
+ /** TTL override merged into a {@link ModelQuery} for {@link searchModels}. */
92
+ export interface SearchModelsInput extends ModelQuery {
93
+ /** TTL override for the cached `/serving-endpoints` listing, in ms. */
94
+ ttlMs?: number;
95
+ }
96
+
97
+ /**
98
+ * Round a Fuse score to the display precision so version siblings that
99
+ * match a token identically (e.g. `opus-4-7` vs `opus-4-8` for the
100
+ * query `"opus"`) tie on match and let the class / within-class rank
101
+ * decide - which is what surfaces the newer, higher-quality sibling.
102
+ */
103
+ function matchBucket(score: number | undefined): number {
104
+ return Math.round((score ?? 0) * 1000);
105
+ }
106
+
107
+ /**
108
+ * Rank the live catalogue against a {@link ModelQuery}, best-first.
109
+ *
110
+ * Candidates are the classified endpoints in the eligible classes:
111
+ * {@link classesAtOrBelow} the requested `modelClass`, or - when none is
112
+ * given - the chat bands only ({@link CHAT_CLASS_ORDER}), so a general
113
+ * ask never surfaces an embedding endpoint. Each class bucket is
114
+ * already best-first from {@link classifyEndpoints}. Ranking is **match
115
+ * then class**:
116
+ *
117
+ * 1. With a `search`, only endpoints matching it survive, ordered by
118
+ * match distance (bucketed via {@link matchBucket} so near-identical
119
+ * scores tie), then by class (more capable first), then by the stable
120
+ * within-class rank.
121
+ * 2. Without a `search`, the class-then-rank candidate order stands.
122
+ *
123
+ * A `limit` truncates the result. Returns `[]` when nothing is
124
+ * eligible or matches - callers layer their own fallback.
125
+ */
126
+ export function rankModels(
127
+ endpoints: readonly ServingEndpointSummary[],
128
+ query: ModelQuery = {},
129
+ ): RankedModel[] {
130
+ const classified = classifyEndpoints(endpoints);
131
+ const eligible =
132
+ query.modelClass !== undefined
133
+ ? classesAtOrBelow(query.modelClass)
134
+ : CHAT_CLASS_ORDER;
135
+
136
+ // Flatten eligible classes in capability order, carrying each
137
+ // endpoint's class; bucket order is already best-first.
138
+ const candidates: RankedModel[] = [];
139
+ for (const modelClass of eligible) {
140
+ for (const endpoint of classified[modelClass])
141
+ candidates.push({ endpoint, modelClass });
142
+ }
143
+
144
+ const search = query.search?.trim();
145
+ let ranked: RankedModel[];
146
+ if (search) {
147
+ const scores = new Map<string, number>();
148
+ for (const match of searchServingEndpoints(
149
+ search,
150
+ candidates.map((c) => c.endpoint),
151
+ query.threshold !== undefined ? { threshold: query.threshold } : {},
152
+ )) {
153
+ scores.set(match.endpoint.name, match.score);
154
+ }
155
+ // `Array.prototype.sort` is stable, so endpoints equal on match and
156
+ // class keep their best-first within-class order.
157
+ ranked = candidates
158
+ .filter((c) => scores.has(c.endpoint.name))
159
+ .map((c) => ({ ...c, score: scores.get(c.endpoint.name) }))
160
+ .sort((a, b) => {
161
+ const byMatch = matchBucket(a.score) - matchBucket(b.score);
162
+ if (byMatch !== 0) return byMatch;
163
+ return (
164
+ MODEL_CLASS_ORDER.indexOf(a.modelClass) -
165
+ MODEL_CLASS_ORDER.indexOf(b.modelClass)
166
+ );
167
+ });
168
+ } else {
169
+ ranked = candidates;
170
+ }
171
+
172
+ return query.limit !== undefined ? ranked.slice(0, Math.max(0, query.limit)) : ranked;
173
+ }
174
+
175
+ /**
176
+ * Rank a workspace's catalogue in one call: list its
177
+ * `/serving-endpoints` (cached) and run {@link rankModels} over the
178
+ * result. The list counterpart to {@link selectModel}, for a consumer
179
+ * that wants the full ranked set (a model picker, a CLI) rather than a
180
+ * single id. Catalogue fetches fail loud: network / auth errors
181
+ * propagate so the caller sees the real SDK message.
182
+ *
183
+ * @param host - Workspace host used as the cache key. Pass the value
184
+ * resolved from `client.config.getHost()`.
185
+ */
186
+ export async function searchModels(
187
+ client: WorkspaceClientLike,
188
+ host: string,
189
+ input: SearchModelsInput = {},
190
+ ): Promise<RankedModel[]> {
191
+ const endpoints = await listServingEndpoints(
192
+ client,
193
+ host,
194
+ input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {},
195
+ );
196
+ return rankModels(endpoints, input);
197
+ }
198
+
74
199
  /**
75
200
  * Resolve a model id for a workspace in one call: list its
76
201
  * `/serving-endpoints` (cached) and run {@link resolveModel} over the
@@ -102,16 +227,16 @@ export async function selectModel(
102
227
  }
103
228
 
104
229
  /**
105
- * Resolve a model id from the live catalogue and caller intent.
230
+ * Resolve a single model id from the live catalogue and caller intent,
231
+ * delegating the live selection to {@link rankModels} with `limit: 1`.
106
232
  *
107
233
  * 1. **Explicit ask**: with `fuzzy` off, returned verbatim; otherwise
108
- * snapped to the closest live endpoint via {@link resolveModelId}.
109
- * 2. **Tier intent**: classify the live catalogue and return the top
110
- * available model in that tier, then the tier's static list, then
111
- * the {@link FALLBACK_MODEL_IDS} floor.
112
- * 3. **Neither**: walk `fallbacks` first, then the live
113
- * score-classified catalogue (Thinking -> Balanced -> Fast), then
114
- * the static floor, and return the first id actually present.
234
+ * fuzzy-ranked within the (optional) class ceiling and the best taken,
235
+ * falling back to the input verbatim when nothing matches.
236
+ * 2. **No explicit ask**: an operator-pinned `fallback` that exists in
237
+ * the live catalogue wins first; then the ranked live catalogue
238
+ * (class ceiling applied); then the static {@link FALLBACK_MODEL_IDS}
239
+ * floor when the catalogue yields nothing in range.
115
240
  */
116
241
  export function resolveModel(
117
242
  endpoints: readonly ServingEndpointSummary[],
@@ -121,54 +246,38 @@ export function resolveModel(
121
246
  if (input.fuzzy === false) {
122
247
  return { modelId: input.explicit, source: "explicit" };
123
248
  }
124
- const { modelId } = resolveModelId(input.explicit, endpoints, {
125
- threshold: input.threshold,
126
- });
127
- return { modelId, source: "fuzzy-match" };
249
+ const [top] = rankModels(endpoints, buildQuery(input, input.explicit));
250
+ return { modelId: top?.endpoint.name ?? input.explicit, source: "fuzzy-match" };
128
251
  }
129
252
 
130
- const classified = classifyEndpoints(endpoints);
131
- const chain =
132
- input.tier !== undefined
133
- ? tierChain(classified, input.tier)
134
- : defaultChain(classified, input.fallbacks);
135
- return {
136
- modelId: pickFirstAvailable(chain, endpoints),
137
- source: input.tier !== undefined ? "tier" : "fallback",
138
- };
139
- }
253
+ // Operator-pinned fallbacks win when present and live (e.g. a
254
+ // regulated workspace restricted to an approved subset).
255
+ if (input.modelClass === undefined && input.fallbacks && input.fallbacks.length > 0) {
256
+ const present = new Set(endpoints.map((e) => e.name));
257
+ const pinned = input.fallbacks.find((id) => present.has(id));
258
+ if (pinned) return { modelId: pinned, source: "fallback" };
259
+ }
140
260
 
141
- /**
142
- * Candidate chain for an explicit {@link ModelTier} ask: live models
143
- * classified into that tier (best-first), then the tier's small static
144
- * list, then the full fallback floor as a last resort.
145
- */
146
- function tierChain(
147
- classified: Record<ModelTier, ServingEndpointSummary[]>,
148
- tier: ModelTier,
149
- ): string[] {
150
- return dedupe([
151
- ...classified[tier].map((e) => e.name),
152
- ...modelsForTier(tier),
153
- ...FALLBACK_MODEL_IDS,
154
- ]);
261
+ const source = input.modelClass !== undefined ? "class" : "fallback";
262
+ const [top] = rankModels(endpoints, buildQuery(input, undefined));
263
+ if (top) return { modelId: top.endpoint.name, source };
264
+
265
+ // Live catalogue yielded nothing in range: walk the static floor.
266
+ const floor =
267
+ input.modelClass !== undefined
268
+ ? dedupe([...modelsForClass(input.modelClass), ...FALLBACK_MODEL_IDS])
269
+ : dedupe([...(input.fallbacks ?? []), ...FALLBACK_MODEL_IDS]);
270
+ return { modelId: pickFirstAvailable(floor, endpoints), source };
155
271
  }
156
272
 
157
- /**
158
- * Candidate chain for the no-explicit default: any operator-supplied
159
- * `fallbacks` first, then the live score-classified catalogue in
160
- * descending tier order, then the static fallback floor.
161
- */
162
- function defaultChain(
163
- classified: Record<ModelTier, ServingEndpointSummary[]>,
164
- fallbacks: readonly string[] | undefined,
165
- ): string[] {
166
- const live = [
167
- ...classified[ModelTier.Thinking],
168
- ...classified[ModelTier.Balanced],
169
- ...classified[ModelTier.Fast],
170
- ].map((e) => e.name);
171
- return dedupe([...(fallbacks ?? []), ...live, ...FALLBACK_MODEL_IDS]);
273
+ /** Build a {@link ModelQuery} from {@link ResolveModelInput} for the `limit: 1` delegation. */
274
+ function buildQuery(input: ResolveModelInput, search: string | undefined): ModelQuery {
275
+ return {
276
+ ...(search !== undefined ? { search } : {}),
277
+ ...(input.modelClass !== undefined ? { modelClass: input.modelClass } : {}),
278
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
279
+ limit: 1,
280
+ };
172
281
  }
173
282
 
174
283
  /** Drop duplicate ids while preserving first-seen order. */