@dbx-tools/shared-model 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
@@ -14,9 +14,12 @@ Key features:
14
14
  - Browser-safe zod schemas for lookup requests, endpoint summaries, ranked
15
15
  results, and profile metadata.
16
16
  - Endpoint classifier that groups serving endpoints by score profile and family
17
- naming conventions.
17
+ naming conventions, plus `classify.endpointCapabilities` for chat / embedding
18
+ / tool-calling capability flags.
18
19
  - Version/family parsing helpers for model catalogues and tests.
19
20
  - Human-readable endpoint display names via `display.toModelDisplayName`.
21
+ - OpenAI wire contracts: chat message/tool-call types plus a Responses API
22
+ translator, so a proxy, a route, and a UI all speak the same payload shapes.
20
23
  - Types that match the server selection API without depending on the Databricks
21
24
  SDK.
22
25
 
@@ -89,12 +92,66 @@ const version = classify.versionTuple("llama-3-1-70b");
89
92
  Family parsing helps callers bucket custom lists or explain why an endpoint
90
93
  landed in a class before the live workspace scores are available.
91
94
 
95
+ ## Ask What An Endpoint Can Do
96
+
97
+ ```ts
98
+ const caps = classify.endpointCapabilities(endpoint);
99
+ if (caps.chat) offerInChatPicker(endpoint);
100
+ ```
101
+
102
+ Capability comes from the Databricks task hint (`llm/v1/chat` /
103
+ `llm/v1/embeddings`) with the classified class as the fallback for endpoints
104
+ Databricks left untagged. Filter on this rather than comparing raw `task`
105
+ strings, so every picker, CLI, and route agrees on what "chat-capable" means.
106
+
107
+ ## Translate The OpenAI Responses API
108
+
109
+ ```ts
110
+ import { openaiResponses } from "@dbx-tools/shared-model";
111
+
112
+ const { chat, stream } = openaiResponses.responsesToChat(requestBody);
113
+ // ... POST `chat` to the endpoint's invocations URL ...
114
+ const response = openaiResponses.chatToResponse(completion, modelId);
115
+ ```
116
+
117
+ Databricks serving endpoints speak Chat Completions; some clients (the Codex
118
+ CLI, for one) speak only the Responses API. `openaiResponses` bridges the two in
119
+ both directions, including a streaming translator
120
+ (`createResponsesStreamTranslator`) that lifts `chat.completion.chunk` SSE into
121
+ the Responses event stream, and `readResponsesOutput` for pulling the answer and
122
+ its citations back out of a native Responses reply. Pure functions over plain
123
+ JSON, so the same translation runs in a proxy, a server route, or a test.
124
+
125
+ ## Strip Fields Databricks Rejects
126
+
127
+ ```ts
128
+ import { openaiChat } from "@dbx-tools/shared-model";
129
+
130
+ const dropped = openaiChat.stripUnsupportedChatFields(body); // mutates `body`
131
+ ```
132
+
133
+ Databricks Model Serving validates the chat body strictly: one unrecognized
134
+ top-level key fails the entire turn rather than being ignored. An OpenAI client
135
+ that sends `parallel_tool_calls` gets back
136
+ `parallel_tool_calls: Extra inputs are not permitted` and no completion at all.
137
+
138
+ `stripUnsupportedChatFields` deletes the known offenders in place and returns
139
+ what it removed, so a caller can log the difference. Reach for it on any path
140
+ that forwards a client body largely as-is; a translator that copies fields
141
+ one-by-one (`openaiResponses.responsesToChat`) already can't leak them. Pass
142
+ `extra` names to cover a workspace that rejects something not yet in
143
+ `openaiChat.UNSUPPORTED_CHAT_FIELDS`.
144
+
92
145
  ## Modules
93
146
 
94
147
  - `model` - `ModelClass`, zod schemas, and inferred types for profiles,
95
148
  endpoint summaries, lookup requests, and ranked results.
96
- - `classify` - family parsing, version tuple parsing, and endpoint
97
- classification.
149
+ - `classify` - family parsing, version tuple parsing, endpoint classification,
150
+ and capability flags.
151
+ - `display` - human-readable endpoint labels.
152
+ - `openaiChat` - Chat Completions message / tool-call types,
153
+ `chatContentToText`, and `stripUnsupportedChatFields`.
154
+ - `openaiResponses` - Responses API translation, in both directions.
98
155
 
99
156
  Server-side selection, cache, and fuzzy endpoint matching are in
100
157
  [`@dbx-tools/model`](../../node/model).
package/index.ts CHANGED
@@ -5,5 +5,9 @@
5
5
  export * as classify from "./src/classify";
6
6
  export * as display from "./src/display";
7
7
  export * as model from "./src/model";
8
- export type { FamilyClass } from "./src/classify";
8
+ export * as openaiChat from "./src/openai-chat";
9
+ export * as openaiResponses from "./src/openai-responses";
10
+ export type { EndpointCapabilities, FamilyClass } from "./src/classify";
9
11
  export type { ModelProfile, ServingEndpointSummary, ModelQuery, RankedModel } from "./src/model";
12
+ export type { ChatRole, ChatContentPart, ChatToolCallFunction, ChatToolCall, ChatMessage, ChatContentToTextOptions } from "./src/openai-chat";
13
+ export type { ResponsesStreamTranslator, ResponsesCitation, ResponsesOutput } from "./src/openai-responses";
package/package.json CHANGED
@@ -12,14 +12,14 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "zod": "^4.3.6",
15
- "@dbx-tools/shared-core": "0.3.21"
15
+ "@dbx-tools/shared-core": "0.3.22"
16
16
  },
17
17
  "main": "index.ts",
18
18
  "license": "UNLICENSED",
19
19
  "publishConfig": {
20
20
  "access": "public"
21
21
  },
22
- "version": "0.3.21",
22
+ "version": "0.3.22",
23
23
  "types": "index.ts",
24
24
  "type": "module",
25
25
  "exports": {
package/src/classify.ts CHANGED
@@ -34,6 +34,34 @@ const CHAT_TASK = "llm/v1/chat";
34
34
  /** Task hint Databricks stamps on embedding endpoints. */
35
35
  const EMBEDDING_TASK = "llm/v1/embeddings";
36
36
 
37
+ /** What an endpoint can be asked to do, as derived by {@link endpointCapabilities}. */
38
+ export interface EndpointCapabilities {
39
+ /** OpenAI chat/completions + Responses: the surface a chat agent needs. */
40
+ chat: boolean;
41
+ /** Embedding (vector) endpoint. */
42
+ embedding: boolean;
43
+ /** Function / tool calling. Every chat endpoint on Databricks supports it. */
44
+ tools: boolean;
45
+ }
46
+
47
+ /**
48
+ * Derive an endpoint's capabilities from its Databricks task hint and its
49
+ * classified {@link ModelClass}, so consumers filter on capability instead of
50
+ * re-deriving it from raw `task` / `class` strings.
51
+ *
52
+ * Either signal alone is enough: the task hint is authoritative when present,
53
+ * and the class covers endpoints Databricks left untasked but the classifier
54
+ * recognized. Embedding wins over chat when both point at it, since the two are
55
+ * not interchangeable.
56
+ */
57
+ export function endpointCapabilities(endpoint: ServingEndpointSummary): EndpointCapabilities {
58
+ const embedding = endpoint.task === EMBEDDING_TASK || endpoint.class === ModelClass.Embedding;
59
+ // Every non-embedding class is a chat band, so "classified but not embedding"
60
+ // is the class-side test for chat capability.
61
+ const chat = !embedding && (endpoint.task === CHAT_TASK || endpoint.class !== undefined);
62
+ return { chat, embedding, tools: chat };
63
+ }
64
+
37
65
  /** Family-heuristic classification of a single endpoint name. */
38
66
  export interface FamilyClass {
39
67
  /** Chat capability band the family maps to (never embedding). */
@@ -0,0 +1,153 @@
1
+ /**
2
+ * OpenAI Chat Completions wire shapes, as Databricks Model Serving speaks them.
3
+ *
4
+ * These are the request/reply payload types for `/chat/completions` and a
5
+ * serving endpoint's `invocations` URL. They are deliberately declared here
6
+ * rather than imported from the OpenAI or AI SDK packages: both keep these
7
+ * fields under internal namespaces that are not part of their public API,
8
+ * whereas the wire payload itself is the stable contract every caller in this
9
+ * repo actually codes against.
10
+ *
11
+ * Pure types plus one text-flattening helper - no zod, no runtime deps - so a
12
+ * browser client, a Node proxy, and an AppKit plugin can all agree on the shape
13
+ * without any of them pulling in the others' dependencies.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ /**
19
+ * The roles OpenAI defines for a chat turn. {@link ChatMessage.role} is typed as
20
+ * a plain string rather than this union, because providers add their own (for
21
+ * example Databricks-hosted Claude replays a `"reasoning"` turn); use this when
22
+ * you want the standard set named.
23
+ */
24
+ export type ChatRole = "system" | "developer" | "user" | "assistant" | "tool";
25
+
26
+ /**
27
+ * One entry of a structured `content` array. Only `type` and `text` are read
28
+ * here; the index signature keeps provider-specific parts (images, thinking
29
+ * blocks, cache markers) intact through a parse/serialize round trip.
30
+ */
31
+ export interface ChatContentPart {
32
+ type?: string;
33
+ text?: string;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ /** The function a tool call invokes, with its arguments as a JSON string. */
38
+ export interface ChatToolCallFunction {
39
+ name: string;
40
+ /** JSON-encoded argument object. Streamed in fragments, so assemble before parsing. */
41
+ arguments: string;
42
+ }
43
+
44
+ /** One tool call attached to an assistant turn. */
45
+ export interface ChatToolCall {
46
+ id: string;
47
+ /** `"function"` in practice; widened so an unrecognized type round-trips. */
48
+ type: string;
49
+ function: ChatToolCallFunction;
50
+ }
51
+
52
+ /**
53
+ * A single chat message. `content` is nullable because an assistant turn that
54
+ * only calls tools carries `content: null`.
55
+ */
56
+ export interface ChatMessage {
57
+ /** See {@link ChatRole} for the standard values; widened for provider extensions. */
58
+ role: string;
59
+ content?: string | ChatContentPart[] | null;
60
+ tool_calls?: ChatToolCall[];
61
+ /** Set on a `tool` turn, keying it back to the call it answers. */
62
+ tool_call_id?: string;
63
+ name?: string;
64
+ }
65
+
66
+ /**
67
+ * Top-level request fields an OpenAI client may send that Databricks Model
68
+ * Serving rejects outright, failing the whole turn rather than ignoring them.
69
+ *
70
+ * Databricks validates the chat body strictly, so an unrecognized key comes
71
+ * back as `parallel_tool_calls: Extra inputs are not permitted` (the
72
+ * gateway's pydantic validation) or `json: unknown field "parallel_tool_calls"`
73
+ * (its strict JSON decode), depending on the endpoint. Everything listed here
74
+ * is either OpenAI-platform bookkeeping with no bearing on the completion, or -
75
+ * in the case of `parallel_tool_calls` - a real setting Databricks has no way
76
+ * to accept, so dropping it is the only way the request can succeed at all.
77
+ *
78
+ * Callers that translate a request field-by-field (an allowlist, as
79
+ * `openaiResponses.responsesToChat` does) never need this; it exists for the
80
+ * paths that forward a client body largely as-is.
81
+ */
82
+ export const UNSUPPORTED_CHAT_FIELDS: readonly string[] = [
83
+ // Tool-calling concurrency hint. Databricks rejects it; the upstream provider
84
+ // decides parallelism itself.
85
+ "parallel_tool_calls",
86
+ // Response-persistence and bookkeeping fields for OpenAI's own platform.
87
+ "store",
88
+ "metadata",
89
+ "service_tier",
90
+ "prompt_cache_key",
91
+ "safety_identifier",
92
+ ];
93
+
94
+ /**
95
+ * Delete the fields Databricks rejects from a chat request body, in place.
96
+ * Returns the names actually removed so a caller can log what it dropped.
97
+ *
98
+ * @param body - Parsed chat request body. Mutated.
99
+ * @param extra - Additional field names to drop, for a workspace or endpoint
100
+ * that rejects something not yet in {@link UNSUPPORTED_CHAT_FIELDS}.
101
+ */
102
+ export function stripUnsupportedChatFields(
103
+ body: Record<string, unknown>,
104
+ extra: readonly string[] = [],
105
+ ): string[] {
106
+ const dropped: string[] = [];
107
+ for (const field of [...UNSUPPORTED_CHAT_FIELDS, ...extra]) {
108
+ if (!(field in body)) continue;
109
+ delete body[field];
110
+ dropped.push(field);
111
+ }
112
+ return dropped;
113
+ }
114
+
115
+ /** Options for {@link chatContentToText}. */
116
+ export interface ChatContentToTextOptions {
117
+ /**
118
+ * Placed between parts. Defaults to `""`, which reassembles a message that was
119
+ * split purely for transport; pass `"\n\n"` when the parts are separate
120
+ * paragraphs that a human will read.
121
+ */
122
+ separator?: string;
123
+ /**
124
+ * Restrict to these `type` values. Defaults to accepting any part carrying
125
+ * `text`, which covers the `input_text` / `output_text` / `text` spellings
126
+ * different APIs use for the same thing.
127
+ */
128
+ types?: readonly string[];
129
+ }
130
+
131
+ /**
132
+ * Flatten a message `content` value to plain text. Accepts the string form and
133
+ * the structured-parts form ({@link ChatContentPart}), and yields `""` for
134
+ * anything else (null, a lone image part, a malformed payload). Typed as
135
+ * `unknown` because most callers are reading a just-parsed JSON body, and the
136
+ * point of this helper is that they do not have to pre-check it.
137
+ */
138
+ export function chatContentToText(
139
+ content: unknown,
140
+ options: ChatContentToTextOptions = {},
141
+ ): string {
142
+ if (typeof content === "string") return content;
143
+ if (!Array.isArray(content)) return "";
144
+ const { separator = "", types } = options;
145
+ const parts: string[] = [];
146
+ for (const raw of content) {
147
+ if (!raw || typeof raw !== "object") continue;
148
+ const part = raw as ChatContentPart;
149
+ if (types && (typeof part.type !== "string" || !types.includes(part.type))) continue;
150
+ if (typeof part.text === "string") parts.push(part.text);
151
+ }
152
+ return parts.join(separator);
153
+ }
@@ -0,0 +1,446 @@
1
+ /**
2
+ * OpenAI Responses API <-> Chat Completions translation.
3
+ *
4
+ * Some clients speak only the Responses API (`POST /v1/responses`) - the Codex
5
+ * CLI is the motivating one - while Databricks serving endpoints speak only
6
+ * Chat Completions (`messages`) at their `invocations` URL. This module bridges
7
+ * the two, in both directions:
8
+ *
9
+ * - {@link responsesToChat} lowers a Responses request body to a chat
10
+ * completions body (instructions -> system message, typed `input` items ->
11
+ * `messages`, `tools`/`tool_choice` carried through, function-call outputs
12
+ * folded back into the transcript).
13
+ * - {@link chatToResponse} lifts a non-streaming chat completion back into a
14
+ * Responses `response` object.
15
+ * - {@link createResponsesStreamTranslator} lifts a streaming chat completion
16
+ * (an OpenAI SSE `chat.completion.chunk` stream) into the Responses SSE
17
+ * event stream those clients consume (`response.created`,
18
+ * `response.output_text.delta`, function-call argument deltas,
19
+ * `response.completed`).
20
+ * - {@link readResponsesOutput} reads the other direction: pull the answer
21
+ * text and citations out of a `response` object returned by an endpoint
22
+ * that speaks Responses natively (the Databricks native web-search tool).
23
+ *
24
+ * Only the surface real clients exercise is translated; unknown fields are
25
+ * ignored rather than rejected, so a newer client degrades instead of breaking.
26
+ *
27
+ * Browser-safe: pure functions over plain JSON, no transport and no Node
28
+ * built-ins, so the same translation runs in a proxy, a server route, or a test.
29
+ *
30
+ * @module
31
+ */
32
+
33
+ import { type ChatMessage, chatContentToText } from "./openai-chat";
34
+
35
+ /**
36
+ * Lower a Responses request body to a chat-completions body. Returns the chat
37
+ * body plus whether the caller asked for streaming (the server needs it to pick
38
+ * the upstream `accept` header and the translation path).
39
+ */
40
+ export function responsesToChat(body: Record<string, unknown>): {
41
+ chat: Record<string, unknown>;
42
+ stream: boolean;
43
+ } {
44
+ const messages: ChatMessage[] = [];
45
+
46
+ // `instructions` becomes the leading system message.
47
+ if (typeof body.instructions === "string" && body.instructions.length > 0) {
48
+ messages.push({ role: "system", content: body.instructions });
49
+ }
50
+
51
+ // `input` is an array of typed items: messages, function_call, and
52
+ // function_call_output. Fold each into the chat transcript.
53
+ const input = Array.isArray(body.input) ? body.input : [];
54
+ for (const raw of input) {
55
+ if (!raw || typeof raw !== "object") continue;
56
+ const item = raw as Record<string, unknown>;
57
+ const type = item.type;
58
+
59
+ if (type === "function_call") {
60
+ // A prior tool call the model made; re-attach it to an assistant turn.
61
+ messages.push({
62
+ role: "assistant",
63
+ content: null,
64
+ tool_calls: [
65
+ {
66
+ id: String(item.call_id ?? item.id ?? ""),
67
+ type: "function",
68
+ function: {
69
+ name: String(item.name ?? ""),
70
+ arguments: typeof item.arguments === "string" ? item.arguments : "{}",
71
+ },
72
+ },
73
+ ],
74
+ });
75
+ continue;
76
+ }
77
+
78
+ if (type === "function_call_output") {
79
+ // The tool's result, keyed back to the call it answers.
80
+ messages.push({
81
+ role: "tool",
82
+ tool_call_id: String(item.call_id ?? item.id ?? ""),
83
+ content: typeof item.output === "string" ? item.output : chatContentToText(item.output),
84
+ });
85
+ continue;
86
+ }
87
+
88
+ // Default: a message item with a role and typed content.
89
+ const role = typeof item.role === "string" ? item.role : "user";
90
+ // Chat has no "developer" role; treat it as system (its intent here).
91
+ const chatRole = role === "developer" ? "system" : role;
92
+ messages.push({ role: chatRole, content: chatContentToText(item.content) });
93
+ }
94
+
95
+ const chat: Record<string, unknown> = {
96
+ model: body.model,
97
+ messages,
98
+ stream: body.stream === true,
99
+ };
100
+
101
+ // Carry through function-calling config when present. Responses function
102
+ // tools are FLAT (`{type:"function", name, description, parameters}`); chat
103
+ // wants them NESTED (`{type:"function", function:{name, …}}`). Clients also
104
+ // send built-in tool types (local_shell, web_search, …) that Databricks chat
105
+ // rejects ("Missing 'function' in the tool specification"), so we translate
106
+ // only function tools and drop the rest.
107
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
108
+ const chatTools = body.tools
109
+ .map((t) => {
110
+ const tool = t as Record<string, unknown>;
111
+ // Already chat-nested (`function` object present): pass through.
112
+ if (tool.type === "function" && tool.function && typeof tool.function === "object") {
113
+ return tool;
114
+ }
115
+ // Flat Responses function tool: nest it.
116
+ if (tool.type === "function" && typeof tool.name === "string") {
117
+ return {
118
+ type: "function",
119
+ function: {
120
+ name: tool.name,
121
+ ...(tool.description ? { description: tool.description } : {}),
122
+ ...(tool.parameters ? { parameters: tool.parameters } : {}),
123
+ },
124
+ };
125
+ }
126
+ return undefined; // non-function built-in tool: unsupported upstream, drop
127
+ })
128
+ .filter((t): t is Record<string, unknown> => t !== undefined);
129
+ if (chatTools.length > 0) {
130
+ chat.tools = chatTools;
131
+ if (body.tool_choice !== undefined) chat.tool_choice = body.tool_choice;
132
+ // NOTE: do NOT forward `parallel_tool_calls`. Clients send it, but
133
+ // Databricks Model Serving's chat API rejects unknown fields
134
+ // ("parallel_tool_calls: Extra inputs are not permitted"), which would
135
+ // fail the whole turn. Drop it.
136
+ }
137
+ }
138
+
139
+ return { chat, stream: body.stream === true };
140
+ }
141
+
142
+ /** Lift a non-streaming chat completion JSON into a Responses `response` object. */
143
+ export function chatToResponse(chat: Record<string, unknown>, model: string): unknown {
144
+ const choices = Array.isArray(chat.choices) ? chat.choices : [];
145
+ const first = (choices[0] ?? {}) as Record<string, unknown>;
146
+ const message = (first.message ?? {}) as Record<string, unknown>;
147
+ const output: unknown[] = [];
148
+
149
+ const text = typeof message.content === "string" ? message.content : "";
150
+ if (text) {
151
+ output.push({
152
+ type: "message",
153
+ role: "assistant",
154
+ content: [{ type: "output_text", text }],
155
+ });
156
+ }
157
+
158
+ // Surface any tool calls as Responses `function_call` output items.
159
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
160
+ for (const raw of toolCalls) {
161
+ const call = raw as Record<string, unknown>;
162
+ const fn = (call.function ?? {}) as Record<string, unknown>;
163
+ output.push({
164
+ type: "function_call",
165
+ call_id: String(call.id ?? ""),
166
+ name: String(fn.name ?? ""),
167
+ arguments: typeof fn.arguments === "string" ? fn.arguments : "{}",
168
+ });
169
+ }
170
+
171
+ const usage = (chat.usage ?? {}) as Record<string, unknown>;
172
+ return {
173
+ id: String(chat.id ?? "resp"),
174
+ object: "response",
175
+ created_at: typeof chat.created === "number" ? chat.created : 0,
176
+ model,
177
+ status: "completed",
178
+ output,
179
+ usage: {
180
+ input_tokens: Number(usage.prompt_tokens ?? 0),
181
+ output_tokens: Number(usage.completion_tokens ?? 0),
182
+ total_tokens: Number(usage.total_tokens ?? 0),
183
+ },
184
+ };
185
+ }
186
+
187
+ /** One Server-Sent Event, ready to write to the client. */
188
+ function sse(event: string, data: unknown): string {
189
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
190
+ }
191
+
192
+ /**
193
+ * Incremental chat-chunk -> Responses-SSE translator. `feed` and `finish` both
194
+ * return the SSE bytes to forward, or `""` when a chunk produced no event.
195
+ */
196
+ export interface ResponsesStreamTranslator {
197
+ /** Translate one upstream `chat.completion.chunk` object. */
198
+ feed(chunk: Record<string, unknown>): string;
199
+ /** Close any open items and emit `response.completed`. Call once, at end of stream. */
200
+ finish(): string;
201
+ }
202
+
203
+ /**
204
+ * Translate an upstream chat-completions SSE stream into the Responses SSE
205
+ * event stream a Responses client consumes.
206
+ *
207
+ * Upstream chunks are `chat.completion.chunk` objects whose `choices[0].delta`
208
+ * carries incremental `content` (assistant text) and/or `tool_calls` (function
209
+ * calls, streamed by `index` with partial `arguments`). We emit the Responses
210
+ * lifecycle around them:
211
+ *
212
+ * response.created
213
+ * -> per text run: output_item.added → output_text.delta* → output_item.done
214
+ * -> per tool call: output_item.added → function_call_arguments.delta*
215
+ * → function_call_arguments.done → output_item.done
216
+ * response.completed (with the assembled final `response` object)
217
+ *
218
+ * The translator is intentionally tolerant: malformed/keepalive lines yield
219
+ * nothing.
220
+ */
221
+ export function createResponsesStreamTranslator(
222
+ model: string,
223
+ responseId: string,
224
+ ): ResponsesStreamTranslator {
225
+ let started = false;
226
+ let outputIndex = 0;
227
+
228
+ // Text run state: whether a message item is currently open, and its buffer.
229
+ let textOpen = false;
230
+ let textBuffer = "";
231
+ let textItemId = "";
232
+
233
+ // Tool-call state, keyed by the upstream `tool_calls[].index`.
234
+ interface ToolState {
235
+ outputIndex: number;
236
+ callId: string;
237
+ name: string;
238
+ args: string;
239
+ }
240
+ const tools = new Map<number, ToolState>();
241
+
242
+ const created = () =>
243
+ sse("response.created", {
244
+ type: "response.created",
245
+ response: { id: responseId, object: "response", status: "in_progress", model, output: [] },
246
+ });
247
+
248
+ function openText(): string {
249
+ textOpen = true;
250
+ textItemId = `${responseId}-msg-${outputIndex}`;
251
+ const ev = sse("response.output_item.added", {
252
+ type: "response.output_item.added",
253
+ output_index: outputIndex,
254
+ item: { id: textItemId, type: "message", role: "assistant", content: [] },
255
+ });
256
+ return ev;
257
+ }
258
+
259
+ function closeText(): string {
260
+ if (!textOpen) return "";
261
+ const item = {
262
+ id: textItemId,
263
+ type: "message",
264
+ role: "assistant",
265
+ content: [{ type: "output_text", text: textBuffer }],
266
+ };
267
+ const ev =
268
+ sse("response.output_text.done", {
269
+ type: "response.output_text.done",
270
+ item_id: textItemId,
271
+ output_index: outputIndex,
272
+ content_index: 0,
273
+ text: textBuffer,
274
+ }) +
275
+ sse("response.output_item.done", {
276
+ type: "response.output_item.done",
277
+ output_index: outputIndex,
278
+ item,
279
+ });
280
+ textOpen = false;
281
+ textBuffer = "";
282
+ outputIndex += 1;
283
+ return ev;
284
+ }
285
+
286
+ function feed(chunk: Record<string, unknown>): string {
287
+ let out = "";
288
+ if (!started) {
289
+ started = true;
290
+ out += created();
291
+ }
292
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
293
+ const choice = (choices[0] ?? {}) as Record<string, unknown>;
294
+ const delta = (choice.delta ?? {}) as Record<string, unknown>;
295
+
296
+ // Assistant text deltas.
297
+ if (typeof delta.content === "string" && delta.content.length > 0) {
298
+ if (!textOpen) out += openText();
299
+ textBuffer += delta.content;
300
+ out += sse("response.output_text.delta", {
301
+ type: "response.output_text.delta",
302
+ item_id: textItemId,
303
+ output_index: outputIndex,
304
+ content_index: 0,
305
+ delta: delta.content,
306
+ });
307
+ }
308
+
309
+ // Tool-call deltas: each is keyed by `index`; `arguments` arrives in pieces.
310
+ const toolDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
311
+ for (const rawTc of toolDeltas) {
312
+ const tc = rawTc as Record<string, unknown>;
313
+ const idx = Number(tc.index ?? 0);
314
+ const fn = (tc.function ?? {}) as Record<string, unknown>;
315
+ let state = tools.get(idx);
316
+ if (!state) {
317
+ // First fragment for this call: a text run (if open) must close first so
318
+ // output ordering stays monotonic, then we open a function_call item.
319
+ out += closeText();
320
+ state = {
321
+ outputIndex,
322
+ callId: String(tc.id ?? `${responseId}-call-${idx}`),
323
+ name: String(fn.name ?? ""),
324
+ args: "",
325
+ };
326
+ tools.set(idx, state);
327
+ out += sse("response.output_item.added", {
328
+ type: "response.output_item.added",
329
+ output_index: state.outputIndex,
330
+ item: {
331
+ id: state.callId,
332
+ type: "function_call",
333
+ call_id: state.callId,
334
+ name: state.name,
335
+ arguments: "",
336
+ },
337
+ });
338
+ outputIndex += 1;
339
+ }
340
+ if (typeof fn.name === "string" && fn.name && !state.name) state.name = fn.name;
341
+ if (typeof fn.arguments === "string" && fn.arguments.length > 0) {
342
+ state.args += fn.arguments;
343
+ out += sse("response.function_call_arguments.delta", {
344
+ type: "response.function_call_arguments.delta",
345
+ item_id: state.callId,
346
+ output_index: state.outputIndex,
347
+ delta: fn.arguments,
348
+ });
349
+ }
350
+ }
351
+ return out;
352
+ }
353
+
354
+ function finish(): string {
355
+ let out = closeText();
356
+ // Close any open tool calls, emitting their assembled arguments.
357
+ for (const state of tools.values()) {
358
+ out += sse("response.function_call_arguments.done", {
359
+ type: "response.function_call_arguments.done",
360
+ item_id: state.callId,
361
+ output_index: state.outputIndex,
362
+ arguments: state.args,
363
+ });
364
+ out += sse("response.output_item.done", {
365
+ type: "response.output_item.done",
366
+ output_index: state.outputIndex,
367
+ item: {
368
+ id: state.callId,
369
+ type: "function_call",
370
+ call_id: state.callId,
371
+ name: state.name,
372
+ arguments: state.args,
373
+ },
374
+ });
375
+ }
376
+ out += sse("response.completed", {
377
+ type: "response.completed",
378
+ response: { id: responseId, object: "response", status: "completed", model },
379
+ });
380
+ return out;
381
+ }
382
+
383
+ return { feed, finish };
384
+ }
385
+
386
+ /** A source the model cited, as carried by a Responses content-part annotation. */
387
+ export interface ResponsesCitation {
388
+ url: string;
389
+ title?: string;
390
+ }
391
+
392
+ /** What {@link readResponsesOutput} pulls out of a `response` object. */
393
+ export interface ResponsesOutput {
394
+ /** The assistant's answer, with the output items concatenated. */
395
+ text: string;
396
+ /** Cited sources in first-seen order, deduplicated by URL. */
397
+ citations: ResponsesCitation[];
398
+ }
399
+
400
+ /**
401
+ * Read the answer text and cited sources out of a Responses `response` payload.
402
+ *
403
+ * This is the inverse of {@link chatToResponse}: it consumes what an endpoint
404
+ * speaking Responses natively returns, rather than producing it. The Databricks
405
+ * native web-search tool answers this way, with each source attached as a
406
+ * `url_citation` annotation on the content part that used it.
407
+ *
408
+ * Prefers the flattened `output_text` when the payload carries it, and falls
409
+ * back to walking `output[].content[].text`. Every field is optional upstream,
410
+ * so a payload missing any of it yields empty results rather than throwing.
411
+ */
412
+ export function readResponsesOutput(payload: Record<string, unknown>): ResponsesOutput {
413
+ const output = Array.isArray(payload.output) ? (payload.output as unknown[]) : [];
414
+ const texts: string[] = [];
415
+ const citations: ResponsesCitation[] = [];
416
+ const seen = new Set<string>();
417
+
418
+ for (const rawItem of output) {
419
+ if (!rawItem || typeof rawItem !== "object") continue;
420
+ const content = (rawItem as Record<string, unknown>).content;
421
+ if (!Array.isArray(content)) continue;
422
+ for (const rawPart of content) {
423
+ if (!rawPart || typeof rawPart !== "object") continue;
424
+ const part = rawPart as Record<string, unknown>;
425
+ const text = str(part.text);
426
+ if (text) texts.push(text);
427
+ const annotations = Array.isArray(part.annotations) ? part.annotations : [];
428
+ for (const rawAnn of annotations) {
429
+ if (!rawAnn || typeof rawAnn !== "object") continue;
430
+ const ann = rawAnn as Record<string, unknown>;
431
+ const url = str(ann.url);
432
+ if (!url || seen.has(url)) continue;
433
+ seen.add(url);
434
+ const title = str(ann.title);
435
+ citations.push({ url, ...(title ? { title } : {}) });
436
+ }
437
+ }
438
+ }
439
+
440
+ return { text: str(payload.output_text) || texts.join("\n").trim(), citations };
441
+ }
442
+
443
+ /** Coerce an unknown JSON value to a trimmed string, or `""` when it isn't one. */
444
+ function str(value: unknown): string {
445
+ return typeof value === "string" ? value.trim() : "";
446
+ }
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import { classifyByFamily, classifyEndpoints, versionTuple } from "../src/classify";
3
+ import {
4
+ classifyByFamily,
5
+ classifyEndpoints,
6
+ endpointCapabilities,
7
+ versionTuple,
8
+ } from "../src/classify";
4
9
  import { ModelClass, type ModelProfile, type ServingEndpointSummary } from "../src/model";
5
10
 
6
11
  const CHAT_TASK = "llm/v1/chat";
@@ -49,6 +54,53 @@ describe("versionTuple", () => {
49
54
  });
50
55
  });
51
56
 
57
+ describe("endpointCapabilities", () => {
58
+ it("reads capability off the task hint", () => {
59
+ assert.deepEqual(endpointCapabilities(chat("databricks-gpt-5-5")), {
60
+ chat: true,
61
+ embedding: false,
62
+ tools: true,
63
+ });
64
+ assert.deepEqual(endpointCapabilities(embedding("databricks-gte-large-en")), {
65
+ chat: false,
66
+ embedding: true,
67
+ tools: false,
68
+ });
69
+ });
70
+
71
+ it("falls back to the classified class when the endpoint carries no task", () => {
72
+ assert.equal(
73
+ endpointCapabilities({ name: "custom", class: ModelClass.ChatFast }).chat,
74
+ true,
75
+ );
76
+ assert.equal(
77
+ endpointCapabilities({ name: "custom", class: ModelClass.Embedding }).embedding,
78
+ true,
79
+ );
80
+ });
81
+
82
+ it("keeps an unclassified, untasked endpoint out of both buckets", () => {
83
+ assert.deepEqual(endpointCapabilities({ name: "mystery" }), {
84
+ chat: false,
85
+ embedding: false,
86
+ tools: false,
87
+ });
88
+ });
89
+
90
+ it("lets embedding win when task and class disagree", () => {
91
+ const conflicted: ServingEndpointSummary = {
92
+ name: "databricks-gte-large-en",
93
+ task: EMBEDDING_TASK,
94
+ class: ModelClass.ChatFast,
95
+ };
96
+ assert.deepEqual(endpointCapabilities(conflicted), {
97
+ chat: false,
98
+ embedding: true,
99
+ tools: false,
100
+ });
101
+ });
102
+ });
103
+
52
104
  describe("classifyEndpoints", () => {
53
105
  it("buckets scored models by quality quantiles, best-first", () => {
54
106
  // Three clear quality bands (gaps keep the 1/3 and 2/3 quantiles