@dbx-tools/appkit-model-gateway 0.9.189 → 0.9.193
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 +6 -0
- package/index.ts +7 -1
- package/lib/index.d.ts +7 -1
- package/lib/index.js +6 -2
- package/lib/src/ai-decide.d.ts +50 -0
- package/lib/src/ai-decide.js +406 -0
- package/lib/src/gateway.d.ts +6 -2
- package/lib/src/gateway.js +72 -16
- package/lib/src/model-context.d.ts +32 -0
- package/lib/src/model-context.js +270 -0
- package/lib/src/models.d.ts +3 -3
- package/lib/src/models.js +55 -8
- package/lib/src/reasoning-adapt.js +2 -5
- package/lib/src/routes.js +18 -2
- package/lib/src/smart-router.d.ts +4 -7
- package/lib/src/smart-router.js +7 -175
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/ai-decide.ts +552 -0
- package/src/gateway.ts +96 -14
- package/src/model-context.ts +306 -0
- package/src/models.ts +66 -4
- package/src/reasoning-adapt.ts +1 -5
- package/src/routes.ts +16 -0
- package/src/smart-router.ts +8 -187
package/src/ai-decide.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Databricks AI Functions `ai_decide` model selection.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getExecutionContext, type WorkspaceClient } from "@databricks/appkit";
|
|
8
|
+
import { closeCache, createCache, getOrSet } from "@dbx-tools/cache";
|
|
9
|
+
import { refreshWorkspaceClientAuthentication } from "@dbx-tools/databricks";
|
|
10
|
+
import { endpointCapabilities, modelMetadataFor, selection, versionTuple } from "@dbx-tools/model";
|
|
11
|
+
import { isRecord, log, toError } from "@dbx-tools/shared-core";
|
|
12
|
+
import { ModelClass, type ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
13
|
+
import {
|
|
14
|
+
AiDecideModelFiltersSchema,
|
|
15
|
+
AiDecideObservationSchema,
|
|
16
|
+
AiDecideStrategySchema,
|
|
17
|
+
type AiDecideModelFilters,
|
|
18
|
+
type AiDecideObservation,
|
|
19
|
+
type AiDecideStrategy,
|
|
20
|
+
type ResolvedAiDecideModelFilters,
|
|
21
|
+
} from "@dbx-tools/shared-model-gateway";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
AppKitSmartRouterProbeStore,
|
|
26
|
+
SmartRouterContextBuilder,
|
|
27
|
+
SmartRouterProbeRecordSchema,
|
|
28
|
+
type SmartRouterProbeRecord,
|
|
29
|
+
type SmartRouterProbeStore,
|
|
30
|
+
} from "./smart-router.ts";
|
|
31
|
+
|
|
32
|
+
const AI_DECIDE_PATH = "/api/2.0/ai-functions/ai-decide";
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
34
|
+
const PROBE_KEY = "ai-decide-v1";
|
|
35
|
+
const POSITIVE_PROBE_TTL_MS = 5 * 60_000;
|
|
36
|
+
const NEGATIVE_PROBE_TTL_MS = 30_000;
|
|
37
|
+
const PROBE_CACHE_TTL_MS = POSITIVE_PROBE_TTL_MS * 2;
|
|
38
|
+
const logger = log.logger("appkit/model-gateway/ai-decide");
|
|
39
|
+
|
|
40
|
+
const AiDecideChoiceAnswerSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
type: z.literal("choice"),
|
|
43
|
+
choice: z.string().trim().min(1),
|
|
44
|
+
probabilities: z.record(z.string(), z.number().min(0).max(1)).optional(),
|
|
45
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
46
|
+
})
|
|
47
|
+
.passthrough();
|
|
48
|
+
|
|
49
|
+
const AiDecideTaskComplexitySchema = z.enum(["simple", "standard", "advanced"]);
|
|
50
|
+
|
|
51
|
+
const AiDecideTaskComplexityAnswerSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
type: z.literal("choice"),
|
|
54
|
+
choice: AiDecideTaskComplexitySchema,
|
|
55
|
+
})
|
|
56
|
+
.passthrough();
|
|
57
|
+
|
|
58
|
+
const AiDecideTaskComplexityResponseSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
response: z
|
|
61
|
+
.object({
|
|
62
|
+
answers: z
|
|
63
|
+
.object({
|
|
64
|
+
task_complexity: AiDecideTaskComplexityAnswerSchema,
|
|
65
|
+
})
|
|
66
|
+
.passthrough(),
|
|
67
|
+
})
|
|
68
|
+
.passthrough(),
|
|
69
|
+
metadata: z.object({ version: z.string() }).passthrough().optional(),
|
|
70
|
+
})
|
|
71
|
+
.passthrough();
|
|
72
|
+
|
|
73
|
+
const AiDecideModelResponseSchema = z
|
|
74
|
+
.object({
|
|
75
|
+
response: z
|
|
76
|
+
.object({
|
|
77
|
+
answers: z
|
|
78
|
+
.object({
|
|
79
|
+
model: AiDecideChoiceAnswerSchema,
|
|
80
|
+
})
|
|
81
|
+
.passthrough(),
|
|
82
|
+
})
|
|
83
|
+
.passthrough(),
|
|
84
|
+
metadata: z.object({ version: z.string() }).passthrough().optional(),
|
|
85
|
+
})
|
|
86
|
+
.passthrough();
|
|
87
|
+
|
|
88
|
+
/** Selection policy accepted by {@link AiDecideRouterService.select}. */
|
|
89
|
+
export interface AiDecideSelectionOptions {
|
|
90
|
+
readonly strategy?: AiDecideStrategy;
|
|
91
|
+
readonly filters?: AiDecideModelFilters;
|
|
92
|
+
readonly signal?: AbortSignal;
|
|
93
|
+
readonly timeoutMs?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Concrete endpoint and observable decision returned by `ai_decide`. */
|
|
97
|
+
export interface AiDecideSelection {
|
|
98
|
+
readonly endpoint: ServingEndpointSummary;
|
|
99
|
+
readonly observation: AiDecideObservation;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type AiDecideInvoker = (
|
|
103
|
+
client: WorkspaceClient,
|
|
104
|
+
body: Readonly<Record<string, unknown>>,
|
|
105
|
+
signal: AbortSignal,
|
|
106
|
+
timeoutMs: number,
|
|
107
|
+
) => Promise<unknown>;
|
|
108
|
+
|
|
109
|
+
type AiDecideProbeCache = ReturnType<typeof createCache>;
|
|
110
|
+
|
|
111
|
+
/** Select the best sufficient live foundation model through Databricks `ai_decide`. */
|
|
112
|
+
export class AiDecideRouterService {
|
|
113
|
+
private readonly context = new SmartRouterContextBuilder();
|
|
114
|
+
private readonly probes = new WeakMap<object, AiDecideProbeCache>();
|
|
115
|
+
private readonly probeCaches = new Set<AiDecideProbeCache>();
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly invoke: AiDecideInvoker = invokeAiDecide,
|
|
119
|
+
private readonly probeStore: SmartRouterProbeStore = new AppKitSmartRouterProbeStore(),
|
|
120
|
+
) {}
|
|
121
|
+
|
|
122
|
+
async select(
|
|
123
|
+
input: unknown,
|
|
124
|
+
endpoints: readonly ServingEndpointSummary[],
|
|
125
|
+
client: WorkspaceClient | undefined = undefined,
|
|
126
|
+
options: AiDecideSelectionOptions = {},
|
|
127
|
+
): Promise<AiDecideSelection> {
|
|
128
|
+
const workspaceClient = client ?? getExecutionContext().client;
|
|
129
|
+
const strategy = AiDecideStrategySchema.parse(options.strategy ?? "balanced");
|
|
130
|
+
const filters = AiDecideModelFiltersSchema.parse(options.filters ?? {});
|
|
131
|
+
const candidates = modelCandidates(endpoints, filters);
|
|
132
|
+
if (candidates.length === 0) {
|
|
133
|
+
throw new AiDecideRouterError(400, "No eligible foundation models are available");
|
|
134
|
+
}
|
|
135
|
+
if (candidates.length === 1) {
|
|
136
|
+
const [endpoint] = candidates;
|
|
137
|
+
return {
|
|
138
|
+
endpoint: endpoint!,
|
|
139
|
+
observation: AiDecideObservationSchema.parse({
|
|
140
|
+
selectedModel: endpoint!.name,
|
|
141
|
+
strategy,
|
|
142
|
+
confidence: 1,
|
|
143
|
+
probabilities: { [endpoint!.name]: 1 },
|
|
144
|
+
latencyMs: 0,
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const context = await this.context.analyze(input);
|
|
149
|
+
if (!context.task) {
|
|
150
|
+
throw new AiDecideRouterError(400, "ai_decide input contains no routable text");
|
|
151
|
+
}
|
|
152
|
+
const currentRequest = [...context.events]
|
|
153
|
+
.reverse()
|
|
154
|
+
.find((event) => event.role === "user")?.text;
|
|
155
|
+
const currentTask = currentRequest ?? context.task;
|
|
156
|
+
const taskComplexityBody = {
|
|
157
|
+
state: {
|
|
158
|
+
task: currentTask,
|
|
159
|
+
current_request: currentTask,
|
|
160
|
+
},
|
|
161
|
+
questions: {
|
|
162
|
+
task_complexity: {
|
|
163
|
+
type: "choice" as const,
|
|
164
|
+
instructions:
|
|
165
|
+
"Classify the current request only. Choose advanced when a complete answer needs dependencies, configuration, APIs, and multiple code artifacts; architecture, debugging, or several interacting constraints. Choose standard for a focused implementation or analysis with limited moving parts. Choose simple only for short factual, creative, formatting, or explanation requests.",
|
|
166
|
+
criteria: {
|
|
167
|
+
simple: "One direct answer or artifact with little reasoning or implementation risk.",
|
|
168
|
+
standard: "Focused implementation or analysis with limited interacting requirements.",
|
|
169
|
+
advanced:
|
|
170
|
+
"Multiple implementation artifacts or interacting technical constraints where completeness and correctness require stronger reasoning.",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
options: { version: "1.0" },
|
|
175
|
+
};
|
|
176
|
+
const buildDecision = (
|
|
177
|
+
decisionCandidates: readonly ServingEndpointSummary[],
|
|
178
|
+
taskComplexity: z.infer<typeof AiDecideTaskComplexitySchema>,
|
|
179
|
+
) => {
|
|
180
|
+
const labels = new Map(
|
|
181
|
+
decisionCandidates.map((endpoint, index) => [`model_${index + 1}`, endpoint]),
|
|
182
|
+
);
|
|
183
|
+
const criteria = Object.fromEntries(
|
|
184
|
+
[...labels].map(([label, endpoint], index) => [
|
|
185
|
+
label,
|
|
186
|
+
candidateDescription(endpoint, index + 1),
|
|
187
|
+
]),
|
|
188
|
+
);
|
|
189
|
+
return {
|
|
190
|
+
labels,
|
|
191
|
+
body: {
|
|
192
|
+
state: {
|
|
193
|
+
task: currentTask,
|
|
194
|
+
current_request: currentTask,
|
|
195
|
+
...(context.events.length > 1 ? { conversation_context: context.task } : {}),
|
|
196
|
+
conversation_turns: context.events.filter((event) => event.role === "user").length,
|
|
197
|
+
context_tokens: context.tokenCount,
|
|
198
|
+
completion_requirement:
|
|
199
|
+
"The selected model must answer the current request accurately and completely in one response. Partial or truncated output is insufficient.",
|
|
200
|
+
task_complexity: taskComplexity,
|
|
201
|
+
strategy,
|
|
202
|
+
candidates: decisionCandidates.map((endpoint, index) =>
|
|
203
|
+
candidateState(endpoint, index + 1),
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
questions: {
|
|
207
|
+
model: {
|
|
208
|
+
type: "choice" as const,
|
|
209
|
+
instructions: `${strategyInstructions(strategy)} The task has already been classified as ${taskComplexity}.`,
|
|
210
|
+
criteria,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
options: { version: "1.0" },
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
const startedAt = performance.now();
|
|
218
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
219
|
+
let decision: ReturnType<typeof buildDecision>;
|
|
220
|
+
let response: z.infer<typeof AiDecideModelResponseSchema>;
|
|
221
|
+
try {
|
|
222
|
+
const taskComplexity = AiDecideTaskComplexityResponseSchema.parse(
|
|
223
|
+
await this.invoke(
|
|
224
|
+
workspaceClient,
|
|
225
|
+
taskComplexityBody,
|
|
226
|
+
signal,
|
|
227
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
228
|
+
),
|
|
229
|
+
).response.answers.task_complexity.choice;
|
|
230
|
+
decision = buildDecision(sufficientCandidates(candidates, taskComplexity), taskComplexity);
|
|
231
|
+
response = AiDecideModelResponseSchema.parse(
|
|
232
|
+
await this.invoke(
|
|
233
|
+
workspaceClient,
|
|
234
|
+
decision.body,
|
|
235
|
+
signal,
|
|
236
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
this.setProbeValue(workspaceClient, true);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
this.setProbeValue(workspaceClient, false);
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
const answer = response.response.answers.model;
|
|
245
|
+
const selected = decision.labels.get(answer.choice);
|
|
246
|
+
if (!selected) {
|
|
247
|
+
throw new AiDecideRouterError(502, `ai_decide selected unknown candidate ${answer.choice}`);
|
|
248
|
+
}
|
|
249
|
+
const probabilities = answer.probabilities
|
|
250
|
+
? Object.fromEntries(
|
|
251
|
+
Object.entries(answer.probabilities).flatMap(([label, probability]) => {
|
|
252
|
+
const endpoint = decision.labels.get(label);
|
|
253
|
+
return endpoint ? [[endpoint.name, probability] as const] : [];
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
: undefined;
|
|
257
|
+
return {
|
|
258
|
+
endpoint: selected,
|
|
259
|
+
observation: AiDecideObservationSchema.parse({
|
|
260
|
+
selectedModel: selected.name,
|
|
261
|
+
strategy,
|
|
262
|
+
...(answer.confidence !== undefined ? { confidence: answer.confidence } : {}),
|
|
263
|
+
...(probabilities && Object.keys(probabilities).length > 0 ? { probabilities } : {}),
|
|
264
|
+
latencyMs: Math.round(performance.now() - startedAt),
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Probe and memoize whether the active workspace identity can call `ai_decide`. */
|
|
270
|
+
async isEnabled(
|
|
271
|
+
endpoints: readonly ServingEndpointSummary[],
|
|
272
|
+
client: WorkspaceClient | undefined = undefined,
|
|
273
|
+
): Promise<boolean> {
|
|
274
|
+
if (modelCandidates(endpoints, AiDecideModelFiltersSchema.parse({})).length < 2) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
let workspaceClient: WorkspaceClient;
|
|
278
|
+
try {
|
|
279
|
+
workspaceClient = client ?? getExecutionContext().client;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
const cache = this.probeCache(workspaceClient);
|
|
284
|
+
const probe = await getOrSet(
|
|
285
|
+
cache,
|
|
286
|
+
PROBE_KEY,
|
|
287
|
+
() => this.loadProbe(endpoints, workspaceClient),
|
|
288
|
+
{
|
|
289
|
+
ttl: PROBE_CACHE_TTL_MS,
|
|
290
|
+
refreshAfterWrite: (value) => this.probeTtl(value.enabled),
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
return probe?.enabled ?? false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Stop process-local probe caches after active refreshes settle. */
|
|
297
|
+
dispose(): void {
|
|
298
|
+
for (const cache of this.probeCaches) void closeCache(cache);
|
|
299
|
+
this.probeCaches.clear();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private async loadProbe(
|
|
303
|
+
endpoints: readonly ServingEndpointSummary[],
|
|
304
|
+
client: WorkspaceClient,
|
|
305
|
+
): Promise<SmartRouterProbeRecord> {
|
|
306
|
+
const stored = await this.readStoredProbe(client);
|
|
307
|
+
if (stored && stored.expiresAt > Date.now()) return stored;
|
|
308
|
+
const enabled = await this.select(
|
|
309
|
+
"Choose the lowest-latency sufficient model for a one-sentence factual answer.",
|
|
310
|
+
endpoints,
|
|
311
|
+
client,
|
|
312
|
+
{ strategy: "latency", timeoutMs: DEFAULT_TIMEOUT_MS },
|
|
313
|
+
)
|
|
314
|
+
.then(() => true)
|
|
315
|
+
.catch((error) => {
|
|
316
|
+
logger.debug("ai_decide capability probe unavailable", { error: toError(error) });
|
|
317
|
+
return false;
|
|
318
|
+
});
|
|
319
|
+
const probe = this.probeRecord(enabled);
|
|
320
|
+
await this.writeStoredProbe(probe, client);
|
|
321
|
+
return probe;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private setProbeValue(client: WorkspaceClient, enabled: boolean): void {
|
|
325
|
+
const probe = this.probeRecord(enabled);
|
|
326
|
+
void this.writeProcessProbe(this.probeCache(client), probe);
|
|
327
|
+
void this.writeStoredProbe(probe, client);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private probeRecord(enabled: boolean): SmartRouterProbeRecord {
|
|
331
|
+
return { enabled, expiresAt: Date.now() + this.probeTtl(enabled) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private probeTtl(enabled: boolean): number {
|
|
335
|
+
return enabled ? POSITIVE_PROBE_TTL_MS : NEGATIVE_PROBE_TTL_MS;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private async readStoredProbe(
|
|
339
|
+
client: WorkspaceClient,
|
|
340
|
+
): Promise<SmartRouterProbeRecord | undefined> {
|
|
341
|
+
try {
|
|
342
|
+
const parsed = SmartRouterProbeRecordSchema.safeParse(
|
|
343
|
+
await this.probeStore.get(PROBE_KEY, client),
|
|
344
|
+
);
|
|
345
|
+
return parsed.success ? parsed.data : undefined;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logger.debug("ai_decide probe cache read failed", { error: toError(error) });
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async writeProcessProbe(
|
|
353
|
+
cache: AiDecideProbeCache,
|
|
354
|
+
probe: SmartRouterProbeRecord,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
try {
|
|
357
|
+
await cache.set(PROBE_KEY, probe, { ttl: PROBE_CACHE_TTL_MS });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
logger.debug("ai_decide process probe cache write failed", { error: toError(error) });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async writeStoredProbe(
|
|
364
|
+
probe: SmartRouterProbeRecord,
|
|
365
|
+
client: WorkspaceClient,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
try {
|
|
368
|
+
await this.probeStore.set(
|
|
369
|
+
PROBE_KEY,
|
|
370
|
+
probe,
|
|
371
|
+
Math.max(1, Math.ceil(PROBE_CACHE_TTL_MS / 1_000)),
|
|
372
|
+
client,
|
|
373
|
+
);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
logger.debug("ai_decide probe cache write failed", { error: toError(error) });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private probeCache(client: WorkspaceClient): AiDecideProbeCache {
|
|
380
|
+
const owner =
|
|
381
|
+
client.config && typeof client.config === "object" ? (client.config as object) : client;
|
|
382
|
+
let cache = this.probes.get(owner);
|
|
383
|
+
if (!cache) {
|
|
384
|
+
cache = createCache({ maxEntries: 1, useClone: false });
|
|
385
|
+
this.probes.set(owner, cache);
|
|
386
|
+
this.probeCaches.add(cache);
|
|
387
|
+
}
|
|
388
|
+
return cache;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** HTTP failure returned by the Databricks AI Functions router. */
|
|
393
|
+
export class AiDecideRouterError extends Error {
|
|
394
|
+
constructor(
|
|
395
|
+
readonly status: number,
|
|
396
|
+
message: string,
|
|
397
|
+
) {
|
|
398
|
+
super(message);
|
|
399
|
+
this.name = "AiDecideRouterError";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function modelCandidates(
|
|
404
|
+
endpoints: readonly ServingEndpointSummary[],
|
|
405
|
+
filters: ResolvedAiDecideModelFilters,
|
|
406
|
+
): ServingEndpointSummary[] {
|
|
407
|
+
const families = filters.families?.map((family) => family.toLowerCase());
|
|
408
|
+
return selection
|
|
409
|
+
.lookupModels(
|
|
410
|
+
endpoints,
|
|
411
|
+
{
|
|
412
|
+
...(filters.modelClass ? { modelClass: filters.modelClass } : {}),
|
|
413
|
+
modelSource: filters.modelSource,
|
|
414
|
+
...(filters.requiresTools !== undefined ? { requiresTools: filters.requiresTools } : {}),
|
|
415
|
+
...(filters.requiresStructuredOutput !== undefined
|
|
416
|
+
? { requiresStructuredOutput: filters.requiresStructuredOutput }
|
|
417
|
+
: {}),
|
|
418
|
+
limit: filters.maxCandidates,
|
|
419
|
+
},
|
|
420
|
+
{ preferSystemModels: true },
|
|
421
|
+
)
|
|
422
|
+
.map(({ endpoint }) => endpoint)
|
|
423
|
+
.filter(
|
|
424
|
+
(endpoint) =>
|
|
425
|
+
!families || (endpoint.family && families.includes(endpoint.family.toLowerCase())),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function sufficientCandidates(
|
|
430
|
+
candidates: readonly ServingEndpointSummary[],
|
|
431
|
+
taskComplexity: z.infer<typeof AiDecideTaskComplexitySchema>,
|
|
432
|
+
): ServingEndpointSummary[] {
|
|
433
|
+
const classOrder = {
|
|
434
|
+
simple: [ModelClass.ChatFast, ModelClass.ChatBalanced, ModelClass.ChatThinking],
|
|
435
|
+
standard: [ModelClass.ChatBalanced, ModelClass.ChatThinking, ModelClass.ChatFast],
|
|
436
|
+
advanced: [ModelClass.ChatThinking, ModelClass.ChatBalanced, ModelClass.ChatFast],
|
|
437
|
+
}[taskComplexity];
|
|
438
|
+
for (const modelClass of classOrder) {
|
|
439
|
+
const sufficient = candidates.filter((endpoint) => endpoint.class === modelClass);
|
|
440
|
+
if (sufficient.length > 0) return sufficient;
|
|
441
|
+
}
|
|
442
|
+
return [...candidates];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function candidateState(endpoint: ServingEndpointSummary, rank: number): Record<string, unknown> {
|
|
446
|
+
const capabilities = endpointCapabilities(endpoint);
|
|
447
|
+
return {
|
|
448
|
+
id: endpoint.name,
|
|
449
|
+
rank,
|
|
450
|
+
display_name: endpoint.displayName,
|
|
451
|
+
family: endpoint.family,
|
|
452
|
+
version: versionTuple(endpoint.name),
|
|
453
|
+
class: endpoint.class,
|
|
454
|
+
profile: endpoint.profile,
|
|
455
|
+
benchmarks: candidateBenchmarks(endpoint),
|
|
456
|
+
capabilities: {
|
|
457
|
+
chat: capabilities.chat,
|
|
458
|
+
tools: capabilities.tools,
|
|
459
|
+
structured_output: endpoint.supportsStructuredOutput === true,
|
|
460
|
+
reasoning_efforts: endpoint.reasoningEfforts ?? [],
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function candidateDescription(
|
|
466
|
+
endpoint: ServingEndpointSummary,
|
|
467
|
+
rank: number,
|
|
468
|
+
): Record<string, unknown> {
|
|
469
|
+
const benchmarks = candidateBenchmarks(endpoint);
|
|
470
|
+
return {
|
|
471
|
+
model: endpoint.name,
|
|
472
|
+
rank,
|
|
473
|
+
family: endpoint.family ?? null,
|
|
474
|
+
version: versionTuple(endpoint.name),
|
|
475
|
+
capability_class: endpoint.class ?? null,
|
|
476
|
+
quality_score: endpoint.profile?.quality ?? null,
|
|
477
|
+
speed_score: endpoint.profile?.speed ?? null,
|
|
478
|
+
cost_score: endpoint.profile?.cost ?? null,
|
|
479
|
+
measured_quality_percentile: benchmarks.quality_percentile,
|
|
480
|
+
normalized_response_time_ms: benchmarks.normalized_response_time_ms,
|
|
481
|
+
time_to_first_token_ms: benchmarks.time_to_first_token_ms,
|
|
482
|
+
output_tokens_per_second: benchmarks.output_tokens_per_second,
|
|
483
|
+
supports_tools: endpointCapabilities(endpoint).tools,
|
|
484
|
+
supports_structured_output: endpoint.supportsStructuredOutput === true,
|
|
485
|
+
reasoning_efforts: endpoint.reasoningEfforts ?? [],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function candidateBenchmarks(endpoint: ServingEndpointSummary): Record<string, number | null> {
|
|
490
|
+
const metadata = modelMetadataFor(endpoint);
|
|
491
|
+
return {
|
|
492
|
+
quality_percentile: metadata.rating?.score ?? null,
|
|
493
|
+
normalized_response_time_ms: metadata.latencyScore?.score ?? null,
|
|
494
|
+
time_to_first_token_ms: metadata.latencyScore?.measurements.timeToFirstTokenMs ?? null,
|
|
495
|
+
output_tokens_per_second: metadata.latencyScore?.measurements.outputTokensPerSecond ?? null,
|
|
496
|
+
successful_run_count: metadata.latencyScore?.measurements.successfulRunCount ?? null,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function strategyInstructions(strategy: AiDecideStrategy): string {
|
|
501
|
+
const objective = {
|
|
502
|
+
balanced:
|
|
503
|
+
"Choose the smallest and least expensive model that is still likely to answer the task accurately and completely.",
|
|
504
|
+
quality: "Choose the model most likely to produce the highest-quality correct answer.",
|
|
505
|
+
cost: "Choose the lowest-cost model that is still sufficient for the task. Keep premier and newer unscored models eligible when they are materially more likely to complete the request correctly. When candidates have similar expected answer quality and completeness, prefer lower cost over lower latency.",
|
|
506
|
+
latency: "Choose the fastest model that is still sufficient for the task.",
|
|
507
|
+
}[strategy];
|
|
508
|
+
return `${objective} Judge current_request as the work that must be completed now; use task only as conversation context, and do not let simpler earlier turns reduce the current request's difficulty. A model is sufficient only if it is likely to produce a complete, correct answer in one response. Treat partial or truncated output as insufficient. Software implementation that requires dependencies, configuration, APIs, and multiple code artifacts needs materially more quality than a short explanation, even when the request itself is concise. Optimize for the selected strategy only among sufficient candidates. Use the supplied rank, version, capability class, quality, measured latency, throughput, cost, tool, structured-output, family, and reasoning metadata. Lower normalized_response_time_ms and time_to_first_token_ms are faster; higher output_tokens_per_second and measured_quality_percentile are better. Treat a newer unscored model as potentially stronger than an older scored predecessor rather than penalizing missing profile scores. Prefer simpler models for genuinely simple requests and reserve stronger models for implementation, multi-constraint reasoning, architecture, and debugging.`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function invokeAiDecide(
|
|
512
|
+
client: WorkspaceClient,
|
|
513
|
+
body: Readonly<Record<string, unknown>>,
|
|
514
|
+
signal: AbortSignal,
|
|
515
|
+
timeoutMs: number,
|
|
516
|
+
): Promise<unknown> {
|
|
517
|
+
const host = (await client.config.getHost()).toString();
|
|
518
|
+
const url = new URL(AI_DECIDE_PATH, host);
|
|
519
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
520
|
+
await client.config.authenticate(headers);
|
|
521
|
+
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
|
522
|
+
let response = await fetch(url, {
|
|
523
|
+
method: "POST",
|
|
524
|
+
headers,
|
|
525
|
+
body: JSON.stringify(body),
|
|
526
|
+
signal: combinedSignal,
|
|
527
|
+
});
|
|
528
|
+
if (response.status === 401 && (await refreshWorkspaceClientAuthentication(client))) {
|
|
529
|
+
await response.body?.cancel();
|
|
530
|
+
headers.delete("authorization");
|
|
531
|
+
headers.delete("x-databricks-workspace-id");
|
|
532
|
+
await client.config.authenticate(headers);
|
|
533
|
+
response = await fetch(url, {
|
|
534
|
+
method: "POST",
|
|
535
|
+
headers,
|
|
536
|
+
body: JSON.stringify(body),
|
|
537
|
+
signal: combinedSignal,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
const detail = (await response.text().catch(() => "")).trim().slice(0, 500);
|
|
542
|
+
throw new AiDecideRouterError(
|
|
543
|
+
response.status,
|
|
544
|
+
`Databricks ai_decide failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const value = (await response.json()) as unknown;
|
|
548
|
+
if (isRecord(value) && typeof value.error_message === "string" && value.error_message) {
|
|
549
|
+
throw new AiDecideRouterError(502, value.error_message);
|
|
550
|
+
}
|
|
551
|
+
return value;
|
|
552
|
+
}
|