@dbx-tools/appkit-model-gateway 0.9.201 → 0.9.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/gateway.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
 
44
44
  import { AiDecideRouterService } from "./ai-decide.ts";
45
45
  import { listModelsPayload } from "./models.ts";
46
+ import { ProfileFallback, type ModelGatewayProfile } from "./profile-fallback.ts";
46
47
  import { adaptInferenceReasoning, learnAndAdaptReasoningRetry } from "./reasoning-adapt.ts";
47
48
  import {
48
49
  DatabricksModelRegistry,
@@ -78,6 +79,7 @@ export interface ModelGatewayOptions {
78
79
  readonly model?: string;
79
80
  readonly modelClass?: ModelClassType;
80
81
  readonly preferSystemModels?: boolean;
82
+ readonly profiles?: readonly ModelGatewayProfile[];
81
83
  }
82
84
 
83
85
  /** Error carrying an HTTP status and protocol-safe message. */
@@ -100,14 +102,22 @@ export class ModelGateway {
100
102
  private readonly model?: string;
101
103
  private readonly modelClass?: ModelClassType;
102
104
  private readonly preferSystemModels: boolean;
105
+ private readonly profileFallback?: ProfileFallback;
106
+ private readonly primaryProfile?: ModelGatewayProfile;
103
107
 
104
108
  constructor(options: ModelGatewayOptions = {}, registry?: ModelRegistry) {
109
+ this.primaryProfile = options.profiles?.[0];
110
+ this.profileFallback =
111
+ options.profiles && options.profiles.length > 1
112
+ ? new ProfileFallback(options.profiles)
113
+ : undefined;
105
114
  const registryOptions: ModelRegistryOptions = {
106
115
  ...(options.cacheTtlMs !== undefined ? { ttlMs: options.cacheTtlMs } : {}),
107
116
  ...(options.overrides ? { overrides: options.overrides } : {}),
108
117
  ...(options.preferSystemModels !== undefined
109
118
  ? { preferSystemModels: options.preferSystemModels }
110
119
  : {}),
120
+ ...(this.primaryProfile ? { client: this.primaryProfile.client } : {}),
111
121
  };
112
122
  this.registry = registry ?? new DatabricksModelRegistry(registryOptions);
113
123
  this.aiDecide = options.aiDecideService ?? new AiDecideRouterService();
@@ -129,6 +139,7 @@ export class ModelGateway {
129
139
  },
130
140
  options.smartRouterService,
131
141
  options.smartRouterStore,
142
+ this.primaryProfile?.client,
132
143
  );
133
144
  }
134
145
  this.model = options.model;
@@ -174,6 +185,7 @@ export class ModelGateway {
174
185
  targets
175
186
  .map((target) => target.endpoint)
176
187
  .filter((endpoint): endpoint is ServingEndpointSummary => endpoint !== undefined),
188
+ this.primaryProfile?.client,
177
189
  ));
178
190
  if (this.smartRouterMode === "true" && !smartRouterEnabled) {
179
191
  throw new ModelGatewayError(
@@ -251,7 +263,7 @@ export class ModelGateway {
251
263
  (registryTargets ?? [])
252
264
  .map((candidate) => candidate.endpoint)
253
265
  .filter((endpoint): endpoint is ServingEndpointSummary => endpoint !== undefined),
254
- undefined,
266
+ this.primaryProfile?.client,
255
267
  {
256
268
  strategy: aiDecideStrategy(headers),
257
269
  filters: {
@@ -344,9 +356,25 @@ export class ModelGateway {
344
356
  signal: AbortSignal,
345
357
  ): Promise<Response> {
346
358
  if (route.upstreamProtocol === "ai-sdk") {
347
- return translateGatewayRequest(route, body, signal);
359
+ return translateGatewayRequest(route, body, signal, {
360
+ ...(this.primaryProfile ? { client: this.primaryProfile.client } : {}),
361
+ ...(this.profileFallback
362
+ ? { fetch: this.profileFallback.createFetch(route.upstreamModel) }
363
+ : {}),
364
+ });
365
+ }
366
+ if (this.profileFallback) {
367
+ return this.profileFallback.request(route.upstreamModel, signal, ({ client }) =>
368
+ fetchDatabricks({ route, body, headers, signal, client }),
369
+ );
348
370
  }
349
- return fetchDatabricks({ route, body, headers, signal });
371
+ return fetchDatabricks({
372
+ route,
373
+ body,
374
+ headers,
375
+ signal,
376
+ ...(this.primaryProfile ? { client: this.primaryProfile.client } : {}),
377
+ });
350
378
  }
351
379
  }
352
380
 
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Ordered Databricks profile fallback for rate-limited model requests.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type { WorkspaceClient } from "@databricks/appkit";
8
+ import { log } from "@dbx-tools/shared-core";
9
+
10
+ import { fetchWorkspace } from "./transport.ts";
11
+
12
+ const logger = log.logger("appkit/model-gateway/profile-fallback");
13
+ const GENERIC_RATE_LIMIT_COOLDOWN_MS = 15_000;
14
+ const WORKSPACE_TOKEN_COOLDOWN_MS = 60_000;
15
+
16
+ /** One configured workspace identity in fallback order. */
17
+ export interface ModelGatewayProfile {
18
+ readonly profile?: string;
19
+ readonly workspace: string;
20
+ readonly client: WorkspaceClient;
21
+ }
22
+
23
+ /** Parsed upstream rate-limit timing and diagnostic fields. */
24
+ export interface ModelGatewayRateLimit {
25
+ readonly retryAfterMs: number;
26
+ readonly source: "header" | "body" | "workspace-token-window" | "default";
27
+ readonly errorCode?: string;
28
+ readonly message?: string;
29
+ }
30
+
31
+ /** Injectable time and transport boundaries for profile-fallback tests. */
32
+ export interface ProfileFallbackDependencies {
33
+ readonly now?: () => number;
34
+ readonly sleep?: (delayMs: number, signal: AbortSignal) => Promise<void>;
35
+ readonly fetch?: typeof globalThis.fetch;
36
+ }
37
+
38
+ /** Process-local ordered profile cooldowns keyed by workspace and model. */
39
+ export class ProfileFallback {
40
+ private readonly cooldowns = new Map<string, number>();
41
+ private readonly now: () => number;
42
+ private readonly sleep: (delayMs: number, signal: AbortSignal) => Promise<void>;
43
+ private readonly fetcher: typeof globalThis.fetch;
44
+
45
+ constructor(
46
+ readonly profiles: readonly ModelGatewayProfile[],
47
+ dependencies: ProfileFallbackDependencies = {},
48
+ ) {
49
+ if (profiles.length < 2) {
50
+ throw new TypeError("Profile fallback requires at least two profiles");
51
+ }
52
+ this.now = dependencies.now ?? Date.now;
53
+ this.sleep = dependencies.sleep ?? abortableSleep;
54
+ this.fetcher = dependencies.fetch ?? globalThis.fetch.bind(globalThis);
55
+ }
56
+
57
+ /** Primary profile used for discovery and workspace-scoped routing. */
58
+ get primary(): ModelGatewayProfile {
59
+ return this.profiles[0]!;
60
+ }
61
+
62
+ /** Execute against the first available profile and cool down HTTP 429 responders. */
63
+ async request(
64
+ model: string,
65
+ signal: AbortSignal,
66
+ send: (profile: ModelGatewayProfile) => Promise<Response>,
67
+ ): Promise<Response> {
68
+ const attempted = new Set<number>();
69
+ let waitedForExistingCooldown = false;
70
+ while (true) {
71
+ const now = this.now();
72
+ const profileIndex = this.availableProfile(model, attempted, now);
73
+ if (profileIndex === undefined) {
74
+ if (attempted.size > 0 || waitedForExistingCooldown) {
75
+ throw new Error("Profile fallback exhausted without an upstream response");
76
+ }
77
+ const delayMs = this.nextCooldownDelay(model, now);
78
+ if (delayMs === undefined) {
79
+ throw new Error("Profile fallback has no available profile");
80
+ }
81
+ waitedForExistingCooldown = true;
82
+ await this.sleep(delayMs, signal);
83
+ continue;
84
+ }
85
+
86
+ attempted.add(profileIndex);
87
+ const profile = this.profiles[profileIndex]!;
88
+ const response = await send(profile);
89
+ if (response.status !== 429) {
90
+ this.cooldowns.delete(this.key(profile.workspace, model));
91
+ if (profileIndex > 0) {
92
+ logger.info("model request completed through fallback profile", {
93
+ model,
94
+ profile: profile.profile ?? "default",
95
+ profileIndex,
96
+ });
97
+ }
98
+ return response;
99
+ }
100
+
101
+ const rateLimit = await modelGatewayRateLimit(response, this.now());
102
+ this.cooldowns.set(this.key(profile.workspace, model), this.now() + rateLimit.retryAfterMs);
103
+ logger.warn("model profile rate limited", {
104
+ errorCode: rateLimit.errorCode,
105
+ upstreamMessage: rateLimit.message,
106
+ model,
107
+ profile: profile.profile ?? "default",
108
+ profileIndex,
109
+ retryAfterMs: rateLimit.retryAfterMs,
110
+ retryAfterSource: rateLimit.source,
111
+ workspace: profile.workspace,
112
+ });
113
+
114
+ if (this.availableProfile(model, attempted, this.now()) === undefined) {
115
+ return response;
116
+ }
117
+ await response.body?.cancel().catch(() => undefined);
118
+ }
119
+ }
120
+
121
+ /** Build an AI SDK fetch that replays a request through the ordered profile pool. */
122
+ createFetch(model: string): typeof globalThis.fetch {
123
+ return (async (input, init) => {
124
+ const request = new Request(input, init);
125
+ const body =
126
+ request.method === "GET" || request.method === "HEAD"
127
+ ? undefined
128
+ : await request.arrayBuffer();
129
+ const originalUrl = new URL(request.url);
130
+ const originalHeaders = new Headers(request.headers);
131
+ return this.request(model, request.signal, async ({ client, workspace }) => {
132
+ const url = new URL(`${originalUrl.pathname}${originalUrl.search}`, workspace);
133
+ return fetchWorkspace(
134
+ client,
135
+ url,
136
+ {
137
+ method: request.method,
138
+ headers: originalHeaders,
139
+ body,
140
+ signal: request.signal,
141
+ },
142
+ this.fetcher,
143
+ );
144
+ });
145
+ }) as typeof globalThis.fetch;
146
+ }
147
+
148
+ private availableProfile(
149
+ model: string,
150
+ attempted: ReadonlySet<number>,
151
+ now: number,
152
+ ): number | undefined {
153
+ for (let index = 0; index < this.profiles.length; index += 1) {
154
+ if (attempted.has(index)) continue;
155
+ const workspace = this.profiles[index]!.workspace;
156
+ if ((this.cooldowns.get(this.key(workspace, model)) ?? 0) <= now) return index;
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ private nextCooldownDelay(model: string, now: number): number | undefined {
162
+ const cooldowns = this.profiles
163
+ .map((profile) => this.cooldowns.get(this.key(profile.workspace, model)))
164
+ .filter((until): until is number => until !== undefined && until > now);
165
+ if (cooldowns.length === 0) return undefined;
166
+ return Math.max(0, Math.min(...cooldowns) - now);
167
+ }
168
+
169
+ private key(workspace: string, model: string): string {
170
+ return `${normalizeWorkspace(workspace)}\0${model}`;
171
+ }
172
+ }
173
+
174
+ function normalizeWorkspace(workspace: string): string {
175
+ const url = new URL(workspace);
176
+ return url.origin.toLowerCase();
177
+ }
178
+
179
+ /** Parse HTTP and Databricks JSON retry timing for a 429 response. */
180
+ export async function modelGatewayRateLimit(
181
+ response: Response,
182
+ now = Date.now(),
183
+ ): Promise<ModelGatewayRateLimit> {
184
+ const headerDelay = retryAfterHeaderMs(response.headers.get("retry-after"), now);
185
+ const body = await response
186
+ .clone()
187
+ .json()
188
+ .catch(() => undefined);
189
+ const details = rateLimitBody(body);
190
+ if (headerDelay !== undefined) {
191
+ return { ...details, retryAfterMs: headerDelay, source: "header" };
192
+ }
193
+ if (details.retryAfterMs !== undefined) {
194
+ return { ...details, retryAfterMs: details.retryAfterMs, source: "body" };
195
+ }
196
+ if (isWorkspaceTokenRateLimit(details.message)) {
197
+ return {
198
+ ...details,
199
+ retryAfterMs: WORKSPACE_TOKEN_COOLDOWN_MS,
200
+ source: "workspace-token-window",
201
+ };
202
+ }
203
+ return {
204
+ ...details,
205
+ retryAfterMs: GENERIC_RATE_LIMIT_COOLDOWN_MS,
206
+ source: "default",
207
+ };
208
+ }
209
+
210
+ function rateLimitBody(value: unknown): {
211
+ readonly retryAfterMs?: number;
212
+ readonly errorCode?: string;
213
+ readonly message?: string;
214
+ } {
215
+ const root = record(value);
216
+ const error = record(root?.error) ?? root;
217
+ const retryAfter = integer(error?.retry_after ?? root?.retry_after);
218
+ const errorCode = text(error?.error_code ?? root?.error_code);
219
+ const message =
220
+ text(error?.message ?? root?.message) ??
221
+ (typeof root?.error === "string" ? text(root.error) : undefined);
222
+ return {
223
+ ...(retryAfter !== undefined ? { retryAfterMs: retryAfter * 1_000 } : {}),
224
+ ...(errorCode ? { errorCode } : {}),
225
+ ...(message ? { message } : {}),
226
+ };
227
+ }
228
+
229
+ function retryAfterHeaderMs(value: string | null, now: number): number | undefined {
230
+ const normalized = value?.trim();
231
+ if (!normalized) return undefined;
232
+ const seconds = Number(normalized);
233
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
234
+ const timestamp = Date.parse(normalized);
235
+ return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined;
236
+ }
237
+
238
+ function isWorkspaceTokenRateLimit(message: string | undefined): boolean {
239
+ return (
240
+ message !== undefined && /exceeded workspace .*tokens per minute rate limit/i.test(message)
241
+ );
242
+ }
243
+
244
+ function integer(value: unknown): number | undefined {
245
+ const normalized = typeof value === "string" ? value.trim().replaceAll(",", "") : value;
246
+ const parsed = typeof normalized === "number" ? normalized : Number(normalized);
247
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
248
+ }
249
+
250
+ function text(value: unknown): string | undefined {
251
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
252
+ }
253
+
254
+ function record(value: unknown): Record<string, unknown> | undefined {
255
+ return value !== null && typeof value === "object" && !Array.isArray(value)
256
+ ? (value as Record<string, unknown>)
257
+ : undefined;
258
+ }
259
+
260
+ function abortableSleep(delayMs: number, signal: AbortSignal): Promise<void> {
261
+ return new Promise((resolve, reject) => {
262
+ if (signal.aborted) {
263
+ reject(signal.reason);
264
+ return;
265
+ }
266
+ const timeout = setTimeout(finish, delayMs);
267
+ function finish() {
268
+ signal.removeEventListener("abort", abort);
269
+ resolve();
270
+ }
271
+ function abort() {
272
+ clearTimeout(timeout);
273
+ reject(signal.reason);
274
+ }
275
+ signal.addEventListener("abort", abort, { once: true });
276
+ });
277
+ }
@@ -4,7 +4,7 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { getExecutionContext } from "@databricks/appkit";
7
+ import { getExecutionContext, type WorkspaceClient } from "@databricks/appkit";
8
8
  import { invoke } from "@dbx-tools/model";
9
9
  import {
10
10
  decodeGatewayRequest,
@@ -26,6 +26,10 @@ export async function translateGatewayRequest(
26
26
  route: GatewayRoute,
27
27
  body: Readonly<Record<string, unknown>>,
28
28
  signal: AbortSignal,
29
+ options: {
30
+ readonly client?: WorkspaceClient;
31
+ readonly fetch?: typeof globalThis.fetch;
32
+ } = {},
29
33
  ): Promise<Response> {
30
34
  const decoded = decodeGatewayRequest(route.clientProtocol, body);
31
35
  logger.debug("decoded request", {
@@ -35,7 +39,7 @@ export async function translateGatewayRequest(
35
39
  stream: body.stream === true,
36
40
  toolCount: Object.keys(decoded.tools).length,
37
41
  });
38
- const model = await translationModel(route);
42
+ const model = await translationModel(route, options.client, options.fetch);
39
43
  const result = streamText({
40
44
  model,
41
45
  messages: decoded.messages,
@@ -110,8 +114,11 @@ export async function translateGatewayRequest(
110
114
  );
111
115
  }
112
116
 
113
- async function translationModel(route: GatewayRoute): Promise<LanguageModel> {
114
- const client = getExecutionContext().client;
117
+ async function translationModel(
118
+ route: GatewayRoute,
119
+ client: WorkspaceClient = getExecutionContext().client,
120
+ fetcher?: typeof globalThis.fetch,
121
+ ): Promise<LanguageModel> {
115
122
  const host = (await client.config.getHost()).toString();
116
123
  const headers = await invoke.authHeaders(client);
117
124
  const protocol = translationProtocol(route);
@@ -121,6 +128,7 @@ async function translationModel(route: GatewayRoute): Promise<LanguageModel> {
121
128
  protocol,
122
129
  host,
123
130
  headers,
131
+ ...(fetcher ? { fetch: fetcher } : {}),
124
132
  });
125
133
  }
126
134
 
package/src/transport.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { getExecutionContext } from "@databricks/appkit";
7
+ import { getExecutionContext, type WorkspaceClient } from "@databricks/appkit";
8
8
  import { refreshWorkspaceClientAuthentication } from "@dbx-tools/databricks";
9
9
  import { log, toError } from "@dbx-tools/shared-core";
10
10
  import { stripUnsupportedChatFields } from "@dbx-tools/shared-model";
@@ -45,11 +45,12 @@ export interface DatabricksTransportRequest {
45
45
  readonly body: Readonly<Record<string, unknown>>;
46
46
  readonly headers: Headers;
47
47
  readonly signal: AbortSignal;
48
+ readonly client?: WorkspaceClient;
48
49
  }
49
50
 
50
51
  /** Execute a direct route with SDK-owned authentication and an unbuffered response body. */
51
52
  export async function fetchDatabricks(request: DatabricksTransportRequest): Promise<Response> {
52
- const client = getExecutionContext().client;
53
+ const client = request.client ?? getExecutionContext().client;
53
54
  const host = (await client.config.getHost()).toString();
54
55
  const headers = new Headers({ "content-type": "application/json" });
55
56
  request.headers.forEach((value, name) => {
@@ -58,7 +59,6 @@ export async function fetchDatabricks(request: DatabricksTransportRequest): Prom
58
59
  headers.set(name, value);
59
60
  }
60
61
  });
61
- await client.config.authenticate(headers);
62
62
  const url = upstreamUrl(host, request.route);
63
63
  const startedAt = Date.now();
64
64
  const body = JSON.stringify(directDatabricksRequestBody(request.route, request.body));
@@ -69,28 +69,12 @@ export async function fetchDatabricks(request: DatabricksTransportRequest): Prom
69
69
  upstreamProtocol: request.route.upstreamProtocol,
70
70
  });
71
71
  try {
72
- let response = await fetch(url, {
72
+ const response = await fetchWorkspace(client, url, {
73
73
  method: "POST",
74
74
  headers,
75
75
  body,
76
76
  signal: request.signal,
77
77
  });
78
- if (response.status === 401 && (await refreshWorkspaceClientAuthentication(client))) {
79
- logger.debug("retrying rejected upstream credential", {
80
- model: request.route.upstreamModel,
81
- upstreamProtocol: request.route.upstreamProtocol,
82
- });
83
- await response.body?.cancel();
84
- headers.delete("authorization");
85
- headers.delete("x-databricks-workspace-id");
86
- await client.config.authenticate(headers);
87
- response = await fetch(url, {
88
- method: "POST",
89
- headers,
90
- body,
91
- signal: request.signal,
92
- });
93
- }
94
78
  const details = {
95
79
  elapsedMs: Date.now() - startedAt,
96
80
  model: request.route.upstreamModel,
@@ -117,6 +101,32 @@ export async function fetchDatabricks(request: DatabricksTransportRequest): Prom
117
101
  }
118
102
  }
119
103
 
104
+ /** Execute one authenticated workspace request and refresh a rejected credential once. */
105
+ export async function fetchWorkspace(
106
+ client: WorkspaceClient,
107
+ input: string | URL,
108
+ init: RequestInit,
109
+ fetcher: typeof globalThis.fetch = globalThis.fetch.bind(globalThis),
110
+ ): Promise<Response> {
111
+ const headers = new Headers(init.headers);
112
+ headers.delete("authorization");
113
+ headers.delete("x-databricks-workspace-id");
114
+ await client.config.authenticate(headers);
115
+ let response = await fetcher(input, { ...init, headers });
116
+ if (response.status !== 401 || !(await refreshWorkspaceClientAuthentication(client))) {
117
+ return response;
118
+ }
119
+ logger.debug("retrying rejected workspace credential", {
120
+ path: new URL(input).pathname,
121
+ });
122
+ await response.body?.cancel();
123
+ headers.delete("authorization");
124
+ headers.delete("x-databricks-workspace-id");
125
+ await client.config.authenticate(headers);
126
+ response = await fetcher(input, { ...init, headers });
127
+ return response;
128
+ }
129
+
120
130
  /** Remove client compatibility fields rejected by direct Databricks protocols. */
121
131
  export function directDatabricksRequestBody(
122
132
  route: Pick<GatewayRoute, "upstreamProtocol" | "upstreamModel">,