@dbx-tools/appkit-model-gateway 0.9.175 → 0.9.177
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 +77 -0
- package/index.ts +4 -1
- package/lib/index.d.ts +4 -1
- package/lib/index.js +4 -2
- package/lib/src/gateway.d.ts +13 -1
- package/lib/src/gateway.js +98 -15
- package/lib/src/models.d.ts +3 -3
- package/lib/src/models.js +100 -6
- package/lib/src/plugin.js +2 -10
- package/lib/src/registry.js +3 -1
- package/lib/src/router.js +27 -5
- package/lib/src/routes.js +5 -2
- package/lib/src/smart-router.d.ts +159 -0
- package/lib/src/smart-router.js +700 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/src/gateway.ts +152 -14
- package/src/models.ts +128 -5
- package/src/plugin.ts +1 -9
- package/src/registry.ts +2 -0
- package/src/router.ts +29 -4
- package/src/routes.ts +4 -1
- package/src/smart-router.ts +938 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Databricks Smart Router transport, context construction, probing, and turn pinning.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { CacheManager, getExecutionContext, type WorkspaceClient } from "@databricks/appkit";
|
|
9
|
+
import { closeCache, createCache, getOrSet } from "@dbx-tools/cache";
|
|
10
|
+
import { refreshWorkspaceClientAuthentication } from "@dbx-tools/databricks";
|
|
11
|
+
import { isRecord, log, toError } from "@dbx-tools/shared-core";
|
|
12
|
+
import { ReasoningEffortSchema, type ReasoningEffort } from "@dbx-tools/shared-model";
|
|
13
|
+
import {
|
|
14
|
+
SMART_ROUTER_NAME,
|
|
15
|
+
SmartRouterObservationSchema,
|
|
16
|
+
SmartRouterRequestSchema,
|
|
17
|
+
SmartRouterResponseSchema,
|
|
18
|
+
type ClientProtocol,
|
|
19
|
+
type ModelTarget,
|
|
20
|
+
type RequestedFeatures,
|
|
21
|
+
type SmartRouterHarness,
|
|
22
|
+
type SmartRouterObservation,
|
|
23
|
+
type SmartRouterRequest,
|
|
24
|
+
} from "@dbx-tools/shared-model-gateway";
|
|
25
|
+
import { Tiktoken } from "js-tiktoken/lite";
|
|
26
|
+
import o200kBase from "js-tiktoken/ranks/o200k_base";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
|
|
29
|
+
import { resolveRoute } from "./router.ts";
|
|
30
|
+
|
|
31
|
+
const logger = log.logger("appkit/model-gateway/smart-router");
|
|
32
|
+
const ROUTE_CACHE_NAMESPACE = "model-gateway-smart-router";
|
|
33
|
+
const ROUTE_POLICY_VERSION = 1;
|
|
34
|
+
const PROBE_CACHE_NAMESPACE = "model-gateway-smart-router-probe";
|
|
35
|
+
const PROBE_CACHE_VERSION = 1;
|
|
36
|
+
const PROBE_TASK = "Check whether Smart Router can select an available model.";
|
|
37
|
+
const NEGATIVE_PROBE_TTL_MS = 30_000;
|
|
38
|
+
const CONTEXT_ENCODING = new Tiktoken(o200kBase);
|
|
39
|
+
|
|
40
|
+
/** Runtime settings for Smart Router. */
|
|
41
|
+
export interface SmartRouterSettings {
|
|
42
|
+
readonly contextTokenBudget: number;
|
|
43
|
+
readonly probeRefreshIntervalMs: number;
|
|
44
|
+
readonly probeTtlMs: number;
|
|
45
|
+
readonly routeTtlSeconds: number;
|
|
46
|
+
readonly routerName: string;
|
|
47
|
+
readonly timeoutMs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Defaults shared by gateway, Mastra, and CLI Smart Router callers. */
|
|
51
|
+
export const SMART_ROUTER_DEFAULT_SETTINGS: SmartRouterSettings = Object.freeze({
|
|
52
|
+
contextTokenBudget: 4_096,
|
|
53
|
+
probeRefreshIntervalMs: 3_600_000,
|
|
54
|
+
probeTtlMs: 300_000,
|
|
55
|
+
routeTtlSeconds: 21_600,
|
|
56
|
+
routerName: SMART_ROUTER_NAME,
|
|
57
|
+
timeoutMs: 10_000,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** One route option associated with the caller-owned value it represents. */
|
|
61
|
+
export interface SmartRouterCandidate<T> {
|
|
62
|
+
readonly harness: SmartRouterHarness;
|
|
63
|
+
readonly model: string;
|
|
64
|
+
readonly value: T;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Result returned after mapping a Databricks route back to a caller-owned value. */
|
|
68
|
+
export interface SmartRouterSelection<T> {
|
|
69
|
+
readonly harness: SmartRouterHarness;
|
|
70
|
+
readonly latencyMs: number;
|
|
71
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
72
|
+
readonly rationale?: string;
|
|
73
|
+
readonly routeModel: string;
|
|
74
|
+
readonly routerName: string;
|
|
75
|
+
readonly value: T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Portable persistence surface for a pinned Smart Router turn. */
|
|
79
|
+
export interface SmartRouterStore {
|
|
80
|
+
get(key: string): Promise<unknown>;
|
|
81
|
+
set(key: string, value: SmartRouterObservation, ttlSeconds: number): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const SmartRouterProbeRecordSchema = z
|
|
85
|
+
.object({
|
|
86
|
+
enabled: z.boolean().describe("Whether the live Smart Router probe succeeded."),
|
|
87
|
+
expiresAt: z.number().int().describe("Epoch milliseconds when this probe becomes stale."),
|
|
88
|
+
})
|
|
89
|
+
.describe("Persisted Smart Router capability probe result.");
|
|
90
|
+
|
|
91
|
+
export type SmartRouterProbeRecord = z.infer<typeof SmartRouterProbeRecordSchema>;
|
|
92
|
+
|
|
93
|
+
/** Optional persistent cache for capability probes. */
|
|
94
|
+
export interface SmartRouterProbeStore {
|
|
95
|
+
get(key: string, client: WorkspaceClient): Promise<unknown>;
|
|
96
|
+
set(
|
|
97
|
+
key: string,
|
|
98
|
+
value: SmartRouterProbeRecord,
|
|
99
|
+
ttlSeconds: number,
|
|
100
|
+
client: WorkspaceClient,
|
|
101
|
+
): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Result applied to one concrete gateway request. */
|
|
105
|
+
export interface SmartRouterResult {
|
|
106
|
+
readonly body: Readonly<Record<string, unknown>>;
|
|
107
|
+
readonly observation: SmartRouterObservation;
|
|
108
|
+
readonly target: ModelTarget;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Bounded task context extracted from an arbitrary supported input shape. */
|
|
112
|
+
export interface SmartRouterContext {
|
|
113
|
+
readonly continuation: boolean;
|
|
114
|
+
readonly logicalTurn: string;
|
|
115
|
+
readonly task: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Error carrying an HTTP status for Smart Router validation or availability failures. */
|
|
119
|
+
export class SmartRouterError extends Error {
|
|
120
|
+
constructor(
|
|
121
|
+
readonly status: number,
|
|
122
|
+
message: string,
|
|
123
|
+
) {
|
|
124
|
+
super(message);
|
|
125
|
+
this.name = "SmartRouterError";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** AppKit cache adapter; configured Lakebase/Postgres storage remains AppKit-owned. */
|
|
130
|
+
export class AppKitSmartRouterStore implements SmartRouterStore {
|
|
131
|
+
async get(key: string): Promise<unknown> {
|
|
132
|
+
const cache = await CacheManager.getInstance();
|
|
133
|
+
return cache.get(cache.generateKey([ROUTE_CACHE_NAMESPACE, ROUTE_POLICY_VERSION], key));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async set(key: string, value: SmartRouterObservation, ttlSeconds: number): Promise<void> {
|
|
137
|
+
const cache = await CacheManager.getInstance();
|
|
138
|
+
await cache.set(cache.generateKey([ROUTE_CACHE_NAMESPACE, ROUTE_POLICY_VERSION], key), value, {
|
|
139
|
+
ttl: ttlSeconds,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** AppKit cache adapter for process- and persistence-backed capability probes. */
|
|
145
|
+
export class AppKitSmartRouterProbeStore implements SmartRouterProbeStore {
|
|
146
|
+
async get(key: string, client: WorkspaceClient): Promise<unknown> {
|
|
147
|
+
const cache = appKitCacheManager();
|
|
148
|
+
if (!cache) return undefined;
|
|
149
|
+
const identity = await smartRouterProbeIdentity(client);
|
|
150
|
+
return cache.get(
|
|
151
|
+
cache.generateKey([PROBE_CACHE_NAMESPACE, PROBE_CACHE_VERSION], `${identity}:${key}`),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async set(
|
|
156
|
+
key: string,
|
|
157
|
+
value: SmartRouterProbeRecord,
|
|
158
|
+
ttlSeconds: number,
|
|
159
|
+
client: WorkspaceClient,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const cache = appKitCacheManager();
|
|
162
|
+
if (!cache) return;
|
|
163
|
+
const identity = await smartRouterProbeIdentity(client);
|
|
164
|
+
await cache.set(
|
|
165
|
+
cache.generateKey([PROBE_CACHE_NAMESPACE, PROBE_CACHE_VERSION], `${identity}:${key}`),
|
|
166
|
+
value,
|
|
167
|
+
{ ttl: ttlSeconds },
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type SmartRouterProbeCache = ReturnType<typeof createCache>;
|
|
173
|
+
|
|
174
|
+
/** Optional identity and cancellation inputs for one Smart Router call. */
|
|
175
|
+
export interface SmartRouterSelectOptions {
|
|
176
|
+
readonly client?: WorkspaceClient;
|
|
177
|
+
readonly signal?: AbortSignal;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type SmartRouterInvoker = (
|
|
181
|
+
client: WorkspaceClient,
|
|
182
|
+
request: SmartRouterRequest,
|
|
183
|
+
signal: AbortSignal,
|
|
184
|
+
timeoutMs: number,
|
|
185
|
+
) => Promise<unknown>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Dedicated Smart Router service for strings, request objects, message arrays,
|
|
189
|
+
* and generic JSON-like inputs.
|
|
190
|
+
*/
|
|
191
|
+
export class SmartRouterService {
|
|
192
|
+
readonly context: SmartRouterContextBuilder;
|
|
193
|
+
private readonly settings: SmartRouterSettings;
|
|
194
|
+
private readonly probes = new WeakMap<object, SmartRouterProbeCache>();
|
|
195
|
+
private readonly probeKeys = new WeakMap<object, Set<string>>();
|
|
196
|
+
private readonly probeCaches = new Set<SmartRouterProbeCache>();
|
|
197
|
+
|
|
198
|
+
constructor(
|
|
199
|
+
settings: Partial<SmartRouterSettings> = {},
|
|
200
|
+
private readonly invoke: SmartRouterInvoker = invokeSmartRouter,
|
|
201
|
+
private readonly probeStore: SmartRouterProbeStore = new AppKitSmartRouterProbeStore(),
|
|
202
|
+
) {
|
|
203
|
+
this.settings = { ...SMART_ROUTER_DEFAULT_SETTINGS, ...settings };
|
|
204
|
+
this.context = new SmartRouterContextBuilder(this.settings.contextTokenBudget);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Select one offered route for arbitrary input content. */
|
|
208
|
+
async select<T>(
|
|
209
|
+
input: unknown,
|
|
210
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
211
|
+
options: SmartRouterSelectOptions = {},
|
|
212
|
+
): Promise<SmartRouterSelection<T>> {
|
|
213
|
+
const task = this.context.build(input);
|
|
214
|
+
if (!task) throw new SmartRouterError(400, "Smart Router input contains no routable text");
|
|
215
|
+
const client = options.client ?? getExecutionContext().client;
|
|
216
|
+
try {
|
|
217
|
+
const selection = await this.selectTask(task, candidates, { ...options, client });
|
|
218
|
+
this.setProbeValue(candidates, client, true);
|
|
219
|
+
return selection;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
this.setProbeUnavailable(candidates, client);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Return a memoized live capability probe for the active identity and candidate menu. */
|
|
227
|
+
async isEnabled<T>(
|
|
228
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
229
|
+
client = getExecutionContext().client,
|
|
230
|
+
): Promise<boolean> {
|
|
231
|
+
const offered = uniqueCandidates(candidates);
|
|
232
|
+
if (offered.length === 0) return false;
|
|
233
|
+
const cache = this.probeCache(client);
|
|
234
|
+
const key = probeKey(offered, this.settings.routerName);
|
|
235
|
+
this.rememberProbeKey(client, key);
|
|
236
|
+
const probe = await getOrSet(
|
|
237
|
+
cache,
|
|
238
|
+
key,
|
|
239
|
+
() => this.loadProbe(key, offered, client),
|
|
240
|
+
{
|
|
241
|
+
ttl: this.probeCacheTtlMs(),
|
|
242
|
+
refreshAfterWrite: (value) => this.probeRefreshAfterWrite(value.enabled),
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
return probe?.enabled ?? false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Stop process-local probe caches after their active refreshes settle. */
|
|
249
|
+
dispose(): void {
|
|
250
|
+
for (const cache of this.probeCaches) void closeCache(cache);
|
|
251
|
+
this.probeCaches.clear();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async loadProbe<T>(
|
|
255
|
+
key: string,
|
|
256
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
257
|
+
client: WorkspaceClient,
|
|
258
|
+
): Promise<SmartRouterProbeRecord> {
|
|
259
|
+
const stored = await this.readStoredProbe(key, client);
|
|
260
|
+
if (stored && stored.expiresAt > Date.now()) return stored;
|
|
261
|
+
return this.executeProbe(key, candidates, client);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async executeProbe<T>(
|
|
265
|
+
key: string,
|
|
266
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
267
|
+
client: WorkspaceClient,
|
|
268
|
+
): Promise<SmartRouterProbeRecord> {
|
|
269
|
+
const enabled = await this.selectTask(PROBE_TASK, candidates, { client })
|
|
270
|
+
.then(() => true)
|
|
271
|
+
.catch((error) => {
|
|
272
|
+
logger.debug("Smart Router capability probe unavailable", { error: toError(error) });
|
|
273
|
+
return false;
|
|
274
|
+
});
|
|
275
|
+
const probe = this.probeRecord(enabled);
|
|
276
|
+
void this.writeStoredProbe(key, probe, client);
|
|
277
|
+
return probe;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private setProbeValue<T>(
|
|
281
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
282
|
+
client: WorkspaceClient,
|
|
283
|
+
enabled: boolean,
|
|
284
|
+
): void {
|
|
285
|
+
const offered = uniqueCandidates(candidates);
|
|
286
|
+
if (offered.length === 0) return;
|
|
287
|
+
const cache = this.probeCache(client);
|
|
288
|
+
const key = probeKey(offered, this.settings.routerName);
|
|
289
|
+
this.rememberProbeKey(client, key);
|
|
290
|
+
const probe = this.probeRecord(enabled);
|
|
291
|
+
void this.writeProcessProbe(cache, key, probe);
|
|
292
|
+
void this.writeStoredProbe(key, probe, client);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private setProbeUnavailable<T>(
|
|
296
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
297
|
+
client: WorkspaceClient,
|
|
298
|
+
): void {
|
|
299
|
+
const cache = this.probeCache(client);
|
|
300
|
+
const offered = uniqueCandidates(candidates);
|
|
301
|
+
const requestedKey = probeKey(offered, this.settings.routerName);
|
|
302
|
+
this.rememberProbeKey(client, requestedKey);
|
|
303
|
+
const keys = this.probeKeys.get(client.config as object) ?? new Set([requestedKey]);
|
|
304
|
+
for (const key of keys) {
|
|
305
|
+
const probe = this.probeRecord(false);
|
|
306
|
+
void this.writeProcessProbe(cache, key, probe);
|
|
307
|
+
void this.writeStoredProbe(key, probe, client);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private probeRecord(enabled: boolean): SmartRouterProbeRecord {
|
|
312
|
+
return { enabled, expiresAt: Date.now() + this.probeTtl(enabled) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async readStoredProbe(
|
|
316
|
+
key: string,
|
|
317
|
+
client: WorkspaceClient,
|
|
318
|
+
): Promise<SmartRouterProbeRecord | undefined> {
|
|
319
|
+
try {
|
|
320
|
+
const parsed = SmartRouterProbeRecordSchema.safeParse(await this.probeStore.get(key, client));
|
|
321
|
+
return parsed.success ? parsed.data : undefined;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
logger.debug("Smart Router probe cache read failed", { error: toError(error) });
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private async writeProcessProbe(
|
|
329
|
+
cache: SmartRouterProbeCache,
|
|
330
|
+
key: string,
|
|
331
|
+
probe: SmartRouterProbeRecord,
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
try {
|
|
334
|
+
await cache.set(key, probe, { ttl: this.probeCacheTtlMs() });
|
|
335
|
+
} catch (error) {
|
|
336
|
+
logger.debug("Smart Router process probe cache write failed", { error: toError(error) });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async writeStoredProbe(
|
|
341
|
+
key: string,
|
|
342
|
+
probe: SmartRouterProbeRecord,
|
|
343
|
+
client: WorkspaceClient,
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
try {
|
|
346
|
+
await this.probeStore.set(
|
|
347
|
+
key,
|
|
348
|
+
probe,
|
|
349
|
+
Math.max(1, Math.ceil(this.probeCacheTtlMs() / 1_000)),
|
|
350
|
+
client,
|
|
351
|
+
);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
logger.debug("Smart Router probe cache write failed", { error: toError(error) });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private probeCache(client: WorkspaceClient): SmartRouterProbeCache {
|
|
358
|
+
const owner = client.config as object;
|
|
359
|
+
let cache = this.probes.get(owner);
|
|
360
|
+
if (!cache) {
|
|
361
|
+
cache = createCache({ maxEntries: 32, useClone: false });
|
|
362
|
+
this.probes.set(owner, cache);
|
|
363
|
+
this.probeCaches.add(cache);
|
|
364
|
+
}
|
|
365
|
+
return cache;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private rememberProbeKey(client: WorkspaceClient, key: string): void {
|
|
369
|
+
const owner = client.config as object;
|
|
370
|
+
let keys = this.probeKeys.get(owner);
|
|
371
|
+
if (!keys) {
|
|
372
|
+
keys = new Set();
|
|
373
|
+
this.probeKeys.set(owner, keys);
|
|
374
|
+
}
|
|
375
|
+
keys.add(key);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private probeCacheTtlMs(): number {
|
|
379
|
+
return Math.max(this.settings.probeRefreshIntervalMs, this.settings.probeTtlMs) * 2;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private probeTtl(enabled: boolean): number {
|
|
383
|
+
return enabled
|
|
384
|
+
? this.settings.probeTtlMs
|
|
385
|
+
: Math.min(this.settings.probeTtlMs, NEGATIVE_PROBE_TTL_MS);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private probeRefreshAfterWrite(enabled: boolean): number {
|
|
389
|
+
return Math.min(this.settings.probeRefreshIntervalMs, this.probeTtl(enabled));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async selectTask<T>(
|
|
393
|
+
task: string,
|
|
394
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
395
|
+
options: SmartRouterSelectOptions,
|
|
396
|
+
): Promise<SmartRouterSelection<T>> {
|
|
397
|
+
const offered = uniqueCandidates(candidates);
|
|
398
|
+
if (offered.length === 0) {
|
|
399
|
+
throw new SmartRouterError(400, "No compatible system models are available to Smart Router");
|
|
400
|
+
}
|
|
401
|
+
const request = SmartRouterRequestSchema.parse({
|
|
402
|
+
route_options: offered.map(({ model, harness }) => ({ model, harness })),
|
|
403
|
+
task: { prompt: task },
|
|
404
|
+
route_selector: { router_name: this.settings.routerName },
|
|
405
|
+
});
|
|
406
|
+
const client = options.client ?? getExecutionContext().client;
|
|
407
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
408
|
+
const startedAt = Date.now();
|
|
409
|
+
const response = SmartRouterResponseSchema.parse(
|
|
410
|
+
await this.invoke(client, request, signal, this.settings.timeoutMs),
|
|
411
|
+
);
|
|
412
|
+
const selection = response.route_selection[0]!;
|
|
413
|
+
const selected = offered.find(
|
|
414
|
+
(candidate) =>
|
|
415
|
+
normalizeRouteModel(candidate.model) ===
|
|
416
|
+
normalizeRouteModel(selection.route_option.model) &&
|
|
417
|
+
(!selection.route_option.harness || candidate.harness === selection.route_option.harness),
|
|
418
|
+
);
|
|
419
|
+
if (!selected) {
|
|
420
|
+
throw new SmartRouterError(
|
|
421
|
+
502,
|
|
422
|
+
`Smart Router selected an unsupported model: ${selection.route_option.model}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
value: selected.value,
|
|
427
|
+
routeModel: selection.route_option.model,
|
|
428
|
+
harness: selected.harness,
|
|
429
|
+
routerName: this.settings.routerName,
|
|
430
|
+
latencyMs: Date.now() - startedAt,
|
|
431
|
+
...(response.rationale ? { rationale: response.rationale } : {}),
|
|
432
|
+
...(selection.params ? { params: selection.params } : {}),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Build a Smart Router candidate menu from values carrying modelServiceName. */
|
|
438
|
+
export function smartRouterCandidates<T extends { readonly modelServiceName?: string }>(
|
|
439
|
+
values: readonly T[],
|
|
440
|
+
harness: SmartRouterHarness,
|
|
441
|
+
): SmartRouterCandidate<T>[] {
|
|
442
|
+
return values.flatMap((value) => {
|
|
443
|
+
const model = value.modelServiceName?.trim();
|
|
444
|
+
if (!model || !model.toLowerCase().startsWith("system.ai.")) return [];
|
|
445
|
+
return [{ model, harness, value }];
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Context extractor shared by the gateway, Mastra, and the file/stdin CLI.
|
|
451
|
+
* It favors conversation/task fields and ignores credentials, tool schemas,
|
|
452
|
+
* and tool arguments.
|
|
453
|
+
*/
|
|
454
|
+
export class SmartRouterContextBuilder {
|
|
455
|
+
constructor(private readonly tokenBudget = 4_096) {}
|
|
456
|
+
|
|
457
|
+
/** Return only the bounded task prompt required by routes:select. */
|
|
458
|
+
build(input: unknown): string {
|
|
459
|
+
return this.analyze(input).task;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Return bounded task text plus logical-turn metadata used for pinning. */
|
|
463
|
+
analyze(input: unknown): SmartRouterContext {
|
|
464
|
+
const normalized = parseJsonString(input);
|
|
465
|
+
const events = conversationEvents(normalized);
|
|
466
|
+
if (events.length > 0) return contextFromEvents(events, this.tokenBudget);
|
|
467
|
+
const values = collectTaskStrings(normalized);
|
|
468
|
+
const task = truncateText(values.join("\n"), this.tokenBudget, "start");
|
|
469
|
+
return { task, logicalTurn: task, continuation: false };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Smart Router orchestration shared by Responses, Chat Completions, and Messages. */
|
|
474
|
+
export class SmartModelRouter {
|
|
475
|
+
readonly service: SmartRouterService;
|
|
476
|
+
private readonly settings: SmartRouterSettings;
|
|
477
|
+
|
|
478
|
+
constructor(
|
|
479
|
+
settings: Partial<SmartRouterSettings> = {},
|
|
480
|
+
service: SmartRouterService | undefined = undefined,
|
|
481
|
+
private readonly store: SmartRouterStore = new AppKitSmartRouterStore(),
|
|
482
|
+
private readonly client?: WorkspaceClient,
|
|
483
|
+
) {
|
|
484
|
+
this.settings = { ...SMART_ROUTER_DEFAULT_SETTINGS, ...settings };
|
|
485
|
+
this.service = service ?? new SmartRouterService(this.settings);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Probe whether Smart Router is usable for the current model catalogue. */
|
|
489
|
+
isEnabled(targets: readonly ModelTarget[]): Promise<boolean> {
|
|
490
|
+
const candidates = smartRouterCandidates(
|
|
491
|
+
targets.filter((target) => target.capabilities.chat && target.capabilities.aiGatewayCodex),
|
|
492
|
+
"codex",
|
|
493
|
+
);
|
|
494
|
+
return this.client
|
|
495
|
+
? this.service.isEnabled(candidates, this.client)
|
|
496
|
+
: this.service.isEnabled(candidates);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Resolve one virtual smart-router request to a concrete compatible target. */
|
|
500
|
+
async resolve(input: {
|
|
501
|
+
readonly body: Readonly<Record<string, unknown>>;
|
|
502
|
+
readonly features: RequestedFeatures;
|
|
503
|
+
readonly headers: Headers;
|
|
504
|
+
readonly preferSystemModels: boolean;
|
|
505
|
+
readonly protocol: Exclude<ClientProtocol, "openai-embeddings">;
|
|
506
|
+
readonly signal: AbortSignal;
|
|
507
|
+
readonly targets: readonly ModelTarget[];
|
|
508
|
+
}): Promise<SmartRouterResult> {
|
|
509
|
+
const harness: SmartRouterHarness =
|
|
510
|
+
input.protocol === "anthropic-messages" ? "claude" : "codex";
|
|
511
|
+
const explicitReasoningEffort = requestedReasoningEffort(input.body);
|
|
512
|
+
const eligibleTargets = input.targets.filter((target) =>
|
|
513
|
+
isEligibleTarget(
|
|
514
|
+
target,
|
|
515
|
+
{ ...input, originator: input.headers.get("originator") ?? undefined },
|
|
516
|
+
explicitReasoningEffort,
|
|
517
|
+
harness,
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
const candidates = smartRouterCandidates(eligibleTargets, harness);
|
|
521
|
+
if (candidates.length === 0) {
|
|
522
|
+
throw new SmartRouterError(
|
|
523
|
+
400,
|
|
524
|
+
"No compatible system models are available to Databricks Smart Router",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const context = this.service.context.analyze(input.body);
|
|
529
|
+
if (!context.task) {
|
|
530
|
+
throw new SmartRouterError(400, "Smart Router request contains no task text");
|
|
531
|
+
}
|
|
532
|
+
const key = smartRouterTurnKey(context.logicalTurn, candidates, {
|
|
533
|
+
harness,
|
|
534
|
+
protocol: input.protocol,
|
|
535
|
+
features: input.features,
|
|
536
|
+
...(explicitReasoningEffort ? { explicitReasoningEffort } : {}),
|
|
537
|
+
});
|
|
538
|
+
const cached = context.continuation ? await this.readCached(key, eligibleTargets) : undefined;
|
|
539
|
+
if (cached) {
|
|
540
|
+
return {
|
|
541
|
+
target: eligibleTargets.find((target) => target.id === cached.selectedModel)!,
|
|
542
|
+
body: input.body,
|
|
543
|
+
observation: { ...cached, source: "cache" },
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const selected = await this.service.select(context.task, candidates, {
|
|
548
|
+
signal: input.signal,
|
|
549
|
+
...(this.client ? { client: this.client } : {}),
|
|
550
|
+
});
|
|
551
|
+
const target = selected.value;
|
|
552
|
+
const observation = SmartRouterObservationSchema.parse({
|
|
553
|
+
selectedModel: target.id,
|
|
554
|
+
routeModel: selected.routeModel,
|
|
555
|
+
harness: selected.harness,
|
|
556
|
+
routerName: selected.routerName,
|
|
557
|
+
source: "router",
|
|
558
|
+
latencyMs: selected.latencyMs,
|
|
559
|
+
...(selected.rationale ? { rationale: selected.rationale } : {}),
|
|
560
|
+
...(selected.params ? { params: selected.params } : {}),
|
|
561
|
+
});
|
|
562
|
+
await this.writeCached(key, observation);
|
|
563
|
+
logger.debug("selected Smart Router model", {
|
|
564
|
+
candidateCount: candidates.length,
|
|
565
|
+
harness,
|
|
566
|
+
latencyMs: observation.latencyMs,
|
|
567
|
+
model: observation.selectedModel,
|
|
568
|
+
routeModel: observation.routeModel,
|
|
569
|
+
routerName: observation.routerName,
|
|
570
|
+
});
|
|
571
|
+
return { target, body: input.body, observation };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private async readCached(
|
|
575
|
+
key: string,
|
|
576
|
+
targets: readonly ModelTarget[],
|
|
577
|
+
): Promise<SmartRouterObservation | undefined> {
|
|
578
|
+
try {
|
|
579
|
+
const parsed = SmartRouterObservationSchema.safeParse(await this.store.get(key));
|
|
580
|
+
if (!parsed.success) return undefined;
|
|
581
|
+
return targets.some((target) => target.id === parsed.data.selectedModel)
|
|
582
|
+
? parsed.data
|
|
583
|
+
: undefined;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
logger.debug("Smart Router cache read failed", { error: toError(error) });
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private async writeCached(key: string, observation: SmartRouterObservation): Promise<void> {
|
|
591
|
+
try {
|
|
592
|
+
await this.store.set(key, observation, this.settings.routeTtlSeconds);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
logger.debug("Smart Router cache write failed", { error: toError(error) });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Stable logical-turn key shared by initial requests and tool continuations. */
|
|
600
|
+
export function smartRouterTurnKey(
|
|
601
|
+
logicalTurn: string,
|
|
602
|
+
candidates: readonly SmartRouterCandidate<unknown>[],
|
|
603
|
+
context: Readonly<Record<string, unknown>>,
|
|
604
|
+
): string {
|
|
605
|
+
return createHash("sha256")
|
|
606
|
+
.update(
|
|
607
|
+
JSON.stringify({
|
|
608
|
+
logicalTurn,
|
|
609
|
+
candidates: candidates.map(({ model, harness }) => ({ model, harness })),
|
|
610
|
+
context,
|
|
611
|
+
}),
|
|
612
|
+
)
|
|
613
|
+
.digest("hex");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function isEligibleTarget(
|
|
617
|
+
target: ModelTarget,
|
|
618
|
+
input: {
|
|
619
|
+
readonly features: RequestedFeatures;
|
|
620
|
+
readonly originator?: string;
|
|
621
|
+
readonly preferSystemModels: boolean;
|
|
622
|
+
readonly protocol: Exclude<ClientProtocol, "openai-embeddings">;
|
|
623
|
+
},
|
|
624
|
+
explicitReasoningEffort: ReasoningEffort | undefined,
|
|
625
|
+
harness: SmartRouterHarness,
|
|
626
|
+
): boolean {
|
|
627
|
+
if (!target.endpoint || target.endpoint.status?.deprecated === true) return false;
|
|
628
|
+
if (!target.modelServiceName?.toLowerCase().startsWith("system.ai.")) return false;
|
|
629
|
+
if (!target.capabilities.chat) return false;
|
|
630
|
+
if (harness === "codex" && !target.capabilities.aiGatewayCodex) return false;
|
|
631
|
+
if (harness === "claude" && !target.capabilities.anthropic) return false;
|
|
632
|
+
if (input.features.tools && !target.capabilities.tools) return false;
|
|
633
|
+
if (input.features.parallelTools && !target.capabilities.parallelTools) return false;
|
|
634
|
+
if (input.features.customTools && !target.capabilities.customTools) return false;
|
|
635
|
+
if (input.features.structuredOutput && !target.capabilities.structuredOutput) return false;
|
|
636
|
+
if (input.features.webSearch && !target.capabilities.webSearch) return false;
|
|
637
|
+
if (input.features.imageInput && !target.capabilities.imageInput) return false;
|
|
638
|
+
if (input.features.applyPatch && !target.capabilities.applyPatch) return false;
|
|
639
|
+
if (input.features.reasoning && !target.capabilities.reasoning) return false;
|
|
640
|
+
if (
|
|
641
|
+
explicitReasoningEffort &&
|
|
642
|
+
explicitReasoningEffort !== "none" &&
|
|
643
|
+
!target.reasoningEfforts.includes(explicitReasoningEffort)
|
|
644
|
+
) {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
resolveRoute({
|
|
649
|
+
clientProtocol: input.protocol,
|
|
650
|
+
...(input.originator ? { originator: input.originator } : {}),
|
|
651
|
+
preferSystemModels: input.preferSystemModels,
|
|
652
|
+
features: input.features,
|
|
653
|
+
target,
|
|
654
|
+
});
|
|
655
|
+
return true;
|
|
656
|
+
} catch {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function requestedReasoningEffort(
|
|
662
|
+
body: Readonly<Record<string, unknown>>,
|
|
663
|
+
): ReasoningEffort | undefined {
|
|
664
|
+
const reasoning = isRecord(body.reasoning) ? body.reasoning : undefined;
|
|
665
|
+
const outputConfig = isRecord(body.output_config) ? body.output_config : undefined;
|
|
666
|
+
const raw = body.reasoning_effort ?? reasoning?.effort ?? outputConfig?.effort;
|
|
667
|
+
if (raw === undefined || raw === null) return undefined;
|
|
668
|
+
const normalized = String(raw).trim().toLowerCase().replaceAll("_", "-");
|
|
669
|
+
const parsed = ReasoningEffortSchema.safeParse(
|
|
670
|
+
normalized === "extra-high" ? "xhigh" : normalized,
|
|
671
|
+
);
|
|
672
|
+
if (!parsed.success) {
|
|
673
|
+
throw new SmartRouterError(400, `Unsupported Smart Router reasoning effort: ${String(raw)}`);
|
|
674
|
+
}
|
|
675
|
+
return parsed.data;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
interface ConversationEvent {
|
|
679
|
+
readonly role: "assistant" | "system" | "tool" | "user";
|
|
680
|
+
readonly text: string;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function contextFromEvents(
|
|
684
|
+
events: readonly ConversationEvent[],
|
|
685
|
+
tokenBudget: number,
|
|
686
|
+
): SmartRouterContext {
|
|
687
|
+
const latestUserIndex = lastEventIndex(events, "user");
|
|
688
|
+
const taskEvent = latestUserIndex >= 0 ? events[latestUserIndex] : events.at(-1);
|
|
689
|
+
const task = truncateText(taskEvent?.text ?? "", tokenBudget, "start");
|
|
690
|
+
let remaining = Math.max(0, tokenBudget - countTokens(task));
|
|
691
|
+
const context: string[] = [];
|
|
692
|
+
for (
|
|
693
|
+
let index = (taskEvent ? events.indexOf(taskEvent) : events.length) - 1;
|
|
694
|
+
index >= 0;
|
|
695
|
+
index -= 1
|
|
696
|
+
) {
|
|
697
|
+
const event = events[index];
|
|
698
|
+
if (!event || !event.text || event.role === "tool") continue;
|
|
699
|
+
const value = `${event.role}: ${event.text}`;
|
|
700
|
+
const retained = truncateText(value, remaining, "end");
|
|
701
|
+
if (!retained) break;
|
|
702
|
+
context.unshift(retained);
|
|
703
|
+
remaining -= countTokens(retained);
|
|
704
|
+
}
|
|
705
|
+
const combined = [...context, task].filter(Boolean).join("\n");
|
|
706
|
+
const logicalEvents = latestUserIndex >= 0 ? events.slice(0, latestUserIndex + 1) : events;
|
|
707
|
+
return {
|
|
708
|
+
task: combined,
|
|
709
|
+
logicalTurn: logicalEvents.map((event) => `${event.role}:${event.text}`).join("\n"),
|
|
710
|
+
continuation:
|
|
711
|
+
latestUserIndex >= 0 &&
|
|
712
|
+
events.slice(latestUserIndex + 1).some((event) => event.role !== "system"),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function conversationEvents(input: unknown): ConversationEvent[] {
|
|
717
|
+
if (typeof input === "string") return input.trim() ? [{ role: "user", text: input.trim() }] : [];
|
|
718
|
+
if (Array.isArray(input)) return input.flatMap(messageEvent);
|
|
719
|
+
if (!isRecord(input)) return [];
|
|
720
|
+
for (const key of ["messages", "input"] as const) {
|
|
721
|
+
const value = input[key];
|
|
722
|
+
if (typeof value === "string" && value.trim()) return [{ role: "user", text: value.trim() }];
|
|
723
|
+
if (Array.isArray(value)) {
|
|
724
|
+
const events = value.flatMap(messageEvent);
|
|
725
|
+
if (events.length > 0) return events;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return [];
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function messageEvent(value: unknown): ConversationEvent[] {
|
|
732
|
+
if (typeof value === "string") return value.trim() ? [{ role: "user", text: value.trim() }] : [];
|
|
733
|
+
if (!isRecord(value)) return [];
|
|
734
|
+
const role = messageRole(value);
|
|
735
|
+
if (!role) return [];
|
|
736
|
+
const text = contentText(value.content ?? value.text ?? value.input_text ?? value.output_text);
|
|
737
|
+
return text ? [{ role, text }] : [];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function messageRole(
|
|
741
|
+
value: Readonly<Record<string, unknown>>,
|
|
742
|
+
): ConversationEvent["role"] | undefined {
|
|
743
|
+
if (value.role === "user" || value.role === "assistant" || value.role === "system") {
|
|
744
|
+
return value.role;
|
|
745
|
+
}
|
|
746
|
+
if (
|
|
747
|
+
value.role === "tool" ||
|
|
748
|
+
value.type === "function_call_output" ||
|
|
749
|
+
value.type === "tool_result"
|
|
750
|
+
) {
|
|
751
|
+
return "tool";
|
|
752
|
+
}
|
|
753
|
+
if (value.type === "function_call" || value.type === "reasoning") return "assistant";
|
|
754
|
+
if (value.type === "message" && value.role === undefined) return "user";
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function contentText(value: unknown): string {
|
|
759
|
+
if (typeof value === "string") return value.trim();
|
|
760
|
+
if (Array.isArray(value)) return value.map(contentText).filter(Boolean).join("\n");
|
|
761
|
+
if (!isRecord(value)) return "";
|
|
762
|
+
for (const key of ["text", "input_text", "output_text", "content"] as const) {
|
|
763
|
+
const text = contentText(value[key]);
|
|
764
|
+
if (text) return text;
|
|
765
|
+
}
|
|
766
|
+
return "";
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const TASK_KEYS = [
|
|
770
|
+
"prompt",
|
|
771
|
+
"task",
|
|
772
|
+
"query",
|
|
773
|
+
"question",
|
|
774
|
+
"request",
|
|
775
|
+
"description",
|
|
776
|
+
"message",
|
|
777
|
+
"text",
|
|
778
|
+
"input",
|
|
779
|
+
] as const;
|
|
780
|
+
const IGNORED_KEYS = new Set([
|
|
781
|
+
"api_key",
|
|
782
|
+
"authorization",
|
|
783
|
+
"arguments",
|
|
784
|
+
"credentials",
|
|
785
|
+
"headers",
|
|
786
|
+
"metadata",
|
|
787
|
+
"password",
|
|
788
|
+
"secret",
|
|
789
|
+
"token",
|
|
790
|
+
"tools",
|
|
791
|
+
]);
|
|
792
|
+
|
|
793
|
+
function collectTaskStrings(input: unknown): string[] {
|
|
794
|
+
if (typeof input === "string") return input.trim() ? [input.trim()] : [];
|
|
795
|
+
const preferred: string[] = [];
|
|
796
|
+
if (isRecord(input)) {
|
|
797
|
+
for (const key of TASK_KEYS) preferred.push(...stringLeaves(input[key], key, 0));
|
|
798
|
+
}
|
|
799
|
+
return preferred.length > 0 ? preferred.slice(0, 32) : stringLeaves(input, "", 0).slice(0, 32);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function stringLeaves(value: unknown, key: string, depth: number): string[] {
|
|
803
|
+
if (depth > 6 || IGNORED_KEYS.has(key.toLowerCase())) return [];
|
|
804
|
+
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
|
|
805
|
+
if (Array.isArray(value)) return value.flatMap((item) => stringLeaves(item, key, depth + 1));
|
|
806
|
+
if (!isRecord(value)) return [];
|
|
807
|
+
return Object.entries(value).flatMap(([childKey, child]) =>
|
|
808
|
+
stringLeaves(child, childKey, depth + 1),
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function parseJsonString(input: unknown): unknown {
|
|
813
|
+
if (typeof input !== "string") return input;
|
|
814
|
+
const trimmed = input.trim();
|
|
815
|
+
if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return input;
|
|
816
|
+
try {
|
|
817
|
+
return JSON.parse(trimmed) as unknown;
|
|
818
|
+
} catch {
|
|
819
|
+
return input;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function countTokens(text: string): number {
|
|
824
|
+
return CONTEXT_ENCODING.encode(text, [], []).length;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function truncateText(text: string, tokenBudget: number, edge: "start" | "end"): string {
|
|
828
|
+
if (tokenBudget <= 0 || !text) return "";
|
|
829
|
+
const tokens = CONTEXT_ENCODING.encode(text, [], []);
|
|
830
|
+
if (tokens.length <= tokenBudget) return text;
|
|
831
|
+
return CONTEXT_ENCODING.decode(
|
|
832
|
+
edge === "start" ? tokens.slice(0, tokenBudget) : tokens.slice(-tokenBudget),
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function lastEventIndex(
|
|
837
|
+
events: readonly ConversationEvent[],
|
|
838
|
+
role: ConversationEvent["role"],
|
|
839
|
+
): number {
|
|
840
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
841
|
+
if (events[index]?.role === role) return index;
|
|
842
|
+
}
|
|
843
|
+
return -1;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function uniqueCandidates<T>(
|
|
847
|
+
candidates: readonly SmartRouterCandidate<T>[],
|
|
848
|
+
): SmartRouterCandidate<T>[] {
|
|
849
|
+
const seen = new Set<string>();
|
|
850
|
+
return candidates.filter((candidate) => {
|
|
851
|
+
const key = `${candidate.harness}:${normalizeRouteModel(candidate.model)}`;
|
|
852
|
+
if (seen.has(key)) return false;
|
|
853
|
+
seen.add(key);
|
|
854
|
+
return true;
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function normalizeRouteModel(model: string): string {
|
|
859
|
+
let normalized = model.trim().toLowerCase().split("/").at(-1) ?? "";
|
|
860
|
+
for (const prefix of ["databricks-", "system.ai."] as const) {
|
|
861
|
+
if (normalized.startsWith(prefix)) {
|
|
862
|
+
normalized = normalized.slice(prefix.length);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return normalized;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function probeKey<T>(candidates: readonly SmartRouterCandidate<T>[], routerName: string): string {
|
|
870
|
+
return JSON.stringify({
|
|
871
|
+
routerName,
|
|
872
|
+
candidates: candidates
|
|
873
|
+
.map(({ model, harness }) => ({ model: normalizeRouteModel(model), harness }))
|
|
874
|
+
.sort((left, right) =>
|
|
875
|
+
`${left.harness}:${left.model}`.localeCompare(`${right.harness}:${right.model}`),
|
|
876
|
+
),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function appKitCacheManager(): CacheManager | undefined {
|
|
881
|
+
try {
|
|
882
|
+
return CacheManager.getInstanceSync();
|
|
883
|
+
} catch {
|
|
884
|
+
return undefined;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function smartRouterProbeIdentity(client: WorkspaceClient): Promise<string> {
|
|
889
|
+
const headers = new Headers();
|
|
890
|
+
const host = (await client.config.getHost()).toString();
|
|
891
|
+
await client.config.authenticate(headers);
|
|
892
|
+
return createHash("sha256")
|
|
893
|
+
.update(host)
|
|
894
|
+
.update("\0")
|
|
895
|
+
.update(headers.get("authorization") ?? "")
|
|
896
|
+
.update("\0")
|
|
897
|
+
.update(headers.get("x-databricks-workspace-id") ?? "")
|
|
898
|
+
.digest("hex");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function invokeSmartRouter(
|
|
902
|
+
client: WorkspaceClient,
|
|
903
|
+
body: SmartRouterRequest,
|
|
904
|
+
signal: AbortSignal,
|
|
905
|
+
timeoutMs: number,
|
|
906
|
+
): Promise<unknown> {
|
|
907
|
+
const host = (await client.config.getHost()).toString();
|
|
908
|
+
const url = new URL("/ai-gateway/routing/v1/routes:select", host);
|
|
909
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
910
|
+
await client.config.authenticate(headers);
|
|
911
|
+
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
|
912
|
+
let response = await fetch(url, {
|
|
913
|
+
method: "POST",
|
|
914
|
+
headers,
|
|
915
|
+
body: JSON.stringify(body),
|
|
916
|
+
signal: combinedSignal,
|
|
917
|
+
});
|
|
918
|
+
if (response.status === 401 && (await refreshWorkspaceClientAuthentication(client))) {
|
|
919
|
+
await response.body?.cancel();
|
|
920
|
+
headers.delete("authorization");
|
|
921
|
+
headers.delete("x-databricks-workspace-id");
|
|
922
|
+
await client.config.authenticate(headers);
|
|
923
|
+
response = await fetch(url, {
|
|
924
|
+
method: "POST",
|
|
925
|
+
headers,
|
|
926
|
+
body: JSON.stringify(body),
|
|
927
|
+
signal: combinedSignal,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
if (!response.ok) {
|
|
931
|
+
const detail = (await response.text().catch(() => "")).trim().slice(0, 500);
|
|
932
|
+
throw new SmartRouterError(
|
|
933
|
+
response.status === 404 ? 404 : 503,
|
|
934
|
+
`Databricks Smart Router failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
return response.json();
|
|
938
|
+
}
|