@dbx-tools/cli-model-proxy 0.3.21 → 0.3.22

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 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. The proxy does not translate the OpenAI wire format; it resolves the
8
- requested model, mints/refreshes Databricks auth through the SDK, and streams the
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. The Databricks SDK supplies a fresh authorization header for the workspace.
127
- 3. The proxy forwards the body to the resolved serving endpoint's
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
- 4. JSON or SSE response bodies are piped back unchanged.
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.21",
22
- "@dbx-tools/shared-core": "0.3.21",
23
- "@dbx-tools/shared-model": "0.3.21"
21
+ "@dbx-tools/shared-core": "0.3.22",
22
+ "@dbx-tools/model": "0.3.22",
23
+ "@dbx-tools/shared-model": "0.3.22"
24
24
  },
25
25
  "main": "index.ts",
26
26
  "license": "UNLICENSED",
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "version": "0.3.21",
30
+ "version": "0.3.22",
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: the workspace serving-endpoint list, fuzzy
6
- * name resolution (reusing `@dbx-tools/model`'s {@link resolve.rankModels} so a
7
- * loose `"opus"` / `"claude sonnet"` snaps to the best live endpoint id -
8
- * match score, then class, then within-class version), and a fresh set of auth
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
- * Auth is delegated entirely to the Databricks SDK: `config.authenticate`
12
- * re-runs the configured credential provider on every call and refreshes the
13
- * underlying OAuth / PAT token when it is close to expiry, so the proxy never
14
- * manages token lifetimes itself - each request is signed with a
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 via {@link resolve.rankModels} (the same ranking
89
- * {@link resolve.resolveModel} uses): Fuse match (bucketed so version
90
- * siblings tie), then class, then within-class version - so `"opus"` prefers
91
- * `opus-5` over `opus-4-7`. On a miss the catalogue is re-listed once and
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
- let resolved = this.resolveAgainst(model, await this.models());
99
- if (!resolved.matched) {
100
- resolved = this.resolveAgainst(model, await this.models(true));
101
- }
102
- return resolved;
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
- const headers = new Headers();
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 new URL(
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
@@ -13,40 +13,13 @@
13
13
 
14
14
  import { spawn } from "node:child_process";
15
15
  import type { Server } from "node:http";
16
+ import { classify } from "@dbx-tools/shared-model";
16
17
  import { Command, CommanderError } from "commander";
17
18
 
18
- import { type ServingEndpointSummary } from "@dbx-tools/shared-model";
19
-
20
19
  import { DatabricksBackend, type BackendOptions } from "./backend";
21
20
  import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
22
21
  import { startProxyServer } from "./server";
23
22
 
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
23
  /**
51
24
  * Default terminal client for `chat`, launched via `bunx`. OpenHarness is an
52
25
  * OpenAI-compatible TUI that reads the endpoint straight from the env we inject
@@ -168,10 +141,10 @@ export function buildProgram(): Command {
168
141
  // strings. `chat` = OpenAI chat/completions + Responses (what an agent
169
142
  // like Codex needs); `embedding` = vector endpoints; `tools` = whether the
170
143
  // 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
- });
144
+ const enriched = endpoints.map((endpoint) => ({
145
+ ...endpoint,
146
+ capabilities: classify.endpointCapabilities(endpoint),
147
+ }));
175
148
  const out = opts.chat ? enriched.filter((e) => e.capabilities.chat) : enriched;
176
149
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
177
150
  });
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
- import {
34
- chatToResponse,
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.task === "llm/v1/chat")
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), {
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
-