@dbx-tools/cli-model-proxy 0.3.21 → 0.3.23
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 +36 -6
- package/index.ts +0 -1
- package/package.json +4 -5
- package/src/backend.ts +26 -61
- package/src/cli.ts +109 -116
- package/src/defaults.ts +0 -7
- package/src/server.ts +25 -7
- package/test/tsconfig.json +14 -0
- package/src/responses.ts +0 -391
package/README.md
CHANGED
|
@@ -4,14 +4,18 @@ Local OpenAI-compatible proxy for Databricks Model Serving.
|
|
|
4
4
|
|
|
5
5
|
Import this package or run its CLI when a tool expects the OpenAI API shape but
|
|
6
6
|
you want Databricks Model Serving auth, endpoint discovery, and fuzzy model
|
|
7
|
-
names.
|
|
8
|
-
requested model, mints/refreshes Databricks auth through the SDK,
|
|
9
|
-
upstream response back to the caller.
|
|
7
|
+
names. Chat/completions and embeddings bodies are forwarded verbatim: the proxy
|
|
8
|
+
resolves the requested model, mints/refreshes Databricks auth through the SDK,
|
|
9
|
+
and streams the upstream response back to the caller.
|
|
10
10
|
|
|
11
11
|
Key features:
|
|
12
12
|
|
|
13
13
|
- OpenAI-compatible `/v1/*` forwarding for local tools that already know how to
|
|
14
14
|
call chat/completions endpoints.
|
|
15
|
+
- `POST /v1/responses` support for clients that speak only the OpenAI Responses
|
|
16
|
+
API (the Codex CLI, for one), translated to and from Chat Completions -
|
|
17
|
+
streaming included - by
|
|
18
|
+
[`@dbx-tools/shared-model`](../../shared/model)'s `openaiResponses`.
|
|
15
19
|
- Databricks SDK auth, including profile selection, token refresh, and workspace
|
|
16
20
|
host resolution.
|
|
17
21
|
- Fuzzy model names and model-class requests powered by
|
|
@@ -123,14 +127,40 @@ Use this when tests or local developer tools need a managed proxy lifecycle.
|
|
|
123
127
|
|
|
124
128
|
1. `backend.DatabricksBackend` reads the OpenAI request body and resolves
|
|
125
129
|
`body.model` through [`@dbx-tools/model`](../../node/model).
|
|
126
|
-
2.
|
|
127
|
-
3. The
|
|
130
|
+
2. Request fields Databricks refuses to parse are dropped (see below).
|
|
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
|
|
128
133
|
`/invocations` route.
|
|
129
|
-
|
|
134
|
+
5. JSON or SSE response bodies are piped back unchanged.
|
|
130
135
|
|
|
131
136
|
This keeps the package small: Databricks already speaks the OpenAI schema, so
|
|
132
137
|
the useful work is auth and endpoint resolution.
|
|
133
138
|
|
|
139
|
+
## Unsupported Request Fields
|
|
140
|
+
|
|
141
|
+
Databricks Model Serving validates the chat body strictly, so a single
|
|
142
|
+
top-level key it doesn't recognize fails the whole turn:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{ "error_code": "BAD_REQUEST", "message": "parallel_tool_calls: Extra inputs are not permitted" }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Because `/v1/chat/completions` forwards the client's body as-is, the proxy
|
|
149
|
+
deletes the known offenders first - `parallel_tool_calls` plus OpenAI-platform
|
|
150
|
+
bookkeeping like `store` and `metadata` - using
|
|
151
|
+
`openaiChat.stripUnsupportedChatFields` from
|
|
152
|
+
[`@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.
|
|
155
|
+
|
|
156
|
+
Set `PROXY_DROP_FIELDS` to a comma-separated list to drop more, when a
|
|
157
|
+
workspace or a new client version trips a field this package doesn't know
|
|
158
|
+
about yet:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
PROXY_DROP_FIELDS=some_new_field,another dbx-tools-model-proxy
|
|
162
|
+
```
|
|
163
|
+
|
|
134
164
|
## Modules
|
|
135
165
|
|
|
136
166
|
- `cli` - Commander program and `runCli()`.
|
package/index.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
export * as backend from "./src/backend";
|
|
6
6
|
export * as cli from "./src/cli";
|
|
7
7
|
export * as defaults from "./src/defaults";
|
|
8
|
-
export * as responses from "./src/responses";
|
|
9
8
|
export * as server from "./src/server";
|
|
10
9
|
export type { BackendOptions } from "./src/backend";
|
|
11
10
|
export type { ProxyServerOptions, StartProxyOptions } from "./src/server";
|
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-core": "0.3.
|
|
23
|
-
"@dbx-tools/shared-model": "0.3.
|
|
21
|
+
"@dbx-tools/model": "0.3.23",
|
|
22
|
+
"@dbx-tools/shared-core": "0.3.23",
|
|
23
|
+
"@dbx-tools/shared-model": "0.3.23"
|
|
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.23",
|
|
31
31
|
"types": "index.ts",
|
|
32
32
|
"type": "module",
|
|
33
33
|
"exports": {
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
"./backend": "./src/backend.ts",
|
|
36
36
|
"./cli": "./src/cli.ts",
|
|
37
37
|
"./defaults": "./src/defaults.ts",
|
|
38
|
-
"./responses": "./src/responses.ts",
|
|
39
38
|
"./server": "./src/server.ts",
|
|
40
39
|
"./package.json": "./package.json"
|
|
41
40
|
},
|
package/src/backend.ts
CHANGED
|
@@ -2,31 +2,25 @@
|
|
|
2
2
|
* Databricks backend for the local model proxy.
|
|
3
3
|
*
|
|
4
4
|
* Wraps a single default-auth {@link WorkspaceClient} and exposes the three
|
|
5
|
-
* things the proxy server needs
|
|
6
|
-
* name resolution (
|
|
7
|
-
* loose `"opus"` / `"claude sonnet"` snaps to
|
|
8
|
-
* match score, then class, then within-class
|
|
9
|
-
* headers per upstream request
|
|
5
|
+
* things the proxy server needs, each delegating to `@dbx-tools/model`: the
|
|
6
|
+
* workspace serving-endpoint list, fuzzy name resolution ({@link
|
|
7
|
+
* resolve.rankModelIdLive}, so a loose `"opus"` / `"claude sonnet"` snaps to
|
|
8
|
+
* the best live endpoint id - match score, then class, then within-class
|
|
9
|
+
* version), and a fresh set of auth headers per upstream request ({@link
|
|
10
|
+
* invoke.authHeaders}).
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* currently-valid bearer token.
|
|
16
|
-
*
|
|
17
|
-
* The endpoint catalogue is listed once and reused for the process, and
|
|
18
|
-
* re-listed on a resolve miss so a model deployed after start-up still resolves
|
|
19
|
-
* on first use - no cache layer, just one lazy load.
|
|
12
|
+
* What is left here is only the proxy's own policy: which client to construct,
|
|
13
|
+
* and holding the endpoint catalogue for the process lifetime. The catalogue is
|
|
14
|
+
* listed once and re-listed on a resolve miss, so a model deployed after
|
|
15
|
+
* start-up still resolves on first use - no cache layer, just one lazy load.
|
|
20
16
|
*
|
|
21
17
|
* @module
|
|
22
18
|
*/
|
|
23
19
|
|
|
20
|
+
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
21
|
+
import { invoke, resolve, serving, type ResolvedModel } from "@dbx-tools/model";
|
|
24
22
|
import { log } from "@dbx-tools/shared-core";
|
|
25
23
|
import { type ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
26
|
-
import { resolve, serving, type ResolvedModel } from "@dbx-tools/model";
|
|
27
|
-
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
28
|
-
|
|
29
|
-
import { INVOCATIONS_SUFFIX } from "./defaults";
|
|
30
24
|
|
|
31
25
|
const logger = log.logger("model-proxy/backend");
|
|
32
26
|
|
|
@@ -74,6 +68,9 @@ export class DatabricksBackend {
|
|
|
74
68
|
* {@link ServingEndpointSummary} the resolver needs. Loaded lazily and
|
|
75
69
|
* reused; pass `force` to re-list (used by `/v1/models` and the
|
|
76
70
|
* resolve-on-miss path).
|
|
71
|
+
*
|
|
72
|
+
* Deliberately the uncached listing: the cached one runs through AppKit's
|
|
73
|
+
* `CacheManager`, which a plain CLI has no app to initialize.
|
|
77
74
|
*/
|
|
78
75
|
async models(force = false): Promise<ServingEndpointSummary[]> {
|
|
79
76
|
if (this.endpoints && !force) return this.endpoints;
|
|
@@ -85,40 +82,17 @@ export class DatabricksBackend {
|
|
|
85
82
|
|
|
86
83
|
/**
|
|
87
84
|
* Snap a (possibly loose) OpenAI-style model name to the best real serving
|
|
88
|
-
* endpoint
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* retried, so a freshly deployed model resolves without a restart. Returns
|
|
93
|
-
* the input unchanged with `matched: false` when nothing scores within the
|
|
94
|
-
* threshold, so a deliberate endpoint id is never silently rewritten and
|
|
95
|
-
* Databricks surfaces a clean 404.
|
|
85
|
+
* endpoint. {@link resolve.rankModelIdLive} owns the policy: rank against the
|
|
86
|
+
* loaded catalogue, and on a miss re-list once and retry so a freshly
|
|
87
|
+
* deployed model resolves without a restart. An unmatched name comes back
|
|
88
|
+
* unchanged so a deliberate endpoint id is never silently rewritten.
|
|
96
89
|
*/
|
|
97
90
|
async resolve(model: string): Promise<ResolvedModel> {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Rank `model` against a catalogue snapshot and collapse to a single
|
|
107
|
-
* {@link ResolvedModel}. Uses {@link resolve.rankModels} with `limit: 1`
|
|
108
|
-
* rather than Fuse-only {@link serving.resolveModelId}, so equal-score
|
|
109
|
-
* siblings are broken by class / version.
|
|
110
|
-
*/
|
|
111
|
-
private resolveAgainst(
|
|
112
|
-
model: string,
|
|
113
|
-
endpoints: readonly ServingEndpointSummary[],
|
|
114
|
-
): ResolvedModel {
|
|
115
|
-
const [top] = resolve.rankModels(endpoints, {
|
|
116
|
-
search: model,
|
|
117
|
-
limit: 1,
|
|
118
|
-
...(this.threshold !== undefined ? { threshold: this.threshold } : {}),
|
|
119
|
-
});
|
|
120
|
-
if (!top) return { modelId: model, matched: false };
|
|
121
|
-
return { modelId: top.endpoint.name, matched: true, score: top.score };
|
|
91
|
+
return resolve.rankModelIdLive(
|
|
92
|
+
(force) => this.models(force),
|
|
93
|
+
model,
|
|
94
|
+
this.threshold !== undefined ? { threshold: this.threshold } : {},
|
|
95
|
+
);
|
|
122
96
|
}
|
|
123
97
|
|
|
124
98
|
/**
|
|
@@ -127,20 +101,11 @@ export class DatabricksBackend {
|
|
|
127
101
|
* header without the proxy tracking expiry.
|
|
128
102
|
*/
|
|
129
103
|
async authHeaders(): Promise<Record<string, string>> {
|
|
130
|
-
|
|
131
|
-
await this.client.config.authenticate(headers);
|
|
132
|
-
const out: Record<string, string> = {};
|
|
133
|
-
headers.forEach((value, key) => {
|
|
134
|
-
out[key] = value;
|
|
135
|
-
});
|
|
136
|
-
return out;
|
|
104
|
+
return invoke.authHeaders(this.client);
|
|
137
105
|
}
|
|
138
106
|
|
|
139
107
|
/** OpenAI-compatible invocations URL for a resolved endpoint id. */
|
|
140
108
|
invocationsUrl(endpoint: string): string {
|
|
141
|
-
return
|
|
142
|
-
`serving-endpoints/${encodeURIComponent(endpoint)}/${INVOCATIONS_SUFFIX}`,
|
|
143
|
-
this.host,
|
|
144
|
-
).toString();
|
|
109
|
+
return invoke.invocationsUrl(this.host, endpoint);
|
|
145
110
|
}
|
|
146
111
|
}
|
package/src/cli.ts
CHANGED
|
@@ -8,45 +8,25 @@
|
|
|
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
|
|
|
14
21
|
import { spawn } from "node:child_process";
|
|
15
22
|
import type { Server } from "node:http";
|
|
23
|
+
import { classify } from "@dbx-tools/shared-model";
|
|
16
24
|
import { Command, CommanderError } from "commander";
|
|
17
25
|
|
|
18
|
-
import { type ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
19
|
-
|
|
20
26
|
import { DatabricksBackend, type BackendOptions } from "./backend";
|
|
21
27
|
import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
|
|
22
28
|
import { startProxyServer } from "./server";
|
|
23
29
|
|
|
24
|
-
/** Capability flags an endpoint advertises, derived from its task/class. */
|
|
25
|
-
interface EndpointCapabilities {
|
|
26
|
-
/** OpenAI chat/completions + Responses (what a chat agent like Codex needs). */
|
|
27
|
-
chat: boolean;
|
|
28
|
-
/** Embedding (vector) endpoint. */
|
|
29
|
-
embedding: boolean;
|
|
30
|
-
/** Function / tool calling. Chat endpoints here support it. */
|
|
31
|
-
tools: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Derive an endpoint's capabilities from its Databricks task hint and the
|
|
36
|
-
* classifier's model class. `llm/v1/chat` (and chat-classed endpoints) are
|
|
37
|
-
* chat- and tool-capable; `llm/v1/embeddings` (and embedding-classed) are
|
|
38
|
-
* embedding-only. Keeps capability logic in one place so consumers don't
|
|
39
|
-
* re-derive it from raw strings.
|
|
40
|
-
*/
|
|
41
|
-
function capabilitiesFor(endpoint: ServingEndpointSummary): EndpointCapabilities {
|
|
42
|
-
const task = endpoint.task;
|
|
43
|
-
const cls = endpoint.class;
|
|
44
|
-
const embedding = task === "llm/v1/embeddings" || cls === "embedding";
|
|
45
|
-
const chat =
|
|
46
|
-
!embedding && (task === "llm/v1/chat" || (typeof cls === "string" && cls.startsWith("chat")));
|
|
47
|
-
return { chat, embedding, tools: chat };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
30
|
/**
|
|
51
31
|
* Default terminal client for `chat`, launched via `bunx`. OpenHarness is an
|
|
52
32
|
* OpenAI-compatible TUI that reads the endpoint straight from the env we inject
|
|
@@ -78,6 +58,15 @@ function backendOptions(opts: CommonOpts): BackendOptions {
|
|
|
78
58
|
};
|
|
79
59
|
}
|
|
80
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
|
+
|
|
81
70
|
/** Create the backend and start the proxy from the shared listen flags. */
|
|
82
71
|
async function startProxy(
|
|
83
72
|
opts: ServeOpts,
|
|
@@ -92,102 +81,106 @@ async function startProxy(
|
|
|
92
81
|
return { backend, server, url };
|
|
93
82
|
}
|
|
94
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
|
+
|
|
95
92
|
/** Build the `dbx-tools-model-proxy` commander program (no side effects until parsed). */
|
|
96
93
|
export function buildProgram(): Command {
|
|
97
94
|
// Serving is the root action, not a subcommand: the bare command runs the
|
|
98
95
|
// proxy, and `chat`/`models`/`resolve` are the named detours off it.
|
|
99
|
-
const program =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
process.stderr.write(` OpenAI base URL: ${url}/v1\n`);
|
|
112
|
-
});
|
|
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
|
+
});
|
|
113
108
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
env
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
child.on("exit", (code) => {
|
|
151
|
-
server.close();
|
|
152
|
-
process.exit(code ?? 0);
|
|
153
|
-
});
|
|
154
|
-
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
|
+
},
|
|
155
143
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
.description("List resolvable Databricks serving endpoints (as JSON).")
|
|
160
|
-
.option("--profile <profile>", "Databricks config profile")
|
|
161
|
-
.option("--workspace-host <url>", "override the Databricks workspace host")
|
|
162
|
-
.option("--chat", "only list chat-capable endpoints")
|
|
163
|
-
.action(async (opts: CommonOpts & { chat?: boolean }) => {
|
|
164
|
-
const backend = await DatabricksBackend.create(backendOptions(opts));
|
|
165
|
-
const endpoints = await backend.models();
|
|
166
|
-
// Enrich each endpoint with a derived `capabilities` object so consumers
|
|
167
|
-
// filter on capability rather than re-deriving it from raw `task`/`class`
|
|
168
|
-
// strings. `chat` = OpenAI chat/completions + Responses (what an agent
|
|
169
|
-
// like Codex needs); `embedding` = vector endpoints; `tools` = whether the
|
|
170
|
-
// endpoint supports function/tool calls (all chat endpoints here do).
|
|
171
|
-
const enriched = endpoints.map((endpoint) => {
|
|
172
|
-
const caps = capabilitiesFor(endpoint);
|
|
173
|
-
return { ...endpoint, capabilities: caps };
|
|
174
|
-
});
|
|
175
|
-
const out = opts.chat ? enriched.filter((e) => e.capabilities.chat) : enriched;
|
|
176
|
-
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
144
|
+
child.on("exit", (code) => {
|
|
145
|
+
server.close();
|
|
146
|
+
process.exit(code ?? 0);
|
|
177
147
|
});
|
|
148
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
149
|
+
});
|
|
178
150
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
});
|
|
191
184
|
|
|
192
185
|
return program;
|
|
193
186
|
}
|
package/src/defaults.ts
CHANGED
|
@@ -9,10 +9,3 @@ export const DEFAULT_BIND_HOST = "127.0.0.1";
|
|
|
9
9
|
|
|
10
10
|
/** Default TCP port for the local proxy. */
|
|
11
11
|
export const DEFAULT_PORT = 4000;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Path segment Databricks Model Serving exposes for OpenAI-compatible requests.
|
|
15
|
-
* Every serving endpoint answers chat / completion / embedding payloads under
|
|
16
|
-
* `serving-endpoints/<name>/invocations`.
|
|
17
|
-
*/
|
|
18
|
-
export const INVOCATIONS_SUFFIX = "invocations";
|
package/src/server.ts
CHANGED
|
@@ -25,16 +25,14 @@
|
|
|
25
25
|
* @module
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { error, log } from "@dbx-tools/shared-core";
|
|
29
28
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
29
|
+
import { error, log } from "@dbx-tools/shared-core";
|
|
30
|
+
import { classify, openaiChat, openaiResponses } from "@dbx-tools/shared-model";
|
|
30
31
|
|
|
31
32
|
import type { DatabricksBackend } from "./backend";
|
|
32
33
|
import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
createResponsesStreamTranslator,
|
|
36
|
-
responsesToChat,
|
|
37
|
-
} from "./responses";
|
|
34
|
+
|
|
35
|
+
const { chatToResponse, createResponsesStreamTranslator, responsesToChat } = openaiResponses;
|
|
38
36
|
|
|
39
37
|
const logger = log.logger("model-proxy/server");
|
|
40
38
|
|
|
@@ -54,6 +52,21 @@ const MODELS_PATHS = new Set(["/v1/models", "/models"]);
|
|
|
54
52
|
/** POST routes that carry an OpenAI Responses API body (translated to chat). */
|
|
55
53
|
const RESPONSES_PATHS = new Set(["/v1/responses", "/responses"]);
|
|
56
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Extra request fields to strip before forwarding, on top of
|
|
57
|
+
* `openaiChat.UNSUPPORTED_CHAT_FIELDS`. Comma-separated in `PROXY_DROP_FIELDS`.
|
|
58
|
+
*
|
|
59
|
+
* An escape hatch: when a workspace or a new client version trips a field
|
|
60
|
+
* Databricks rejects, an operator can unblock themselves immediately instead of
|
|
61
|
+
* waiting on a release of this package.
|
|
62
|
+
*/
|
|
63
|
+
function extraDropFields(): string[] {
|
|
64
|
+
return (process.env.PROXY_DROP_FIELDS ?? "")
|
|
65
|
+
.split(",")
|
|
66
|
+
.map((field) => field.trim())
|
|
67
|
+
.filter((field) => field.length > 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
/** Options shared by {@link createProxyServer} and {@link startProxyServer}. */
|
|
58
71
|
export interface ProxyServerOptions {
|
|
59
72
|
/**
|
|
@@ -168,7 +181,7 @@ async function handleModels(
|
|
|
168
181
|
const endpoints = await backend.models(true);
|
|
169
182
|
if (codexShape) {
|
|
170
183
|
const models = endpoints
|
|
171
|
-
.filter((endpoint) => endpoint.
|
|
184
|
+
.filter((endpoint) => classify.endpointCapabilities(endpoint).chat)
|
|
172
185
|
.map((endpoint) => ({
|
|
173
186
|
slug: endpoint.name,
|
|
174
187
|
display_name: endpoint.displayName ?? endpoint.name,
|
|
@@ -213,6 +226,10 @@ async function handleProxy(
|
|
|
213
226
|
// Address the endpoint by URL; rewrite the body's `model` to the real id so
|
|
214
227
|
// pay-per-token endpoints that echo it still see a valid value.
|
|
215
228
|
body.model = resolved.modelId;
|
|
229
|
+
// This route forwards the client's body as-is, so anything Databricks refuses
|
|
230
|
+
// to parse fails the turn. Drop the known offenders rather than relaying a
|
|
231
|
+
// 400 the client can do nothing about.
|
|
232
|
+
const dropped = openaiChat.stripUnsupportedChatFields(body, extraDropFields());
|
|
216
233
|
|
|
217
234
|
const wantsStream = body.stream === true;
|
|
218
235
|
const headers = await backend.authHeaders();
|
|
@@ -224,6 +241,7 @@ async function handleProxy(
|
|
|
224
241
|
resolved: resolved.modelId,
|
|
225
242
|
matched: resolved.matched,
|
|
226
243
|
stream: wantsStream,
|
|
244
|
+
...(dropped.length > 0 ? { dropped } : {}),
|
|
227
245
|
});
|
|
228
246
|
|
|
229
247
|
const upstream = await fetch(backend.invocationsUrl(resolved.modelId), {
|
|
@@ -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
|
+
}
|
package/src/responses.ts
DELETED
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenAI Responses API <-> Chat Completions translation.
|
|
3
|
-
*
|
|
4
|
-
* The Codex CLI speaks only the Responses API (`POST /v1/responses`), but
|
|
5
|
-
* Databricks serving endpoints speak only Chat Completions (`messages`) at
|
|
6
|
-
* their `invocations` URL. This module bridges the two:
|
|
7
|
-
*
|
|
8
|
-
* - {@link responsesToChat} lowers a Responses request body to a chat
|
|
9
|
-
* completions body (instructions -> system message, typed `input` items ->
|
|
10
|
-
* `messages`, `tools`/`tool_choice` carried through, function-call outputs
|
|
11
|
-
* folded back into the transcript).
|
|
12
|
-
* - {@link chatToResponse} lifts a non-streaming chat completion back into a
|
|
13
|
-
* Responses `response` object.
|
|
14
|
-
* - {@link chatStreamToResponsesSse} lifts a streaming chat completion (an
|
|
15
|
-
* OpenAI SSE `chat.completion.chunk` stream) into the Responses SSE event
|
|
16
|
-
* stream Codex consumes (`response.created`, `response.output_text.delta`,
|
|
17
|
-
* function-call argument deltas, `response.completed`).
|
|
18
|
-
*
|
|
19
|
-
* Only the surface Codex actually exercises is translated; unknown fields are
|
|
20
|
-
* ignored rather than rejected, so a newer client degrades instead of breaking.
|
|
21
|
-
*
|
|
22
|
-
* @module
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/** A minimal chat message as Databricks Model Serving expects it. */
|
|
26
|
-
interface ChatMessage {
|
|
27
|
-
role: string;
|
|
28
|
-
content?: string | null;
|
|
29
|
-
tool_calls?: ChatToolCall[];
|
|
30
|
-
tool_call_id?: string;
|
|
31
|
-
name?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface ChatToolCall {
|
|
35
|
-
id: string;
|
|
36
|
-
type: "function";
|
|
37
|
-
function: { name: string; arguments: string };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Extract plain text from a Responses `content` value (string or typed parts). */
|
|
41
|
-
function contentToText(content: unknown): string {
|
|
42
|
-
if (typeof content === "string") return content;
|
|
43
|
-
if (!Array.isArray(content)) return "";
|
|
44
|
-
const parts: string[] = [];
|
|
45
|
-
for (const part of content) {
|
|
46
|
-
if (part && typeof part === "object") {
|
|
47
|
-
const p = part as Record<string, unknown>;
|
|
48
|
-
// input_text / output_text / text all carry their text on `.text`.
|
|
49
|
-
if (typeof p.text === "string") parts.push(p.text);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return parts.join("");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Lower a Responses request body to a chat-completions body. Returns the chat
|
|
57
|
-
* body plus whether the caller asked for streaming (the server needs it to pick
|
|
58
|
-
* the upstream `accept` header and the translation path).
|
|
59
|
-
*/
|
|
60
|
-
export function responsesToChat(body: Record<string, unknown>): {
|
|
61
|
-
chat: Record<string, unknown>;
|
|
62
|
-
stream: boolean;
|
|
63
|
-
} {
|
|
64
|
-
const messages: ChatMessage[] = [];
|
|
65
|
-
|
|
66
|
-
// `instructions` becomes the leading system message.
|
|
67
|
-
if (typeof body.instructions === "string" && body.instructions.length > 0) {
|
|
68
|
-
messages.push({ role: "system", content: body.instructions });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// `input` is an array of typed items: messages, function_call, and
|
|
72
|
-
// function_call_output. Fold each into the chat transcript.
|
|
73
|
-
const input = Array.isArray(body.input) ? body.input : [];
|
|
74
|
-
for (const raw of input) {
|
|
75
|
-
if (!raw || typeof raw !== "object") continue;
|
|
76
|
-
const item = raw as Record<string, unknown>;
|
|
77
|
-
const type = item.type;
|
|
78
|
-
|
|
79
|
-
if (type === "function_call") {
|
|
80
|
-
// A prior tool call the model made; re-attach it to an assistant turn.
|
|
81
|
-
messages.push({
|
|
82
|
-
role: "assistant",
|
|
83
|
-
content: null,
|
|
84
|
-
tool_calls: [
|
|
85
|
-
{
|
|
86
|
-
id: String(item.call_id ?? item.id ?? ""),
|
|
87
|
-
type: "function",
|
|
88
|
-
function: {
|
|
89
|
-
name: String(item.name ?? ""),
|
|
90
|
-
arguments: typeof item.arguments === "string" ? item.arguments : "{}",
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
-
});
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (type === "function_call_output") {
|
|
99
|
-
// The tool's result, keyed back to the call it answers.
|
|
100
|
-
messages.push({
|
|
101
|
-
role: "tool",
|
|
102
|
-
tool_call_id: String(item.call_id ?? item.id ?? ""),
|
|
103
|
-
content: typeof item.output === "string" ? item.output : contentToText(item.output),
|
|
104
|
-
});
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Default: a message item with a role and typed content.
|
|
109
|
-
const role = typeof item.role === "string" ? item.role : "user";
|
|
110
|
-
// Chat has no "developer" role; treat it as system (its intent here).
|
|
111
|
-
const chatRole = role === "developer" ? "system" : role;
|
|
112
|
-
messages.push({ role: chatRole, content: contentToText(item.content) });
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const chat: Record<string, unknown> = {
|
|
116
|
-
model: body.model,
|
|
117
|
-
messages,
|
|
118
|
-
stream: body.stream === true,
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// Carry through function-calling config when present. Responses function
|
|
122
|
-
// tools are FLAT (`{type:"function", name, description, parameters}`); chat
|
|
123
|
-
// wants them NESTED (`{type:"function", function:{name, …}}`). Codex also
|
|
124
|
-
// sends built-in tool types (local_shell, web_search, …) that Databricks chat
|
|
125
|
-
// rejects ("Missing 'function' in the tool specification"), so we translate
|
|
126
|
-
// only function tools and drop the rest.
|
|
127
|
-
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
128
|
-
const chatTools = body.tools
|
|
129
|
-
.map((t) => {
|
|
130
|
-
const tool = t as Record<string, unknown>;
|
|
131
|
-
// Already chat-nested (`function` object present): pass through.
|
|
132
|
-
if (tool.type === "function" && tool.function && typeof tool.function === "object") {
|
|
133
|
-
return tool;
|
|
134
|
-
}
|
|
135
|
-
// Flat Responses function tool: nest it.
|
|
136
|
-
if (tool.type === "function" && typeof tool.name === "string") {
|
|
137
|
-
return {
|
|
138
|
-
type: "function",
|
|
139
|
-
function: {
|
|
140
|
-
name: tool.name,
|
|
141
|
-
...(tool.description ? { description: tool.description } : {}),
|
|
142
|
-
...(tool.parameters ? { parameters: tool.parameters } : {}),
|
|
143
|
-
},
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
return undefined; // non-function built-in tool: unsupported upstream, drop
|
|
147
|
-
})
|
|
148
|
-
.filter((t): t is Record<string, unknown> => t !== undefined);
|
|
149
|
-
if (chatTools.length > 0) {
|
|
150
|
-
chat.tools = chatTools;
|
|
151
|
-
if (body.tool_choice !== undefined) chat.tool_choice = body.tool_choice;
|
|
152
|
-
if (body.parallel_tool_calls !== undefined) {
|
|
153
|
-
chat.parallel_tool_calls = body.parallel_tool_calls;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return { chat, stream: body.stream === true };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Lift a non-streaming chat completion JSON into a Responses `response` object. */
|
|
162
|
-
export function chatToResponse(chat: Record<string, unknown>, model: string): unknown {
|
|
163
|
-
const choices = Array.isArray(chat.choices) ? chat.choices : [];
|
|
164
|
-
const first = (choices[0] ?? {}) as Record<string, unknown>;
|
|
165
|
-
const message = (first.message ?? {}) as Record<string, unknown>;
|
|
166
|
-
const output: unknown[] = [];
|
|
167
|
-
|
|
168
|
-
const text = typeof message.content === "string" ? message.content : "";
|
|
169
|
-
if (text) {
|
|
170
|
-
output.push({
|
|
171
|
-
type: "message",
|
|
172
|
-
role: "assistant",
|
|
173
|
-
content: [{ type: "output_text", text }],
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Surface any tool calls as Responses `function_call` output items.
|
|
178
|
-
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
179
|
-
for (const raw of toolCalls) {
|
|
180
|
-
const call = raw as Record<string, unknown>;
|
|
181
|
-
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
182
|
-
output.push({
|
|
183
|
-
type: "function_call",
|
|
184
|
-
call_id: String(call.id ?? ""),
|
|
185
|
-
name: String(fn.name ?? ""),
|
|
186
|
-
arguments: typeof fn.arguments === "string" ? fn.arguments : "{}",
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const usage = (chat.usage ?? {}) as Record<string, unknown>;
|
|
191
|
-
return {
|
|
192
|
-
id: String(chat.id ?? "resp"),
|
|
193
|
-
object: "response",
|
|
194
|
-
created_at: typeof chat.created === "number" ? chat.created : 0,
|
|
195
|
-
model,
|
|
196
|
-
status: "completed",
|
|
197
|
-
output,
|
|
198
|
-
usage: {
|
|
199
|
-
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
200
|
-
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
201
|
-
total_tokens: Number(usage.total_tokens ?? 0),
|
|
202
|
-
},
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** One Server-Sent Event, ready to write to the client. */
|
|
207
|
-
function sse(event: string, data: unknown): string {
|
|
208
|
-
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Translate an upstream chat-completions SSE stream into the Responses SSE
|
|
213
|
-
* event stream Codex consumes.
|
|
214
|
-
*
|
|
215
|
-
* Upstream chunks are `chat.completion.chunk` objects whose `choices[0].delta`
|
|
216
|
-
* carries incremental `content` (assistant text) and/or `tool_calls` (function
|
|
217
|
-
* calls, streamed by `index` with partial `arguments`). We emit the Responses
|
|
218
|
-
* lifecycle around them:
|
|
219
|
-
*
|
|
220
|
-
* response.created
|
|
221
|
-
* -> per text run: output_item.added → output_text.delta* → output_item.done
|
|
222
|
-
* -> per tool call: output_item.added → function_call_arguments.delta*
|
|
223
|
-
* → function_call_arguments.done → output_item.done
|
|
224
|
-
* response.completed (with the assembled final `response` object)
|
|
225
|
-
*
|
|
226
|
-
* `feed(chunk)` returns the SSE bytes to forward for that upstream chunk;
|
|
227
|
-
* `finish()` returns the closing `response.completed` event. The generator is
|
|
228
|
-
* intentionally tolerant: malformed/keepalive lines yield nothing.
|
|
229
|
-
*/
|
|
230
|
-
export function createResponsesStreamTranslator(model: string, responseId: string) {
|
|
231
|
-
let started = false;
|
|
232
|
-
let outputIndex = 0;
|
|
233
|
-
|
|
234
|
-
// Text run state: whether a message item is currently open, and its buffer.
|
|
235
|
-
let textOpen = false;
|
|
236
|
-
let textBuffer = "";
|
|
237
|
-
let textItemId = "";
|
|
238
|
-
|
|
239
|
-
// Tool-call state, keyed by the upstream `tool_calls[].index`.
|
|
240
|
-
interface ToolState {
|
|
241
|
-
outputIndex: number;
|
|
242
|
-
callId: string;
|
|
243
|
-
name: string;
|
|
244
|
-
args: string;
|
|
245
|
-
}
|
|
246
|
-
const tools = new Map<number, ToolState>();
|
|
247
|
-
|
|
248
|
-
const created = () =>
|
|
249
|
-
sse("response.created", {
|
|
250
|
-
type: "response.created",
|
|
251
|
-
response: { id: responseId, object: "response", status: "in_progress", model, output: [] },
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
function openText(): string {
|
|
255
|
-
textOpen = true;
|
|
256
|
-
textItemId = `${responseId}-msg-${outputIndex}`;
|
|
257
|
-
const ev = sse("response.output_item.added", {
|
|
258
|
-
type: "response.output_item.added",
|
|
259
|
-
output_index: outputIndex,
|
|
260
|
-
item: { id: textItemId, type: "message", role: "assistant", content: [] },
|
|
261
|
-
});
|
|
262
|
-
return ev;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function closeText(): string {
|
|
266
|
-
if (!textOpen) return "";
|
|
267
|
-
const item = {
|
|
268
|
-
id: textItemId,
|
|
269
|
-
type: "message",
|
|
270
|
-
role: "assistant",
|
|
271
|
-
content: [{ type: "output_text", text: textBuffer }],
|
|
272
|
-
};
|
|
273
|
-
const ev =
|
|
274
|
-
sse("response.output_text.done", {
|
|
275
|
-
type: "response.output_text.done",
|
|
276
|
-
item_id: textItemId,
|
|
277
|
-
output_index: outputIndex,
|
|
278
|
-
content_index: 0,
|
|
279
|
-
text: textBuffer,
|
|
280
|
-
}) +
|
|
281
|
-
sse("response.output_item.done", {
|
|
282
|
-
type: "response.output_item.done",
|
|
283
|
-
output_index: outputIndex,
|
|
284
|
-
item,
|
|
285
|
-
});
|
|
286
|
-
textOpen = false;
|
|
287
|
-
textBuffer = "";
|
|
288
|
-
outputIndex += 1;
|
|
289
|
-
return ev;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function feed(chunk: Record<string, unknown>): string {
|
|
293
|
-
let out = "";
|
|
294
|
-
if (!started) {
|
|
295
|
-
started = true;
|
|
296
|
-
out += created();
|
|
297
|
-
}
|
|
298
|
-
const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
|
|
299
|
-
const choice = (choices[0] ?? {}) as Record<string, unknown>;
|
|
300
|
-
const delta = (choice.delta ?? {}) as Record<string, unknown>;
|
|
301
|
-
|
|
302
|
-
// Assistant text deltas.
|
|
303
|
-
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
304
|
-
if (!textOpen) out += openText();
|
|
305
|
-
textBuffer += delta.content;
|
|
306
|
-
out += sse("response.output_text.delta", {
|
|
307
|
-
type: "response.output_text.delta",
|
|
308
|
-
item_id: textItemId,
|
|
309
|
-
output_index: outputIndex,
|
|
310
|
-
content_index: 0,
|
|
311
|
-
delta: delta.content,
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Tool-call deltas: each is keyed by `index`; `arguments` arrives in pieces.
|
|
316
|
-
const toolDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
317
|
-
for (const rawTc of toolDeltas) {
|
|
318
|
-
const tc = rawTc as Record<string, unknown>;
|
|
319
|
-
const idx = Number(tc.index ?? 0);
|
|
320
|
-
const fn = (tc.function ?? {}) as Record<string, unknown>;
|
|
321
|
-
let state = tools.get(idx);
|
|
322
|
-
if (!state) {
|
|
323
|
-
// First fragment for this call: a text run (if open) must close first so
|
|
324
|
-
// output ordering stays monotonic, then we open a function_call item.
|
|
325
|
-
out += closeText();
|
|
326
|
-
state = {
|
|
327
|
-
outputIndex,
|
|
328
|
-
callId: String(tc.id ?? `${responseId}-call-${idx}`),
|
|
329
|
-
name: String(fn.name ?? ""),
|
|
330
|
-
args: "",
|
|
331
|
-
};
|
|
332
|
-
tools.set(idx, state);
|
|
333
|
-
out += sse("response.output_item.added", {
|
|
334
|
-
type: "response.output_item.added",
|
|
335
|
-
output_index: state.outputIndex,
|
|
336
|
-
item: {
|
|
337
|
-
id: state.callId,
|
|
338
|
-
type: "function_call",
|
|
339
|
-
call_id: state.callId,
|
|
340
|
-
name: state.name,
|
|
341
|
-
arguments: "",
|
|
342
|
-
},
|
|
343
|
-
});
|
|
344
|
-
outputIndex += 1;
|
|
345
|
-
}
|
|
346
|
-
if (typeof fn.name === "string" && fn.name && !state.name) state.name = fn.name;
|
|
347
|
-
if (typeof fn.arguments === "string" && fn.arguments.length > 0) {
|
|
348
|
-
state.args += fn.arguments;
|
|
349
|
-
out += sse("response.function_call_arguments.delta", {
|
|
350
|
-
type: "response.function_call_arguments.delta",
|
|
351
|
-
item_id: state.callId,
|
|
352
|
-
output_index: state.outputIndex,
|
|
353
|
-
delta: fn.arguments,
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return out;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function finish(): string {
|
|
361
|
-
let out = closeText();
|
|
362
|
-
// Close any open tool calls, emitting their assembled arguments.
|
|
363
|
-
for (const state of tools.values()) {
|
|
364
|
-
out += sse("response.function_call_arguments.done", {
|
|
365
|
-
type: "response.function_call_arguments.done",
|
|
366
|
-
item_id: state.callId,
|
|
367
|
-
output_index: state.outputIndex,
|
|
368
|
-
arguments: state.args,
|
|
369
|
-
});
|
|
370
|
-
out += sse("response.output_item.done", {
|
|
371
|
-
type: "response.output_item.done",
|
|
372
|
-
output_index: state.outputIndex,
|
|
373
|
-
item: {
|
|
374
|
-
id: state.callId,
|
|
375
|
-
type: "function_call",
|
|
376
|
-
call_id: state.callId,
|
|
377
|
-
name: state.name,
|
|
378
|
-
arguments: state.args,
|
|
379
|
-
},
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
out += sse("response.completed", {
|
|
383
|
-
type: "response.completed",
|
|
384
|
-
response: { id: responseId, object: "response", status: "completed", model },
|
|
385
|
-
});
|
|
386
|
-
return out;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return { feed, finish };
|
|
390
|
-
}
|
|
391
|
-
|