@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/README.md +15 -1
- package/index.ts +5 -2
- package/lib/index.d.ts +5 -2
- package/lib/index.js +5 -3
- package/lib/src/gateway.d.ts +4 -0
- package/lib/src/gateway.js +29 -6
- package/lib/src/profile-fallback.d.ts +45 -0
- package/lib/src/profile-fallback.js +212 -0
- package/lib/src/translation.d.ts +5 -1
- package/lib/src/translation.js +5 -5
- package/lib/src/transport.d.ts +4 -0
- package/lib/src/transport.js +23 -20
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/gateway.ts +31 -3
- package/src/profile-fallback.ts +277 -0
- package/src/translation.ts +12 -4
- package/src/transport.ts +30 -20
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
|
-
|
|
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({
|
|
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
|
+
}
|
package/src/translation.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 { 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(
|
|
114
|
-
|
|
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
|
-
|
|
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">,
|