@dbx-tools/cli-model-proxy 0.3.23 → 0.3.25
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 +12 -6
- package/package.json +4 -4
- package/src/backend.ts +14 -1
- package/src/cli.ts +24 -12
- package/src/server.ts +98 -110
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
|
|
133
|
-
`/invocations
|
|
134
|
-
|
|
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
|
|
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
|
|
154
|
-
|
|
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.
|
|
22
|
-
"@dbx-tools/shared-
|
|
23
|
-
"@dbx-tools/shared-
|
|
21
|
+
"@dbx-tools/model": "0.3.25",
|
|
22
|
+
"@dbx-tools/shared-model": "0.3.25",
|
|
23
|
+
"@dbx-tools/shared-core": "0.3.25"
|
|
24
24
|
},
|
|
25
25
|
"main": "index.ts",
|
|
26
26
|
"license": "UNLICENSED",
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.3.
|
|
30
|
+
"version": "0.3.25",
|
|
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/cli.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { spawn } from "node:child_process";
|
|
22
22
|
import type { Server } from "node:http";
|
|
23
|
-
import { classify } from "@dbx-tools/shared-model";
|
|
23
|
+
import { classify, type ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
24
24
|
import { Command, CommanderError } from "commander";
|
|
25
25
|
|
|
26
26
|
import { DatabricksBackend, type BackendOptions } from "./backend";
|
|
@@ -157,15 +157,7 @@ export function buildProgram(): Command {
|
|
|
157
157
|
const opts = globalOpts<CommonOpts & { chat?: boolean }>(command);
|
|
158
158
|
const backend = await DatabricksBackend.create(backendOptions(opts));
|
|
159
159
|
const endpoints = await backend.models();
|
|
160
|
-
|
|
161
|
-
// filter on capability rather than re-deriving it from raw `task`/`class`
|
|
162
|
-
// strings. `chat` = OpenAI chat/completions + Responses (what an agent
|
|
163
|
-
// like Codex needs); `embedding` = vector endpoints; `tools` = whether the
|
|
164
|
-
// endpoint supports function/tool calls (all chat endpoints here do).
|
|
165
|
-
const enriched = endpoints.map((endpoint) => ({
|
|
166
|
-
...endpoint,
|
|
167
|
-
capabilities: classify.endpointCapabilities(endpoint),
|
|
168
|
-
}));
|
|
160
|
+
const enriched = endpoints.map(enrichEndpoint);
|
|
169
161
|
const out = opts.chat ? enriched.filter((e) => e.capabilities.chat) : enriched;
|
|
170
162
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
171
163
|
});
|
|
@@ -178,13 +170,33 @@ export function buildProgram(): Command {
|
|
|
178
170
|
).action(async (query: string[], _local: CommonOpts, command: Command) => {
|
|
179
171
|
const opts = globalOpts<CommonOpts>(command);
|
|
180
172
|
const backend = await DatabricksBackend.create(backendOptions(opts));
|
|
181
|
-
const
|
|
182
|
-
|
|
173
|
+
const search = query.join(" ");
|
|
174
|
+
const resolved = await backend.resolve(search);
|
|
175
|
+
// Same shape as one `models` entry when matched; null when nothing in the
|
|
176
|
+
// catalogue scores within the threshold (caller can fall back to the query).
|
|
177
|
+
if (!resolved.matched) {
|
|
178
|
+
process.stdout.write("null\n");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const endpoint = (await backend.models()).find((e) => e.name === resolved.modelId);
|
|
182
|
+
process.stdout.write(
|
|
183
|
+
`${JSON.stringify(endpoint ? enrichEndpoint(endpoint) : null, null, 2)}\n`,
|
|
184
|
+
);
|
|
183
185
|
});
|
|
184
186
|
|
|
185
187
|
return program;
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
/** One `models` / `resolve` list entry: endpoint summary + derived capabilities. */
|
|
191
|
+
function enrichEndpoint(endpoint: ServingEndpointSummary) {
|
|
192
|
+
return {
|
|
193
|
+
...endpoint,
|
|
194
|
+
// `chat` = OpenAI chat/completions + Responses; `embedding` = vectors;
|
|
195
|
+
// `tools` = function/tool calls (every chat endpoint here).
|
|
196
|
+
capabilities: classify.endpointCapabilities(endpoint),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
/** Parse `argv` and run the matching command. Throws {@link CommanderError} on flag errors. */
|
|
189
201
|
export async function runCli(argv: string[]): Promise<void> {
|
|
190
202
|
await buildProgram().parseAsync(argv);
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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,
|
|
22
|
-
*
|
|
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 {
|
|
41
|
+
const { chatToResponsesRequest, responseToChatCompletion } = openaiResponses;
|
|
36
42
|
|
|
37
43
|
const logger = log.logger("model-proxy/server");
|
|
38
44
|
|
|
39
|
-
/** POST routes forwarded
|
|
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 (
|
|
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
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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
|
|
276
|
+
async function proxyChatViaResponses(
|
|
267
277
|
backend: DatabricksBackend,
|
|
268
|
-
|
|
278
|
+
body: Record<string, unknown>,
|
|
279
|
+
requested: string,
|
|
280
|
+
modelId: string,
|
|
281
|
+
matched: boolean,
|
|
269
282
|
res: ServerResponse,
|
|
270
283
|
): Promise<void> {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 =
|
|
301
|
+
headers.accept = "application/json";
|
|
285
302
|
|
|
286
|
-
logger.info("
|
|
303
|
+
logger.info("proxy", {
|
|
287
304
|
requested,
|
|
288
|
-
resolved:
|
|
289
|
-
matched
|
|
290
|
-
|
|
305
|
+
resolved: modelId,
|
|
306
|
+
matched,
|
|
307
|
+
upstream: "responses",
|
|
308
|
+
stream: false,
|
|
291
309
|
});
|
|
292
310
|
|
|
293
|
-
const upstream = await fetch(backend.
|
|
311
|
+
const upstream = await fetch(backend.responsesUrl(modelId), {
|
|
294
312
|
method: "POST",
|
|
295
313
|
headers,
|
|
296
|
-
body: JSON.stringify(
|
|
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
|
|
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
|
-
*
|
|
328
|
-
* Responses
|
|
329
|
-
*
|
|
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
|
|
333
|
-
|
|
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
|
|
339
|
-
const
|
|
340
|
-
if (!
|
|
341
|
-
res
|
|
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
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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. */
|