@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.
@@ -0,0 +1,391 @@
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
+