@dbx-tools/cli-model-proxy 0.3.32 → 0.3.34
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 +29 -0
- package/package.json +5 -4
- package/src/server.ts +109 -16
- package/test/server.test.ts +73 -0
package/README.md
CHANGED
|
@@ -145,6 +145,35 @@ Use this when tests or local developer tools need a managed proxy lifecycle.
|
|
|
145
145
|
This keeps the package small: Databricks already speaks the OpenAI schema, so
|
|
146
146
|
the useful work is auth, endpoint resolution, and routing to the right surface.
|
|
147
147
|
|
|
148
|
+
## Timeouts And Cancellation
|
|
149
|
+
|
|
150
|
+
A proxied turn takes as long as the model behind it, so the proxy imposes **no
|
|
151
|
+
deadline of its own** in either direction. The stream ends when Databricks ends
|
|
152
|
+
it, or when the client hangs up.
|
|
153
|
+
|
|
154
|
+
That is a deliberate override of two sets of defaults that otherwise truncate
|
|
155
|
+
long turns:
|
|
156
|
+
|
|
157
|
+
- **Inbound** (client → proxy). Node's `requestTimeout` (300s) and
|
|
158
|
+
`headersTimeout` (60s) are sized for ordinary web traffic, not for holding a
|
|
159
|
+
streamed model response open. Both are set to `0`.
|
|
160
|
+
- **Upstream** (proxy → Databricks). Every upstream call goes out on an
|
|
161
|
+
`undici` `Agent` with `headersTimeout: 0` and `bodyTimeout: 0`. `bodyTimeout`
|
|
162
|
+
is the important one: it measures the gap **between** chunks, so its 300s
|
|
163
|
+
default fires on a model that pauses mid-stream - extended reasoning, a long
|
|
164
|
+
tool round trip, a slow Genie or SQL step - and tears the socket down with
|
|
165
|
+
`UND_ERR_BODY_TIMEOUT`. The client sees a stream that simply stops.
|
|
166
|
+
|
|
167
|
+
Because nothing times out, client disconnect is the backstop: each upstream
|
|
168
|
+
request carries an `AbortSignal` tied to the response, so a cancelled turn
|
|
169
|
+
(Ctrl-C, a closed tab, a killed CLI) releases the Databricks-side stream instead
|
|
170
|
+
of leaking it. If a turn should have a deadline, send one from the client - the
|
|
171
|
+
same as you would to OpenAI.
|
|
172
|
+
|
|
173
|
+
An upstream failure that happens _after_ the response headers are sent is
|
|
174
|
+
logged (`stream ended early`) rather than thrown, since the status line is
|
|
175
|
+
already committed and cannot be turned into an HTTP error.
|
|
176
|
+
|
|
148
177
|
## Unsupported Request Fields
|
|
149
178
|
|
|
150
179
|
Databricks Model Serving validates the chat body strictly, so a single
|
package/package.json
CHANGED
|
@@ -18,16 +18,17 @@
|
|
|
18
18
|
"@databricks/sdk-experimental": "^0.17.0",
|
|
19
19
|
"commander": "^15.0.0",
|
|
20
20
|
"tsx": "^4.23.0",
|
|
21
|
-
"
|
|
22
|
-
"@dbx-tools/model": "0.3.
|
|
23
|
-
"@dbx-tools/shared-
|
|
21
|
+
"undici": "^7.17.0",
|
|
22
|
+
"@dbx-tools/model": "0.3.34",
|
|
23
|
+
"@dbx-tools/shared-core": "0.3.34",
|
|
24
|
+
"@dbx-tools/shared-model": "0.3.34"
|
|
24
25
|
},
|
|
25
26
|
"main": "index.ts",
|
|
26
27
|
"license": "UNLICENSED",
|
|
27
28
|
"publishConfig": {
|
|
28
29
|
"access": "public"
|
|
29
30
|
},
|
|
30
|
-
"version": "0.3.
|
|
31
|
+
"version": "0.3.34",
|
|
31
32
|
"types": "index.ts",
|
|
32
33
|
"type": "module",
|
|
33
34
|
"exports": {
|
package/src/server.ts
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
35
35
|
import { error, json, log } from "@dbx-tools/shared-core";
|
|
36
36
|
import { classify, openaiChat, openaiResponses } from "@dbx-tools/shared-model";
|
|
37
|
+
import { Agent } from "undici";
|
|
37
38
|
|
|
38
39
|
import type { DatabricksBackend } from "./backend";
|
|
39
40
|
import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
|
|
@@ -43,6 +44,24 @@ const { chatToResponsesRequest, responseToChatCompletion, sanitizeOpenResponsesR
|
|
|
43
44
|
|
|
44
45
|
const logger = log.logger("model-proxy/server");
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Dispatcher for every upstream call, with undici's inactivity timeouts
|
|
49
|
+
* DISABLED (`0`).
|
|
50
|
+
*
|
|
51
|
+
* A proxied turn is only as fast as the model behind it, and undici's
|
|
52
|
+
* defaults (300s `headersTimeout`, 300s `bodyTimeout`) are both wrong here.
|
|
53
|
+
* `bodyTimeout` is the killer: it measures the gap BETWEEN chunks, so a model
|
|
54
|
+
* that thinks for a while mid-stream - extended reasoning, a long tool round
|
|
55
|
+
* trip, a slow Genie/SQL step - trips it and undici tears the socket down
|
|
56
|
+
* with `UND_ERR_BODY_TIMEOUT`, which the client sees as a truncated stream.
|
|
57
|
+
*
|
|
58
|
+
* The proxy has no opinion on how long a model may take, so it imposes no
|
|
59
|
+
* deadline: the stream ends when the upstream ends it, or when the client
|
|
60
|
+
* hangs up (see {@link requestAbortSignal}). Callers that DO want a deadline
|
|
61
|
+
* should send one, the same as they would to OpenAI.
|
|
62
|
+
*/
|
|
63
|
+
const upstreamDispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
|
|
64
|
+
|
|
46
65
|
/** POST routes forwarded to a serving endpoint (chat or Responses-only). */
|
|
47
66
|
const PROXY_PATHS = new Set([
|
|
48
67
|
"/v1/chat/completions",
|
|
@@ -95,9 +114,74 @@ export function createProxyServer(
|
|
|
95
114
|
backend: DatabricksBackend,
|
|
96
115
|
options: ProxyServerOptions = {},
|
|
97
116
|
): Server {
|
|
98
|
-
|
|
117
|
+
const server = createServer((req, res) => {
|
|
99
118
|
void handleRequest(backend, options, req, res);
|
|
100
119
|
});
|
|
120
|
+
// Match the upstream policy on the INBOUND side: never cut a client off
|
|
121
|
+
// mid-turn. Node's defaults (300s `requestTimeout`, 60s `headersTimeout`)
|
|
122
|
+
// are sized for ordinary web traffic, not for holding a streamed model
|
|
123
|
+
// response open, and a client that hits one gets a dead socket rather than
|
|
124
|
+
// an error it can act on. `0` disables each; the turn now ends when the
|
|
125
|
+
// model is done or the client disconnects.
|
|
126
|
+
server.requestTimeout = 0;
|
|
127
|
+
server.headersTimeout = 0;
|
|
128
|
+
server.timeout = 0;
|
|
129
|
+
return server;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Signal that aborts when the client hangs up.
|
|
134
|
+
*
|
|
135
|
+
* Removing the timeouts means nothing else will ever end a forgotten request,
|
|
136
|
+
* so the client's own disconnect becomes the only backstop against an upstream
|
|
137
|
+
* call running on with no one to receive it. Passing this to `fetch` is what
|
|
138
|
+
* makes a cancelled turn (Ctrl-C, a closed tab, a killed CLI) release the
|
|
139
|
+
* Databricks-side stream instead of leaking it for the life of the process.
|
|
140
|
+
*
|
|
141
|
+
* Keyed off the RESPONSE, not the request: `IncomingMessage` emits `close` as
|
|
142
|
+
* soon as its body has been fully consumed, which on every POST here happens
|
|
143
|
+
* before the upstream call is even made - watching that would abort each turn
|
|
144
|
+
* instantly. `ServerResponse` emits `close` when the response finishes or the
|
|
145
|
+
* connection drops, so the `writableFinished` check separates "client left"
|
|
146
|
+
* from "we replied".
|
|
147
|
+
*/
|
|
148
|
+
function clientAbortSignal(res: ServerResponse): AbortSignal {
|
|
149
|
+
const controller = new AbortController();
|
|
150
|
+
res.once("close", () => {
|
|
151
|
+
if (!res.writableFinished) controller.abort();
|
|
152
|
+
});
|
|
153
|
+
return controller.signal;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* POST a JSON body to a serving endpoint on the no-timeout
|
|
158
|
+
* {@link upstreamDispatcher}, cancelled by the client hanging up.
|
|
159
|
+
*
|
|
160
|
+
* Every upstream call goes through here so the timeout and cancellation policy
|
|
161
|
+
* is stated once. `dispatcher` is an undici extension that the DOM
|
|
162
|
+
* `RequestInit` Node compiles against does not declare, hence the local
|
|
163
|
+
* intersection type rather than a cast at each call site.
|
|
164
|
+
*/
|
|
165
|
+
async function upstreamFetch(
|
|
166
|
+
url: string,
|
|
167
|
+
headers: Record<string, string>,
|
|
168
|
+
body: unknown,
|
|
169
|
+
res: ServerResponse,
|
|
170
|
+
): Promise<Response> {
|
|
171
|
+
const init: RequestInit = {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers,
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
signal: clientAbortSignal(res),
|
|
176
|
+
};
|
|
177
|
+
// `dispatcher` is an undici extension the DOM `RequestInit` doesn't declare,
|
|
178
|
+
// and it can't simply be typed on: `@types/node` pins its own `undici-types`
|
|
179
|
+
// copy, so the `Agent` from our `undici` dependency is a structurally
|
|
180
|
+
// identical but nominally different type than the one the global `fetch`
|
|
181
|
+
// signature expects. Attaching it structurally sidesteps a version skew that
|
|
182
|
+
// no cast expresses cleanly, and `fetch` reads it at runtime all the same.
|
|
183
|
+
Reflect.set(init, "dispatcher", upstreamDispatcher);
|
|
184
|
+
return fetch(url, init);
|
|
101
185
|
}
|
|
102
186
|
|
|
103
187
|
/**
|
|
@@ -256,11 +340,12 @@ async function handleProxy(
|
|
|
256
340
|
...(dropped.length > 0 ? { dropped } : {}),
|
|
257
341
|
});
|
|
258
342
|
|
|
259
|
-
const upstream = await
|
|
260
|
-
|
|
343
|
+
const upstream = await upstreamFetch(
|
|
344
|
+
backend.invocationsUrl(resolved.modelId),
|
|
261
345
|
headers,
|
|
262
|
-
body
|
|
263
|
-
|
|
346
|
+
body,
|
|
347
|
+
res,
|
|
348
|
+
);
|
|
264
349
|
|
|
265
350
|
res.writeHead(upstream.status, {
|
|
266
351
|
"content-type": upstream.headers.get("content-type") ?? "application/json",
|
|
@@ -314,11 +399,7 @@ async function proxyChatViaResponses(
|
|
|
314
399
|
stream: false,
|
|
315
400
|
});
|
|
316
401
|
|
|
317
|
-
const upstream = await
|
|
318
|
-
method: "POST",
|
|
319
|
-
headers,
|
|
320
|
-
body: JSON.stringify(forward),
|
|
321
|
-
});
|
|
402
|
+
const upstream = await upstreamFetch(upstreamUrl, headers, forward, res);
|
|
322
403
|
|
|
323
404
|
if (!upstream.ok) {
|
|
324
405
|
const text = await upstream.text();
|
|
@@ -383,11 +464,7 @@ async function handleResponses(
|
|
|
383
464
|
...(stripped ? { strippedNonFunctionTools: true } : {}),
|
|
384
465
|
});
|
|
385
466
|
|
|
386
|
-
const upstream = await
|
|
387
|
-
method: "POST",
|
|
388
|
-
headers,
|
|
389
|
-
body: JSON.stringify(forward),
|
|
390
|
-
});
|
|
467
|
+
const upstream = await upstreamFetch(upstreamUrl, headers, forward, res);
|
|
391
468
|
|
|
392
469
|
res.writeHead(upstream.status, {
|
|
393
470
|
"content-type": upstream.headers.get("content-type") ?? "application/json",
|
|
@@ -396,7 +473,16 @@ async function handleResponses(
|
|
|
396
473
|
await streamBody(upstream, res);
|
|
397
474
|
}
|
|
398
475
|
|
|
399
|
-
/**
|
|
476
|
+
/**
|
|
477
|
+
* Pump an upstream `fetch` Response body to the Node response, chunk by chunk.
|
|
478
|
+
*
|
|
479
|
+
* A mid-stream upstream failure is logged rather than thrown: the status line
|
|
480
|
+
* and some number of chunks have already gone out, so there is no way left to
|
|
481
|
+
* turn it into an HTTP error and the only honest move is to end the response
|
|
482
|
+
* and say why in the log. Without this the read loop rejected into the caller's
|
|
483
|
+
* catch, which found `headersSent` and ended the response silently - making an
|
|
484
|
+
* upstream teardown indistinguishable from a clean finish.
|
|
485
|
+
*/
|
|
400
486
|
async function streamBody(upstream: Response, res: ServerResponse): Promise<void> {
|
|
401
487
|
const body = upstream.body;
|
|
402
488
|
if (!body) {
|
|
@@ -411,6 +497,13 @@ async function streamBody(upstream: Response, res: ServerResponse): Promise<void
|
|
|
411
497
|
if (value && !res.writableEnded) res.write(Buffer.from(value));
|
|
412
498
|
if (res.writableEnded) break;
|
|
413
499
|
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
// An aborted request is the expected shape of a client hanging up, not a
|
|
502
|
+
// fault worth reporting at error level.
|
|
503
|
+
const aborted = res.writableEnded || !res.writable;
|
|
504
|
+
logger[aborted ? "info" : "error"]("stream ended early", {
|
|
505
|
+
error: error.errorMessage(err),
|
|
506
|
+
});
|
|
414
507
|
} finally {
|
|
415
508
|
await reader.cancel().catch(() => {});
|
|
416
509
|
res.end();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
|
|
5
|
+
import type { DatabricksBackend } from "../src/backend";
|
|
6
|
+
import { createProxyServer } from "../src/server";
|
|
7
|
+
|
|
8
|
+
/** Listen on an ephemeral port and resolve the bound port. */
|
|
9
|
+
async function listen(server: Server): Promise<number> {
|
|
10
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
11
|
+
const address = server.address();
|
|
12
|
+
assert.ok(typeof address === "object" && address);
|
|
13
|
+
return address.port;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("createProxyServer timeouts", () => {
|
|
17
|
+
it("disables every inbound timeout that could cut a stream short", () => {
|
|
18
|
+
const server = createProxyServer({} as DatabricksBackend);
|
|
19
|
+
// Node's defaults (300s request, 60s headers) are sized for ordinary web
|
|
20
|
+
// traffic and would kill a long model turn mid-stream.
|
|
21
|
+
assert.equal(server.requestTimeout, 0);
|
|
22
|
+
assert.equal(server.headersTimeout, 0);
|
|
23
|
+
assert.equal(server.timeout, 0);
|
|
24
|
+
server.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("differs from a stock node server, so the override is doing the work", () => {
|
|
28
|
+
const stock = createServer();
|
|
29
|
+
assert.notEqual(stock.requestTimeout, 0);
|
|
30
|
+
assert.notEqual(stock.headersTimeout, 0);
|
|
31
|
+
stock.close();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("upstream dispatcher", () => {
|
|
36
|
+
/**
|
|
37
|
+
* Serve one chunk, stall past the supplied body timeout, then finish. Proves
|
|
38
|
+
* `bodyTimeout` measures the gap BETWEEN chunks - the failure mode that made
|
|
39
|
+
* long-thinking turns look like dropped streams.
|
|
40
|
+
*/
|
|
41
|
+
async function readThroughStall(bodyTimeout: number): Promise<string> {
|
|
42
|
+
const upstream = createServer((_req, res) => {
|
|
43
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
44
|
+
res.write("data: first\n\n");
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
res.write("data: second\n\n");
|
|
47
|
+
res.end();
|
|
48
|
+
}, 1_000);
|
|
49
|
+
});
|
|
50
|
+
const port = await listen(upstream);
|
|
51
|
+
try {
|
|
52
|
+
const { Agent } = await import("undici");
|
|
53
|
+
const init: RequestInit = { method: "GET" };
|
|
54
|
+
Reflect.set(init, "dispatcher", new Agent({ bodyTimeout, headersTimeout: 0 }));
|
|
55
|
+
const response = await fetch(`http://127.0.0.1:${port}/`, init);
|
|
56
|
+
let body = "";
|
|
57
|
+
for await (const chunk of response.body as AsyncIterable<Uint8Array>) {
|
|
58
|
+
body += Buffer.from(chunk).toString();
|
|
59
|
+
}
|
|
60
|
+
return body;
|
|
61
|
+
} finally {
|
|
62
|
+
upstream.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("aborts a stalled stream when bodyTimeout is set", async () => {
|
|
67
|
+
await assert.rejects(() => readThroughStall(100));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("survives the same stall with bodyTimeout disabled, as the proxy configures it", async () => {
|
|
71
|
+
assert.equal(await readThroughStall(0), "data: first\n\ndata: second\n\n");
|
|
72
|
+
});
|
|
73
|
+
});
|