@dbx-tools/cli-model-proxy 0.3.22 → 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 +12 -6
- package/package.json +4 -4
- package/src/backend.ts +14 -1
- package/src/cli.ts +108 -88
- package/src/server.ts +98 -110
- package/test/tsconfig.json +14 -0
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/
|
|
22
|
-
"@dbx-tools/model": "0.3.
|
|
23
|
-
"@dbx-tools/shared-
|
|
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.
|
|
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/cli.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* fuzzy name snaps to. Auth comes from the standard Databricks SDK resolution
|
|
9
9
|
* (env vars, `--profile`, or `databricks auth login`).
|
|
10
10
|
*
|
|
11
|
+
* Shared flags (`--profile`, `--workspace-host`, `--threshold`, …) are declared
|
|
12
|
+
* on the root *and* redeclared on subcommands for help discoverability. Commander
|
|
13
|
+
* parks the values on the parent when both declare the same flag, so every
|
|
14
|
+
* subcommand action must read {@link Command.optsWithGlobals} - local `opts`
|
|
15
|
+
* alone misses `--profile` (env `DATABRICKS_CONFIG_PROFILE` still worked because
|
|
16
|
+
* the SDK reads that without the CLI flag).
|
|
17
|
+
*
|
|
11
18
|
* @module
|
|
12
19
|
*/
|
|
13
20
|
|
|
@@ -51,6 +58,15 @@ function backendOptions(opts: CommonOpts): BackendOptions {
|
|
|
51
58
|
};
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Subcommand options merged with parent (root) options. Required for shared
|
|
63
|
+
* flags that are declared on both - Commander stores them on the parent, so
|
|
64
|
+
* the action's local `opts` alone omits `--profile` / `--workspace-host`.
|
|
65
|
+
*/
|
|
66
|
+
function globalOpts<T>(command: Command): T {
|
|
67
|
+
return command.optsWithGlobals() as T;
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
/** Create the backend and start the proxy from the shared listen flags. */
|
|
55
71
|
async function startProxy(
|
|
56
72
|
opts: ServeOpts,
|
|
@@ -65,102 +81,106 @@ async function startProxy(
|
|
|
65
81
|
return { backend, server, url };
|
|
66
82
|
}
|
|
67
83
|
|
|
84
|
+
/** Shared auth / fuzzy-match flags added to the root and every subcommand. */
|
|
85
|
+
function addAuthOptions(command: Command): Command {
|
|
86
|
+
return command
|
|
87
|
+
.option("--profile <profile>", "Databricks config profile")
|
|
88
|
+
.option("--workspace-host <url>", "override the Databricks workspace host")
|
|
89
|
+
.option("-t, --threshold <n>", "fuzzy match threshold (0..1)");
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
/** Build the `dbx-tools-model-proxy` commander program (no side effects until parsed). */
|
|
69
93
|
export function buildProgram(): Command {
|
|
70
94
|
// Serving is the root action, not a subcommand: the bare command runs the
|
|
71
95
|
// proxy, and `chat`/`models`/`resolve` are the named detours off it.
|
|
72
|
-
const program =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
process.stderr.write(` OpenAI base URL: ${url}/v1\n`);
|
|
85
|
-
});
|
|
96
|
+
const program = addAuthOptions(
|
|
97
|
+
new Command()
|
|
98
|
+
.name("dbx-tools-model-proxy")
|
|
99
|
+
.description("Local OpenAI-compatible proxy to Databricks Model Serving.")
|
|
100
|
+
.option("-p, --port <port>", "port to listen on", String(DEFAULT_PORT))
|
|
101
|
+
.option("-H, --host <host>", "address to bind", DEFAULT_BIND_HOST)
|
|
102
|
+
.option("-k, --api-key <key>", "require this bearer token from local clients"),
|
|
103
|
+
).action(async (opts: ServeOpts) => {
|
|
104
|
+
const { backend, url } = await startProxy(opts);
|
|
105
|
+
process.stderr.write(`model-proxy -> ${backend.host}\n`);
|
|
106
|
+
process.stderr.write(` OpenAI base URL: ${url}/v1\n`);
|
|
107
|
+
});
|
|
86
108
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
env
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
child.on("exit", (code) => {
|
|
124
|
-
server.close();
|
|
125
|
-
process.exit(code ?? 0);
|
|
126
|
-
});
|
|
127
|
-
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
109
|
+
addAuthOptions(
|
|
110
|
+
program
|
|
111
|
+
.command("chat")
|
|
112
|
+
.description("Start the proxy and launch a terminal chat client wired to it.")
|
|
113
|
+
.option("-p, --port <port>", "proxy port", String(DEFAULT_PORT))
|
|
114
|
+
.option("-H, --host <host>", "proxy bind host", DEFAULT_BIND_HOST)
|
|
115
|
+
.option("-m, --model <name>", "default model (fuzzy name ok)")
|
|
116
|
+
.option(
|
|
117
|
+
"--client <cmd>",
|
|
118
|
+
"terminal chat CLI to launch (run via your shell)",
|
|
119
|
+
process.env.PROXY_CHAT_CLIENT ?? DEFAULT_CHAT_CLIENT,
|
|
120
|
+
),
|
|
121
|
+
).action(async (_local: ServeOpts & { model?: string; client: string }, command: Command) => {
|
|
122
|
+
const opts = globalOpts<ServeOpts & { model?: string; client: string }>(command);
|
|
123
|
+
const { backend, server, url } = await startProxy(opts);
|
|
124
|
+
const baseUrl = `${url}/v1`;
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
`model-proxy -> ${backend.host}\n OpenAI base URL: ${baseUrl}\n launching: ${opts.client}\n`,
|
|
127
|
+
);
|
|
128
|
+
// Hand off to an off-the-shelf OpenAI-compatible client, pointing it at
|
|
129
|
+
// the proxy via the standard env vars (plus the provider switches a
|
|
130
|
+
// couple of popular CLIs read). `shell: true` lets `--client` carry its
|
|
131
|
+
// own args, e.g. `--client "bunx merlion"`.
|
|
132
|
+
const child = spawn(opts.client, {
|
|
133
|
+
stdio: "inherit",
|
|
134
|
+
shell: true,
|
|
135
|
+
env: {
|
|
136
|
+
...process.env,
|
|
137
|
+
OPENAI_BASE_URL: baseUrl,
|
|
138
|
+
OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "model-proxy",
|
|
139
|
+
...(opts.model ? { OPENAI_MODEL: opts.model } : {}),
|
|
140
|
+
LLM_PROVIDER: "openai-compat",
|
|
141
|
+
CLAUDE_CODE_USE_OPENAI: "1",
|
|
142
|
+
},
|
|
128
143
|
});
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.description("List resolvable Databricks serving endpoints (as JSON).")
|
|
133
|
-
.option("--profile <profile>", "Databricks config profile")
|
|
134
|
-
.option("--workspace-host <url>", "override the Databricks workspace host")
|
|
135
|
-
.option("--chat", "only list chat-capable endpoints")
|
|
136
|
-
.action(async (opts: CommonOpts & { chat?: boolean }) => {
|
|
137
|
-
const backend = await DatabricksBackend.create(backendOptions(opts));
|
|
138
|
-
const endpoints = await backend.models();
|
|
139
|
-
// Enrich each endpoint with a derived `capabilities` object so consumers
|
|
140
|
-
// filter on capability rather than re-deriving it from raw `task`/`class`
|
|
141
|
-
// strings. `chat` = OpenAI chat/completions + Responses (what an agent
|
|
142
|
-
// like Codex needs); `embedding` = vector endpoints; `tools` = whether the
|
|
143
|
-
// endpoint supports function/tool calls (all chat endpoints here do).
|
|
144
|
-
const enriched = endpoints.map((endpoint) => ({
|
|
145
|
-
...endpoint,
|
|
146
|
-
capabilities: classify.endpointCapabilities(endpoint),
|
|
147
|
-
}));
|
|
148
|
-
const out = opts.chat ? enriched.filter((e) => e.capabilities.chat) : enriched;
|
|
149
|
-
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
144
|
+
child.on("exit", (code) => {
|
|
145
|
+
server.close();
|
|
146
|
+
process.exit(code ?? 0);
|
|
150
147
|
});
|
|
148
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
149
|
+
});
|
|
151
150
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
151
|
+
addAuthOptions(
|
|
152
|
+
program
|
|
153
|
+
.command("models")
|
|
154
|
+
.description("List resolvable Databricks serving endpoints (as JSON).")
|
|
155
|
+
.option("--chat", "only list chat-capable endpoints"),
|
|
156
|
+
).action(async (_local: CommonOpts & { chat?: boolean }, command: Command) => {
|
|
157
|
+
const opts = globalOpts<CommonOpts & { chat?: boolean }>(command);
|
|
158
|
+
const backend = await DatabricksBackend.create(backendOptions(opts));
|
|
159
|
+
const endpoints = await backend.models();
|
|
160
|
+
// Enrich each endpoint with a derived `capabilities` object so consumers
|
|
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
|
+
}));
|
|
169
|
+
const out = opts.chat ? enriched.filter((e) => e.capabilities.chat) : enriched;
|
|
170
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
addAuthOptions(
|
|
174
|
+
program
|
|
175
|
+
.command("resolve")
|
|
176
|
+
.description("Show what a fuzzy model name resolves to (as JSON).")
|
|
177
|
+
.argument("<query...>", "model name / fuzzy search terms"),
|
|
178
|
+
).action(async (query: string[], _local: CommonOpts, command: Command) => {
|
|
179
|
+
const opts = globalOpts<CommonOpts>(command);
|
|
180
|
+
const backend = await DatabricksBackend.create(backendOptions(opts));
|
|
181
|
+
const resolved = await backend.resolve(query.join(" "));
|
|
182
|
+
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
|
183
|
+
});
|
|
164
184
|
|
|
165
185
|
return program;
|
|
166
186
|
}
|
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. */
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
+
{
|
|
3
|
+
"extends": "../tsconfig.json",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"noEmit": true,
|
|
6
|
+
"rootDir": ".."
|
|
7
|
+
},
|
|
8
|
+
"include": [
|
|
9
|
+
"**/*.ts"
|
|
10
|
+
],
|
|
11
|
+
"exclude": [
|
|
12
|
+
"node_modules"
|
|
13
|
+
]
|
|
14
|
+
}
|