@dbx-tools/cli-model-proxy 0.3.23 → 0.3.24

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
@@ -129,12 +129,18 @@ Use this when tests or local developer tools need a managed proxy lifecycle.
129
129
  `body.model` through [`@dbx-tools/model`](../../node/model).
130
130
  2. Request fields Databricks refuses to parse are dropped (see below).
131
131
  3. The Databricks SDK supplies a fresh authorization header for the workspace.
132
- 4. The proxy forwards the body to the resolved serving endpoint's
133
- `/invocations` route.
134
- 5. JSON or SSE response bodies are piped back unchanged.
132
+ 4. The proxy picks the upstream URL:
133
+ - Chat Completions → `/serving-endpoints/<name>/invocations`, except
134
+ Responses-only models (Codex) which are translated and posted to
135
+ `/serving-endpoints/responses`.
136
+ - Responses → `/serving-endpoints/responses` (OpenAI-family) or
137
+ `/serving-endpoints/open-responses` (Claude/Gemini/…), body forwarded
138
+ as-is.
139
+ 5. JSON or SSE response bodies are piped back (with a chat↔Responses
140
+ translation only when a chat client hit a Responses-only model).
135
141
 
136
142
  This keeps the package small: Databricks already speaks the OpenAI schema, so
137
- the useful work is auth and endpoint resolution.
143
+ the useful work is auth, endpoint resolution, and routing to the right surface.
138
144
 
139
145
  ## Unsupported Request Fields
140
146
 
@@ -150,8 +156,8 @@ deletes the known offenders first - `parallel_tool_calls` plus OpenAI-platform
150
156
  bookkeeping like `store` and `metadata` - using
151
157
  `openaiChat.stripUnsupportedChatFields` from
152
158
  [`@dbx-tools/shared-model`](../../shared/model). Anything dropped is named in
153
- the `proxy` log line. `/v1/responses` is unaffected: it builds the chat body
154
- field-by-field and never copies these through.
159
+ the `proxy` log line. `/v1/responses` is a native passthrough to Databricks'
160
+ Responses / Open Responses surface, so these chat-only field drops do not apply.
155
161
 
156
162
  Set `PROXY_DROP_FIELDS` to a comma-separated list to drop more, when a
157
163
  workspace or a new client version trips a field this package doesn't know
package/package.json CHANGED
@@ -18,16 +18,16 @@
18
18
  "@databricks/sdk-experimental": "^0.17.0",
19
19
  "commander": "^15.0.0",
20
20
  "tsx": "^4.23.0",
21
- "@dbx-tools/model": "0.3.23",
22
- "@dbx-tools/shared-core": "0.3.23",
23
- "@dbx-tools/shared-model": "0.3.23"
21
+ "@dbx-tools/model": "0.3.24",
22
+ "@dbx-tools/shared-model": "0.3.24",
23
+ "@dbx-tools/shared-core": "0.3.24"
24
24
  },
25
25
  "main": "index.ts",
26
26
  "license": "UNLICENSED",
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "version": "0.3.23",
30
+ "version": "0.3.24",
31
31
  "types": "index.ts",
32
32
  "type": "module",
33
33
  "exports": {
package/src/backend.ts CHANGED
@@ -104,8 +104,21 @@ export class DatabricksBackend {
104
104
  return invoke.authHeaders(this.client);
105
105
  }
106
106
 
107
- /** OpenAI-compatible invocations URL for a resolved endpoint id. */
107
+ /** OpenAI-compatible chat-completions invocations URL for a resolved endpoint id. */
108
108
  invocationsUrl(endpoint: string): string {
109
109
  return invoke.invocationsUrl(this.host, endpoint);
110
110
  }
111
+
112
+ /**
113
+ * Databricks Responses upstream for a resolved endpoint id
114
+ * (`/serving-endpoints/responses` for OpenAI-family, `/open-responses` else).
115
+ */
116
+ responsesUrl(endpoint: string): string {
117
+ return invoke.responsesUpstreamUrl(this.host, endpoint);
118
+ }
119
+
120
+ /** True when the endpoint rejects Chat Completions and needs Responses. */
121
+ isResponsesOnly(endpoint: string): boolean {
122
+ return invoke.isResponsesOnly(endpoint);
123
+ }
111
124
  }
package/src/server.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * OpenAI-compatible HTTP proxy in front of Databricks Model Serving.
3
3
  *
4
- * Databricks serving endpoints already speak the OpenAI wire format, so this
5
- * server is a thin pass-through: it resolves the request's (possibly fuzzy)
6
- * `model` to a real endpoint id via the {@link DatabricksBackend}, stamps a
7
- * fresh auth header, and forwards the body to that endpoint's `invocations`
8
- * URL, streaming the response straight back to the client. Any
9
- * OpenAI-compatible tool (iTerm, editors, the `openai` SDK) can point its base
10
- * URL at this server and use loose model names.
4
+ * Databricks serving endpoints speak OpenAI wire formats, so this server is a
5
+ * thin pass-through: it resolves the request's (possibly fuzzy) `model` to a
6
+ * real endpoint id via the {@link DatabricksBackend}, stamps a fresh auth
7
+ * header, and forwards to the right Databricks URL:
8
+ *
9
+ * - Chat Completions `/serving-endpoints/<name>/invocations`, except for
10
+ * Responses-only models (Codex) which are translated and sent to
11
+ * `/serving-endpoints/responses`.
12
+ * - Responses → native `/serving-endpoints/responses` (OpenAI-family) or
13
+ * `/serving-endpoints/open-responses` (Claude/Gemini/…), body forwarded
14
+ * as-is. No chat round-trip.
15
+ *
16
+ * Any OpenAI-compatible tool (iTerm, editors, the `openai` SDK, Codex CLI) can
17
+ * point its base URL at this server and use loose model names.
11
18
  *
12
19
  * Routes:
13
20
  * - `GET /health`, `GET /` liveness
@@ -18,9 +25,8 @@
18
25
  * both standard OpenAI clients and Codex can enumerate the catalogue.
19
26
  * - `POST /v1/chat/completions` proxy (also `/completions`,
20
27
  * `/v1/completions`, `/v1/embeddings`, and the un-prefixed variants)
21
- * - `POST /v1/responses` OpenAI Responses API, translated to a
22
- * chat-completions `invocations` call and back (Databricks endpoints speak
23
- * only chat completions). This is the surface the Codex CLI uses to chat.
28
+ * - `POST /v1/responses` OpenAI Responses API, forwarded to
29
+ * Databricks' native Responses / Open Responses surface.
24
30
  *
25
31
  * @module
26
32
  */
@@ -32,11 +38,11 @@ import { classify, openaiChat, openaiResponses } from "@dbx-tools/shared-model";
32
38
  import type { DatabricksBackend } from "./backend";
33
39
  import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
34
40
 
35
- const { chatToResponse, createResponsesStreamTranslator, responsesToChat } = openaiResponses;
41
+ const { chatToResponsesRequest, responseToChatCompletion } = openaiResponses;
36
42
 
37
43
  const logger = log.logger("model-proxy/server");
38
44
 
39
- /** POST routes forwarded verbatim to a serving endpoint's invocations URL. */
45
+ /** POST routes forwarded to a serving endpoint (chat or Responses-only). */
40
46
  const PROXY_PATHS = new Set([
41
47
  "/v1/chat/completions",
42
48
  "/chat/completions",
@@ -49,7 +55,7 @@ const PROXY_PATHS = new Set([
49
55
  /** GET routes that list the resolvable model catalogue. */
50
56
  const MODELS_PATHS = new Set(["/v1/models", "/models"]);
51
57
 
52
- /** POST routes that carry an OpenAI Responses API body (translated to chat). */
58
+ /** POST routes that carry an OpenAI Responses API body (native passthrough). */
53
59
  const RESPONSES_PATHS = new Set(["/v1/responses", "/responses"]);
54
60
 
55
61
  /**
@@ -206,9 +212,9 @@ async function handleModels(
206
212
  }
207
213
 
208
214
  /**
209
- * Resolve the request's model to a real endpoint, then forward the body to that
210
- * endpoint's invocations URL with fresh auth, streaming the upstream response
211
- * (SSE or JSON) straight back to the client.
215
+ * Resolve the request's model and forward a Chat Completions body. Responses-
216
+ * only models (Codex) are translated to a Responses request and posted to
217
+ * `/serving-endpoints/responses`; everything else goes to `/invocations`.
212
218
  */
213
219
  async function handleProxy(
214
220
  backend: DatabricksBackend,
@@ -223,9 +229,13 @@ async function handleProxy(
223
229
  }
224
230
 
225
231
  const resolved = await backend.resolve(requested);
226
- // Address the endpoint by URL; rewrite the body's `model` to the real id so
227
- // pay-per-token endpoints that echo it still see a valid value.
228
232
  body.model = resolved.modelId;
233
+
234
+ if (backend.isResponsesOnly(resolved.modelId)) {
235
+ await proxyChatViaResponses(backend, body, requested, resolved.modelId, resolved.matched, res);
236
+ return;
237
+ }
238
+
229
239
  // This route forwards the client's body as-is, so anything Databricks refuses
230
240
  // to parse fails the turn. Drop the known offenders rather than relaying a
231
241
  // 400 the client can do nothing about.
@@ -240,6 +250,7 @@ async function handleProxy(
240
250
  requested,
241
251
  resolved: resolved.modelId,
242
252
  matched: resolved.matched,
253
+ upstream: "invocations",
243
254
  stream: wantsStream,
244
255
  ...(dropped.length > 0 ? { dropped } : {}),
245
256
  });
@@ -258,46 +269,51 @@ async function handleProxy(
258
269
  }
259
270
 
260
271
  /**
261
- * `POST /v1/responses`: accept an OpenAI Responses request (what the Codex CLI
262
- * sends), translate it to a chat-completions call against the resolved
263
- * Databricks endpoint, and translate the reply back to the Responses shape —
264
- * streaming (SSE) or not, matching the request's `stream` flag.
272
+ * Chat Completions client Responses-only Databricks model: translate the
273
+ * request, POST to `/serving-endpoints/responses`, translate the reply back.
274
+ * Streaming is not translated yet - those clients should use `/v1/responses`.
265
275
  */
266
- async function handleResponses(
276
+ async function proxyChatViaResponses(
267
277
  backend: DatabricksBackend,
268
- req: IncomingMessage,
278
+ body: Record<string, unknown>,
279
+ requested: string,
280
+ modelId: string,
281
+ matched: boolean,
269
282
  res: ServerResponse,
270
283
  ): Promise<void> {
271
- const body = (await readJsonBody(req)) as Record<string, unknown>;
272
- const requested = typeof body.model === "string" ? body.model : undefined;
273
- if (!requested) {
274
- sendJson(res, 400, errorBody("missing 'model' in request body", "invalid_request_error"));
284
+ const { responses, stream } = chatToResponsesRequest(body);
285
+ responses.model = modelId;
286
+
287
+ if (stream) {
288
+ sendJson(
289
+ res,
290
+ 400,
291
+ errorBody(
292
+ `model ${modelId} only supports the Responses API; use POST /v1/responses for streaming`,
293
+ "invalid_request_error",
294
+ ),
295
+ );
275
296
  return;
276
297
  }
277
298
 
278
- const resolved = await backend.resolve(requested);
279
- const { chat, stream } = responsesToChat(body);
280
- chat.model = resolved.modelId; // address by URL; keep a valid echoed id
281
-
282
299
  const headers = await backend.authHeaders();
283
300
  headers["content-type"] = "application/json";
284
- headers.accept = stream ? "text/event-stream" : "application/json";
301
+ headers.accept = "application/json";
285
302
 
286
- logger.info("responses", {
303
+ logger.info("proxy", {
287
304
  requested,
288
- resolved: resolved.modelId,
289
- matched: resolved.matched,
290
- stream,
305
+ resolved: modelId,
306
+ matched,
307
+ upstream: "responses",
308
+ stream: false,
291
309
  });
292
310
 
293
- const upstream = await fetch(backend.invocationsUrl(resolved.modelId), {
311
+ const upstream = await fetch(backend.responsesUrl(modelId), {
294
312
  method: "POST",
295
313
  headers,
296
- body: JSON.stringify(chat),
314
+ body: JSON.stringify(responses),
297
315
  });
298
316
 
299
- // Upstream error: forward its status with an OpenAI-shaped body rather than
300
- // half-opening an SSE stream Codex can't parse.
301
317
  if (!upstream.ok) {
302
318
  const text = await upstream.text();
303
319
  res.writeHead(upstream.status, { "content-type": "application/json" });
@@ -305,83 +321,55 @@ async function handleResponses(
305
321
  return;
306
322
  }
307
323
 
308
- const responseId = `resp_${resolved.modelId}_${Date.now().toString(36)}`;
309
-
310
- if (!stream) {
311
- const chatJson = (await upstream.json()) as Record<string, unknown>;
312
- sendJson(res, 200, chatToResponse(chatJson, resolved.modelId));
313
- return;
314
- }
315
-
316
- // Streaming: translate the upstream chat SSE into the Responses SSE stream.
317
- res.writeHead(200, {
318
- "content-type": "text/event-stream",
319
- "cache-control": "no-cache",
320
- connection: "keep-alive",
321
- "x-resolved-model": resolved.modelId,
322
- });
323
- await translateChatSseToResponses(upstream, res, resolved.modelId, responseId);
324
+ const responseJson = (await upstream.json()) as Record<string, unknown>;
325
+ sendJson(res, 200, responseToChatCompletion(responseJson, modelId));
324
326
  }
325
327
 
326
328
  /**
327
- * Read an upstream chat-completions SSE stream and write the translated
328
- * Responses SSE stream to `res`. Parses the `data:` lines, hands each
329
- * `chat.completion.chunk` to the translator, and emits the closing
330
- * `response.completed` on the terminal `[DONE]` (or stream end).
329
+ * `POST /v1/responses`: forward the Responses body to Databricks' native
330
+ * Responses / Open Responses surface (model stays in the body; URL is
331
+ * workspace-level). Streaming and non-streaming both pass through as-is.
331
332
  */
332
- async function translateChatSseToResponses(
333
- upstream: Response,
333
+ async function handleResponses(
334
+ backend: DatabricksBackend,
335
+ req: IncomingMessage,
334
336
  res: ServerResponse,
335
- model: string,
336
- responseId: string,
337
337
  ): Promise<void> {
338
- const translator = createResponsesStreamTranslator(model, responseId);
339
- const bodyStream = upstream.body;
340
- if (!bodyStream) {
341
- res.end(translator.finish());
338
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
339
+ const requested = typeof body.model === "string" ? body.model : undefined;
340
+ if (!requested) {
341
+ sendJson(res, 400, errorBody("missing 'model' in request body", "invalid_request_error"));
342
342
  return;
343
343
  }
344
- const reader = bodyStream.getReader();
345
- const decoder = new TextDecoder();
346
- let buffer = "";
347
- let finished = false;
348
- const flushDone = () => {
349
- if (finished) return;
350
- finished = true;
351
- if (!res.writableEnded) res.write(translator.finish());
352
- };
353
- try {
354
- for (;;) {
355
- const { done, value } = await reader.read();
356
- if (done) break;
357
- buffer += decoder.decode(value, { stream: true });
358
- // SSE frames are separated by blank lines; process complete lines.
359
- let nl: number;
360
- while ((nl = buffer.indexOf("\n")) !== -1) {
361
- const line = buffer.slice(0, nl).trim();
362
- buffer = buffer.slice(nl + 1);
363
- if (!line.startsWith("data:")) continue;
364
- const payload = line.slice(5).trim();
365
- if (payload === "[DONE]") {
366
- flushDone();
367
- continue;
368
- }
369
- try {
370
- const chunk = JSON.parse(payload) as Record<string, unknown>;
371
- const out = translator.feed(chunk);
372
- if (out && !res.writableEnded) res.write(out);
373
- } catch {
374
- // Ignore keepalives / partial or non-JSON data lines.
375
- }
376
- if (res.writableEnded) break;
377
- }
378
- if (res.writableEnded) break;
379
- }
380
- } finally {
381
- await reader.cancel().catch(() => {});
382
- flushDone();
383
- res.end();
384
- }
344
+
345
+ const resolved = await backend.resolve(requested);
346
+ body.model = resolved.modelId;
347
+
348
+ const wantsStream = body.stream === true;
349
+ const headers = await backend.authHeaders();
350
+ headers["content-type"] = "application/json";
351
+ headers.accept = wantsStream ? "text/event-stream" : "application/json";
352
+
353
+ const upstreamUrl = backend.responsesUrl(resolved.modelId);
354
+ logger.info("responses", {
355
+ requested,
356
+ resolved: resolved.modelId,
357
+ matched: resolved.matched,
358
+ upstream: upstreamUrl,
359
+ stream: wantsStream,
360
+ });
361
+
362
+ const upstream = await fetch(upstreamUrl, {
363
+ method: "POST",
364
+ headers,
365
+ body: JSON.stringify(body),
366
+ });
367
+
368
+ res.writeHead(upstream.status, {
369
+ "content-type": upstream.headers.get("content-type") ?? "application/json",
370
+ "x-resolved-model": resolved.modelId,
371
+ });
372
+ await streamBody(upstream, res);
385
373
  }
386
374
 
387
375
  /** Pump an upstream `fetch` Response body to the Node response, chunk by chunk. */