@dbx-tools/cli-model-proxy 0.3.21

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/src/server.ts ADDED
@@ -0,0 +1,416 @@
1
+ /**
2
+ * OpenAI-compatible HTTP proxy in front of Databricks Model Serving.
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.
11
+ *
12
+ * Routes:
13
+ * - `GET /health`, `GET /` liveness
14
+ * - `GET /v1/models`, `GET /models` list resolvable endpoints. Emits the
15
+ * OpenAI `{object:"list",data:[…]}` shape by default; when the request
16
+ * looks like the Codex CLI (a `client_version` query param, which Codex
17
+ * always sends) it emits Codex's `{models:[{slug,…}]}` shape instead, so
18
+ * both standard OpenAI clients and Codex can enumerate the catalogue.
19
+ * - `POST /v1/chat/completions` proxy (also `/completions`,
20
+ * `/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.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import { error, log } from "@dbx-tools/shared-core";
29
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
30
+
31
+ import type { DatabricksBackend } from "./backend";
32
+ import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
33
+ import {
34
+ chatToResponse,
35
+ createResponsesStreamTranslator,
36
+ responsesToChat,
37
+ } from "./responses";
38
+
39
+ const logger = log.logger("model-proxy/server");
40
+
41
+ /** POST routes forwarded verbatim to a serving endpoint's invocations URL. */
42
+ const PROXY_PATHS = new Set([
43
+ "/v1/chat/completions",
44
+ "/chat/completions",
45
+ "/v1/completions",
46
+ "/completions",
47
+ "/v1/embeddings",
48
+ "/embeddings",
49
+ ]);
50
+
51
+ /** GET routes that list the resolvable model catalogue. */
52
+ const MODELS_PATHS = new Set(["/v1/models", "/models"]);
53
+
54
+ /** POST routes that carry an OpenAI Responses API body (translated to chat). */
55
+ const RESPONSES_PATHS = new Set(["/v1/responses", "/responses"]);
56
+
57
+ /** Options shared by {@link createProxyServer} and {@link startProxyServer}. */
58
+ export interface ProxyServerOptions {
59
+ /**
60
+ * When set, local clients must present this value as a bearer token
61
+ * (`Authorization: Bearer <key>`). Unset leaves the proxy open, which is fine
62
+ * for a loopback bind but should be paired with a key on a wider one.
63
+ */
64
+ apiKey?: string;
65
+ }
66
+
67
+ /** Options for {@link startProxyServer}, adding the listen address. */
68
+ export interface StartProxyOptions extends ProxyServerOptions {
69
+ host?: string;
70
+ port?: number;
71
+ }
72
+
73
+ /** Build (but do not start) the proxy HTTP server. */
74
+ export function createProxyServer(
75
+ backend: DatabricksBackend,
76
+ options: ProxyServerOptions = {},
77
+ ): Server {
78
+ return createServer((req, res) => {
79
+ void handleRequest(backend, options, req, res);
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Build and start the proxy, resolving once it is accepting connections.
85
+ * Returns the server and its base URL (with the actually-bound port, so a
86
+ * `port: 0` request surfaces the OS-assigned port).
87
+ */
88
+ export async function startProxyServer(
89
+ backend: DatabricksBackend,
90
+ options: StartProxyOptions = {},
91
+ ): Promise<{ server: Server; url: string }> {
92
+ const host = options.host ?? DEFAULT_BIND_HOST;
93
+ const port = options.port ?? DEFAULT_PORT;
94
+ const server = createProxyServer(backend, options);
95
+ await new Promise<void>((resolve, reject) => {
96
+ const onError = (err: Error) => reject(err);
97
+ server.once("error", onError);
98
+ server.listen(port, host, () => {
99
+ server.off("error", onError);
100
+ resolve();
101
+ });
102
+ });
103
+ const address = server.address();
104
+ const boundPort = typeof address === "object" && address ? address.port : port;
105
+ return { server, url: `http://${host}:${boundPort}` };
106
+ }
107
+
108
+ async function handleRequest(
109
+ backend: DatabricksBackend,
110
+ options: ProxyServerOptions,
111
+ req: IncomingMessage,
112
+ res: ServerResponse,
113
+ ): Promise<void> {
114
+ const rawUrl = req.url ?? "/";
115
+ const [path, query = ""] = rawUrl.split("?");
116
+ const routePath = path ?? "/";
117
+ try {
118
+ if (req.method === "GET" && (routePath === "/health" || routePath === "/")) {
119
+ sendJson(res, 200, { status: "ok" });
120
+ return;
121
+ }
122
+ if (options.apiKey && !isAuthorized(req, options.apiKey)) {
123
+ sendJson(res, 401, errorBody("invalid api key", "invalid_request_error"));
124
+ return;
125
+ }
126
+ if (req.method === "GET" && MODELS_PATHS.has(routePath)) {
127
+ // Codex always sends `?client_version=…`; use that to pick its list shape.
128
+ const wantsCodexShape = new URLSearchParams(query).has("client_version");
129
+ await handleModels(backend, res, wantsCodexShape);
130
+ return;
131
+ }
132
+ if (req.method === "POST" && RESPONSES_PATHS.has(routePath)) {
133
+ await handleResponses(backend, req, res);
134
+ return;
135
+ }
136
+ if (req.method === "POST" && PROXY_PATHS.has(routePath)) {
137
+ await handleProxy(backend, req, res);
138
+ return;
139
+ }
140
+ sendJson(
141
+ res,
142
+ 404,
143
+ errorBody(`unsupported route ${req.method ?? "?"} ${routePath}`, "invalid_request_error"),
144
+ );
145
+ } catch (err) {
146
+ const message = error.errorMessage(err);
147
+ logger.error("request failed", { path: routePath, error: message });
148
+ if (!res.headersSent) sendJson(res, 500, errorBody(message, "proxy_error"));
149
+ else res.end();
150
+ }
151
+ }
152
+
153
+ /**
154
+ * `GET /v1/models`: surface the serving catalogue. Two shapes:
155
+ *
156
+ * - OpenAI (default): `{object:"list", data:[{id,object,created,owned_by}]}`,
157
+ * what the `openai` SDK and most tools expect.
158
+ * - Codex (`codexShape`): `{models:[{slug, display_name, …}]}`, the ChatGPT
159
+ * backend shape the Codex CLI decodes. A response missing the top-level
160
+ * `models` field makes Codex log "failed to decode models response", so it
161
+ * gets its own envelope. Only chat endpoints are advertised to Codex.
162
+ */
163
+ async function handleModels(
164
+ backend: DatabricksBackend,
165
+ res: ServerResponse,
166
+ codexShape: boolean,
167
+ ): Promise<void> {
168
+ const endpoints = await backend.models(true);
169
+ if (codexShape) {
170
+ const models = endpoints
171
+ .filter((endpoint) => endpoint.task === "llm/v1/chat")
172
+ .map((endpoint) => ({
173
+ slug: endpoint.name,
174
+ display_name: endpoint.displayName ?? endpoint.name,
175
+ ...(endpoint.description ? { description: endpoint.description } : {}),
176
+ // Fields Codex's strict decoder requires on every model entry.
177
+ default_reasoning_level: "medium",
178
+ supported_reasoning_levels: [] as string[],
179
+ shell_type: "shell_command",
180
+ visibility: "list",
181
+ supported_in_api: true,
182
+ }));
183
+ sendJson(res, 200, { models });
184
+ return;
185
+ }
186
+ const data = endpoints.map((endpoint) => ({
187
+ id: endpoint.name,
188
+ object: "model",
189
+ created: 0,
190
+ owned_by: "databricks",
191
+ }));
192
+ sendJson(res, 200, { object: "list", data });
193
+ }
194
+
195
+ /**
196
+ * Resolve the request's model to a real endpoint, then forward the body to that
197
+ * endpoint's invocations URL with fresh auth, streaming the upstream response
198
+ * (SSE or JSON) straight back to the client.
199
+ */
200
+ async function handleProxy(
201
+ backend: DatabricksBackend,
202
+ req: IncomingMessage,
203
+ res: ServerResponse,
204
+ ): Promise<void> {
205
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
206
+ const requested = typeof body.model === "string" ? body.model : undefined;
207
+ if (!requested) {
208
+ sendJson(res, 400, errorBody("missing 'model' in request body", "invalid_request_error"));
209
+ return;
210
+ }
211
+
212
+ const resolved = await backend.resolve(requested);
213
+ // Address the endpoint by URL; rewrite the body's `model` to the real id so
214
+ // pay-per-token endpoints that echo it still see a valid value.
215
+ body.model = resolved.modelId;
216
+
217
+ const wantsStream = body.stream === true;
218
+ const headers = await backend.authHeaders();
219
+ headers["content-type"] = "application/json";
220
+ headers.accept = wantsStream ? "text/event-stream" : "application/json";
221
+
222
+ logger.info("proxy", {
223
+ requested,
224
+ resolved: resolved.modelId,
225
+ matched: resolved.matched,
226
+ stream: wantsStream,
227
+ });
228
+
229
+ const upstream = await fetch(backend.invocationsUrl(resolved.modelId), {
230
+ method: "POST",
231
+ headers,
232
+ body: JSON.stringify(body),
233
+ });
234
+
235
+ res.writeHead(upstream.status, {
236
+ "content-type": upstream.headers.get("content-type") ?? "application/json",
237
+ "x-resolved-model": resolved.modelId,
238
+ });
239
+ await streamBody(upstream, res);
240
+ }
241
+
242
+ /**
243
+ * `POST /v1/responses`: accept an OpenAI Responses request (what the Codex CLI
244
+ * sends), translate it to a chat-completions call against the resolved
245
+ * Databricks endpoint, and translate the reply back to the Responses shape —
246
+ * streaming (SSE) or not, matching the request's `stream` flag.
247
+ */
248
+ async function handleResponses(
249
+ backend: DatabricksBackend,
250
+ req: IncomingMessage,
251
+ res: ServerResponse,
252
+ ): Promise<void> {
253
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
254
+ const requested = typeof body.model === "string" ? body.model : undefined;
255
+ if (!requested) {
256
+ sendJson(res, 400, errorBody("missing 'model' in request body", "invalid_request_error"));
257
+ return;
258
+ }
259
+
260
+ const resolved = await backend.resolve(requested);
261
+ const { chat, stream } = responsesToChat(body);
262
+ chat.model = resolved.modelId; // address by URL; keep a valid echoed id
263
+
264
+ const headers = await backend.authHeaders();
265
+ headers["content-type"] = "application/json";
266
+ headers.accept = stream ? "text/event-stream" : "application/json";
267
+
268
+ logger.info("responses", {
269
+ requested,
270
+ resolved: resolved.modelId,
271
+ matched: resolved.matched,
272
+ stream,
273
+ });
274
+
275
+ const upstream = await fetch(backend.invocationsUrl(resolved.modelId), {
276
+ method: "POST",
277
+ headers,
278
+ body: JSON.stringify(chat),
279
+ });
280
+
281
+ // Upstream error: forward its status with an OpenAI-shaped body rather than
282
+ // half-opening an SSE stream Codex can't parse.
283
+ if (!upstream.ok) {
284
+ const text = await upstream.text();
285
+ res.writeHead(upstream.status, { "content-type": "application/json" });
286
+ res.end(text || JSON.stringify(errorBody("upstream error", "proxy_error")));
287
+ return;
288
+ }
289
+
290
+ const responseId = `resp_${resolved.modelId}_${Date.now().toString(36)}`;
291
+
292
+ if (!stream) {
293
+ const chatJson = (await upstream.json()) as Record<string, unknown>;
294
+ sendJson(res, 200, chatToResponse(chatJson, resolved.modelId));
295
+ return;
296
+ }
297
+
298
+ // Streaming: translate the upstream chat SSE into the Responses SSE stream.
299
+ res.writeHead(200, {
300
+ "content-type": "text/event-stream",
301
+ "cache-control": "no-cache",
302
+ connection: "keep-alive",
303
+ "x-resolved-model": resolved.modelId,
304
+ });
305
+ await translateChatSseToResponses(upstream, res, resolved.modelId, responseId);
306
+ }
307
+
308
+ /**
309
+ * Read an upstream chat-completions SSE stream and write the translated
310
+ * Responses SSE stream to `res`. Parses the `data:` lines, hands each
311
+ * `chat.completion.chunk` to the translator, and emits the closing
312
+ * `response.completed` on the terminal `[DONE]` (or stream end).
313
+ */
314
+ async function translateChatSseToResponses(
315
+ upstream: Response,
316
+ res: ServerResponse,
317
+ model: string,
318
+ responseId: string,
319
+ ): Promise<void> {
320
+ const translator = createResponsesStreamTranslator(model, responseId);
321
+ const bodyStream = upstream.body;
322
+ if (!bodyStream) {
323
+ res.end(translator.finish());
324
+ return;
325
+ }
326
+ const reader = bodyStream.getReader();
327
+ const decoder = new TextDecoder();
328
+ let buffer = "";
329
+ let finished = false;
330
+ const flushDone = () => {
331
+ if (finished) return;
332
+ finished = true;
333
+ if (!res.writableEnded) res.write(translator.finish());
334
+ };
335
+ try {
336
+ for (;;) {
337
+ const { done, value } = await reader.read();
338
+ if (done) break;
339
+ buffer += decoder.decode(value, { stream: true });
340
+ // SSE frames are separated by blank lines; process complete lines.
341
+ let nl: number;
342
+ while ((nl = buffer.indexOf("\n")) !== -1) {
343
+ const line = buffer.slice(0, nl).trim();
344
+ buffer = buffer.slice(nl + 1);
345
+ if (!line.startsWith("data:")) continue;
346
+ const payload = line.slice(5).trim();
347
+ if (payload === "[DONE]") {
348
+ flushDone();
349
+ continue;
350
+ }
351
+ try {
352
+ const chunk = JSON.parse(payload) as Record<string, unknown>;
353
+ const out = translator.feed(chunk);
354
+ if (out && !res.writableEnded) res.write(out);
355
+ } catch {
356
+ // Ignore keepalives / partial or non-JSON data lines.
357
+ }
358
+ if (res.writableEnded) break;
359
+ }
360
+ if (res.writableEnded) break;
361
+ }
362
+ } finally {
363
+ await reader.cancel().catch(() => {});
364
+ flushDone();
365
+ res.end();
366
+ }
367
+ }
368
+
369
+ /** Pump an upstream `fetch` Response body to the Node response, chunk by chunk. */
370
+ async function streamBody(upstream: Response, res: ServerResponse): Promise<void> {
371
+ const body = upstream.body;
372
+ if (!body) {
373
+ res.end();
374
+ return;
375
+ }
376
+ const reader = body.getReader();
377
+ try {
378
+ for (;;) {
379
+ const { done, value } = await reader.read();
380
+ if (done) break;
381
+ if (value && !res.writableEnded) res.write(Buffer.from(value));
382
+ if (res.writableEnded) break;
383
+ }
384
+ } finally {
385
+ await reader.cancel().catch(() => {});
386
+ res.end();
387
+ }
388
+ }
389
+
390
+ /** True when the request carries the expected bearer token. */
391
+ function isAuthorized(req: IncomingMessage, apiKey: string): boolean {
392
+ const header = req.headers.authorization;
393
+ if (!header) return false;
394
+ return header.replace(/^Bearer\s+/i, "").trim() === apiKey;
395
+ }
396
+
397
+ /** Drain a request body and parse it as JSON (empty body parses to `{}`). */
398
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
399
+ const chunks: Buffer[] = [];
400
+ for await (const chunk of req) {
401
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
402
+ }
403
+ const raw = Buffer.concat(chunks).toString("utf8");
404
+ return raw ? JSON.parse(raw) : {};
405
+ }
406
+
407
+ /** Serialize and send a JSON response. */
408
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
409
+ res.writeHead(status, { "content-type": "application/json" });
410
+ res.end(JSON.stringify(body));
411
+ }
412
+
413
+ /** OpenAI-shaped error envelope. */
414
+ function errorBody(message: string, type: string): { error: { message: string; type: string } } {
415
+ return { error: { message, type } };
416
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,43 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
2
+ {
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "lib",
6
+ "alwaysStrict": true,
7
+ "declaration": true,
8
+ "esModuleInterop": true,
9
+ "experimentalDecorators": true,
10
+ "inlineSourceMap": true,
11
+ "inlineSources": true,
12
+ "lib": [
13
+ "ES2022"
14
+ ],
15
+ "module": "ESNext",
16
+ "noEmitOnError": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noImplicitAny": true,
19
+ "noImplicitReturns": true,
20
+ "noImplicitThis": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "resolveJsonModule": true,
24
+ "strict": true,
25
+ "strictNullChecks": true,
26
+ "strictPropertyInitialization": true,
27
+ "stripInternal": true,
28
+ "target": "ES2022",
29
+ "types": [
30
+ "node"
31
+ ],
32
+ "moduleResolution": "bundler",
33
+ "skipLibCheck": true
34
+ },
35
+ "include": [
36
+ "src/**/*.ts",
37
+ "index.ts",
38
+ "bin/**/*.ts"
39
+ ],
40
+ "exclude": [
41
+ "node_modules"
42
+ ]
43
+ }