@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 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
- "@dbx-tools/shared-core": "0.3.32",
22
- "@dbx-tools/model": "0.3.32",
23
- "@dbx-tools/shared-model": "0.3.32"
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.32",
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
- return createServer((req, res) => {
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 fetch(backend.invocationsUrl(resolved.modelId), {
260
- method: "POST",
343
+ const upstream = await upstreamFetch(
344
+ backend.invocationsUrl(resolved.modelId),
261
345
  headers,
262
- body: JSON.stringify(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 fetch(upstreamUrl, {
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 fetch(upstreamUrl, {
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
- /** Pump an upstream `fetch` Response body to the Node response, chunk by chunk. */
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
+ });