@dbx-tools/shared-model 0.3.20 → 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 +60 -3
- package/index.ts +5 -1
- package/package.json +2 -2
- package/src/classify.ts +28 -0
- package/src/openai-chat.ts +153 -0
- package/src/openai-responses.ts +446 -0
- package/test/classify.test.ts +53 -1
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,
|
|
97
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/test/classify.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
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
|