@dbx-tools/shared-model 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -3
- package/index.ts +5 -1
- package/package.json +2 -2
- package/src/classify.ts +28 -0
- package/src/display.ts +1 -5
- package/src/openai-chat.ts +153 -0
- package/src/openai-responses.ts +446 -0
- package/test/classify.test.ts +53 -1
- package/test/display.test.ts +45 -0
- package/test/openai-chat.test.ts +80 -0
- package/test/openai-responses.test.ts +242 -0
- package/test/tsconfig.json +14 -0
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.23"
|
|
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.23",
|
|
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). */
|
package/src/display.ts
CHANGED
|
@@ -103,11 +103,7 @@ function renderSegment(segment: string): string {
|
|
|
103
103
|
const token = tokens[i]!;
|
|
104
104
|
const lower = token.toLowerCase();
|
|
105
105
|
// Glue a size unit onto the preceding number: `120` + `b` -> `120B`.
|
|
106
|
-
if (
|
|
107
|
-
SIZE_UNITS.has(lower) &&
|
|
108
|
-
out.length > 0 &&
|
|
109
|
-
/^\d+$/.test(out[out.length - 1]!)
|
|
110
|
-
) {
|
|
106
|
+
if (SIZE_UNITS.has(lower) && out.length > 0 && /^\d+$/.test(out[out.length - 1]!)) {
|
|
111
107
|
out[out.length - 1] = `${out[out.length - 1]}${lower.toUpperCase()}`;
|
|
112
108
|
continue;
|
|
113
109
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { toModelDisplayName } from "../src/display";
|
|
4
|
+
|
|
5
|
+
describe("toModelDisplayName", () => {
|
|
6
|
+
it("strips a leading databricks prefix and dots the version", () => {
|
|
7
|
+
assert.equal(toModelDisplayName("databricks-claude-sonnet-4-6"), "Claude Sonnet 4.6");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("strips the system.ai namespace and uppercases acronyms", () => {
|
|
11
|
+
assert.equal(toModelDisplayName("system.ai.bge_large_en"), "BGE Large En");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("uppercases GPT/OSS and glues the size unit", () => {
|
|
15
|
+
assert.equal(toModelDisplayName("databricks-gpt-oss-120b"), "GPT OSS 120B");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("restores AI casing", () => {
|
|
19
|
+
assert.equal(toModelDisplayName("databricks-ai-router"), "AI Router");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("joins multi-part version runs with dots", () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
toModelDisplayName("databricks-meta-llama-3-3-70b-instruct"),
|
|
25
|
+
"Meta Llama 3.3 70B Instruct",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("prefers a Databricks-provided name verbatim", () => {
|
|
30
|
+
assert.equal(
|
|
31
|
+
toModelDisplayName("databricks-claude-sonnet-4-6", "Claude Sonnet 4.6 (Preview)"),
|
|
32
|
+
"Claude Sonnet 4.6 (Preview)",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("ignores a blank provided name and falls back to the derived label", () => {
|
|
37
|
+
assert.equal(toModelDisplayName("databricks-gpt-5-5", " "), "GPT 5.5");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("title-cases a bare vendor token when nothing else remains", () => {
|
|
41
|
+
// Only leading vendor prefixes are stripped, and never the last
|
|
42
|
+
// remaining token, so a name that is *only* a vendor word stays.
|
|
43
|
+
assert.equal(toModelDisplayName("databricks"), "Databricks");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
UNSUPPORTED_CHAT_FIELDS,
|
|
5
|
+
chatContentToText,
|
|
6
|
+
stripUnsupportedChatFields,
|
|
7
|
+
} from "../src/openai-chat";
|
|
8
|
+
|
|
9
|
+
describe("chatContentToText", () => {
|
|
10
|
+
it("passes a string through and flattens typed parts", () => {
|
|
11
|
+
assert.equal(chatContentToText("hello"), "hello");
|
|
12
|
+
assert.equal(
|
|
13
|
+
chatContentToText([{ type: "input_text", text: "a" }, { type: "output_text", text: "b" }]),
|
|
14
|
+
"ab",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("honors a separator and a type filter", () => {
|
|
19
|
+
const content = [
|
|
20
|
+
{ type: "text", text: "one" },
|
|
21
|
+
{ type: "thinking", text: "hidden" },
|
|
22
|
+
{ type: "text", text: "two" },
|
|
23
|
+
];
|
|
24
|
+
assert.equal(chatContentToText(content, { separator: "\n\n", types: ["text"] }), "one\n\ntwo");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("yields empty string for anything it can't read", () => {
|
|
28
|
+
assert.equal(chatContentToText(null), "");
|
|
29
|
+
assert.equal(chatContentToText(undefined), "");
|
|
30
|
+
assert.equal(chatContentToText([{ type: "image_url" }]), "");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("stripUnsupportedChatFields", () => {
|
|
35
|
+
it("drops parallel_tool_calls, the field Databricks rejects outright", () => {
|
|
36
|
+
const body: Record<string, unknown> = {
|
|
37
|
+
model: "databricks-claude-opus-4-8",
|
|
38
|
+
messages: [],
|
|
39
|
+
parallel_tool_calls: false,
|
|
40
|
+
};
|
|
41
|
+
assert.deepEqual(stripUnsupportedChatFields(body), ["parallel_tool_calls"]);
|
|
42
|
+
assert.deepEqual(body, { model: "databricks-claude-opus-4-8", messages: [] });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("drops a field present with a falsy or null value", () => {
|
|
46
|
+
const body: Record<string, unknown> = { store: false, metadata: null };
|
|
47
|
+
assert.deepEqual(stripUnsupportedChatFields(body).sort(), ["metadata", "store"]);
|
|
48
|
+
assert.deepEqual(body, {});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves a supported body untouched and reports nothing dropped", () => {
|
|
52
|
+
const body: Record<string, unknown> = {
|
|
53
|
+
model: "m",
|
|
54
|
+
messages: [{ role: "user", content: "hi" }],
|
|
55
|
+
stream: true,
|
|
56
|
+
tools: [],
|
|
57
|
+
tool_choice: "auto",
|
|
58
|
+
temperature: 0.2,
|
|
59
|
+
max_tokens: 64,
|
|
60
|
+
};
|
|
61
|
+
const before = structuredClone(body);
|
|
62
|
+
assert.deepEqual(stripUnsupportedChatFields(body), []);
|
|
63
|
+
assert.deepEqual(body, before);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("drops caller-supplied extra fields alongside the built-in list", () => {
|
|
67
|
+
const body: Record<string, unknown> = { store: true, future_field: 1, keep: 2 };
|
|
68
|
+
assert.deepEqual(stripUnsupportedChatFields(body, ["future_field"]), [
|
|
69
|
+
"store",
|
|
70
|
+
"future_field",
|
|
71
|
+
]);
|
|
72
|
+
assert.deepEqual(body, { keep: 2 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("covers every name it advertises", () => {
|
|
76
|
+
const body = Object.fromEntries(UNSUPPORTED_CHAT_FIELDS.map((field) => [field, 1]));
|
|
77
|
+
assert.deepEqual(stripUnsupportedChatFields(body), [...UNSUPPORTED_CHAT_FIELDS]);
|
|
78
|
+
assert.deepEqual(body, {});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { ChatMessage } from "../src/openai-chat";
|
|
4
|
+
import {
|
|
5
|
+
chatToResponse,
|
|
6
|
+
createResponsesStreamTranslator,
|
|
7
|
+
readResponsesOutput,
|
|
8
|
+
responsesToChat,
|
|
9
|
+
} from "../src/openai-responses";
|
|
10
|
+
|
|
11
|
+
/** The `messages` a lowered Responses body produced, typed for assertions. */
|
|
12
|
+
function messagesOf(body: Record<string, unknown>): ChatMessage[] {
|
|
13
|
+
const { chat } = responsesToChat(body);
|
|
14
|
+
return chat.messages as ChatMessage[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Event names, in order, from a block of SSE text. */
|
|
18
|
+
function eventNames(sse: string): string[] {
|
|
19
|
+
return sse
|
|
20
|
+
.split("\n")
|
|
21
|
+
.filter((line) => line.startsWith("event: "))
|
|
22
|
+
.map((line) => line.slice("event: ".length));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parsed `data:` payloads from a block of SSE text. */
|
|
26
|
+
function eventData(sse: string): Record<string, unknown>[] {
|
|
27
|
+
return sse
|
|
28
|
+
.split("\n")
|
|
29
|
+
.filter((line) => line.startsWith("data: "))
|
|
30
|
+
.map((line) => JSON.parse(line.slice("data: ".length)) as Record<string, unknown>);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("responsesToChat", () => {
|
|
34
|
+
it("lowers instructions and message items into a chat transcript", () => {
|
|
35
|
+
const messages = messagesOf({
|
|
36
|
+
instructions: "be terse",
|
|
37
|
+
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
|
38
|
+
});
|
|
39
|
+
assert.deepEqual(messages, [
|
|
40
|
+
{ role: "system", content: "be terse" },
|
|
41
|
+
{ role: "user", content: "hi" },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("maps the developer role to system (chat has no developer role)", () => {
|
|
46
|
+
const [first] = messagesOf({ input: [{ role: "developer", content: "policy" }] });
|
|
47
|
+
assert.equal(first?.role, "system");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("re-attaches a prior function_call to an assistant turn", () => {
|
|
51
|
+
const [first] = messagesOf({
|
|
52
|
+
input: [{ type: "function_call", call_id: "c1", name: "lookup", arguments: '{"q":1}' }],
|
|
53
|
+
});
|
|
54
|
+
assert.equal(first?.role, "assistant");
|
|
55
|
+
assert.equal(first?.content, null);
|
|
56
|
+
assert.deepEqual(first?.tool_calls, [
|
|
57
|
+
{ id: "c1", type: "function", function: { name: "lookup", arguments: '{"q":1}' } },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keys a function_call_output back to the call it answers", () => {
|
|
62
|
+
const [first] = messagesOf({
|
|
63
|
+
input: [{ type: "function_call_output", call_id: "c1", output: "42" }],
|
|
64
|
+
});
|
|
65
|
+
assert.deepEqual(first, { role: "tool", tool_call_id: "c1", content: "42" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("nests flat Responses function tools and drops built-in tool types", () => {
|
|
69
|
+
const { chat } = responsesToChat({
|
|
70
|
+
input: [],
|
|
71
|
+
tools: [
|
|
72
|
+
{ type: "function", name: "lookup", description: "d", parameters: { type: "object" } },
|
|
73
|
+
{ type: "local_shell" },
|
|
74
|
+
{ type: "web_search" },
|
|
75
|
+
],
|
|
76
|
+
tool_choice: "auto",
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(chat.tools, [
|
|
79
|
+
{
|
|
80
|
+
type: "function",
|
|
81
|
+
function: { name: "lookup", description: "d", parameters: { type: "object" } },
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
assert.equal(chat.tool_choice, "auto");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("never forwards parallel_tool_calls (Databricks rejects unknown fields)", () => {
|
|
88
|
+
const { chat } = responsesToChat({
|
|
89
|
+
input: [],
|
|
90
|
+
parallel_tool_calls: true,
|
|
91
|
+
tools: [{ type: "function", name: "lookup" }],
|
|
92
|
+
});
|
|
93
|
+
assert.ok(!("parallel_tool_calls" in chat));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("reports the streaming flag", () => {
|
|
97
|
+
assert.equal(responsesToChat({ input: [] }).stream, false);
|
|
98
|
+
assert.equal(responsesToChat({ input: [], stream: true }).stream, true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("chatToResponse", () => {
|
|
103
|
+
it("lifts assistant text, tool calls, and usage into a response object", () => {
|
|
104
|
+
const response = chatToResponse(
|
|
105
|
+
{
|
|
106
|
+
id: "chatcmpl-1",
|
|
107
|
+
created: 1700000000,
|
|
108
|
+
choices: [
|
|
109
|
+
{
|
|
110
|
+
message: {
|
|
111
|
+
content: "done",
|
|
112
|
+
tool_calls: [{ id: "c1", function: { name: "lookup", arguments: '{"q":1}' } }],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
|
|
117
|
+
},
|
|
118
|
+
"databricks-claude-opus-4-8",
|
|
119
|
+
) as Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
assert.equal(response.id, "chatcmpl-1");
|
|
122
|
+
assert.equal(response.status, "completed");
|
|
123
|
+
assert.equal(response.model, "databricks-claude-opus-4-8");
|
|
124
|
+
assert.deepEqual(response.output, [
|
|
125
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] },
|
|
126
|
+
{ type: "function_call", call_id: "c1", name: "lookup", arguments: '{"q":1}' },
|
|
127
|
+
]);
|
|
128
|
+
assert.deepEqual(response.usage, { input_tokens: 3, output_tokens: 4, total_tokens: 7 });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("omits the message item when the turn was tool calls only", () => {
|
|
132
|
+
const response = chatToResponse(
|
|
133
|
+
{ choices: [{ message: { content: null, tool_calls: [] } }] },
|
|
134
|
+
"m",
|
|
135
|
+
) as { output: unknown[] };
|
|
136
|
+
assert.deepEqual(response.output, []);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("createResponsesStreamTranslator", () => {
|
|
141
|
+
it("opens on the first chunk and closes the text run at finish", () => {
|
|
142
|
+
const translator = createResponsesStreamTranslator("m", "resp-1");
|
|
143
|
+
const first = translator.feed({ choices: [{ delta: { content: "he" } }] });
|
|
144
|
+
assert.deepEqual(eventNames(first), [
|
|
145
|
+
"response.created",
|
|
146
|
+
"response.output_item.added",
|
|
147
|
+
"response.output_text.delta",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const second = translator.feed({ choices: [{ delta: { content: "llo" } }] });
|
|
151
|
+
assert.deepEqual(eventNames(second), ["response.output_text.delta"]);
|
|
152
|
+
|
|
153
|
+
const done = translator.finish();
|
|
154
|
+
assert.deepEqual(eventNames(done), [
|
|
155
|
+
"response.output_text.done",
|
|
156
|
+
"response.output_item.done",
|
|
157
|
+
"response.completed",
|
|
158
|
+
]);
|
|
159
|
+
// The buffered text is what the client renders as the final message.
|
|
160
|
+
assert.equal(eventData(done)[0]?.text, "hello");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("assembles argument fragments into one function_call item", () => {
|
|
164
|
+
const translator = createResponsesStreamTranslator("m", "resp-1");
|
|
165
|
+
translator.feed({
|
|
166
|
+
choices: [
|
|
167
|
+
{ delta: { tool_calls: [{ index: 0, id: "c1", function: { name: "lookup" } }] } },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
translator.feed({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"q' } }] } }] });
|
|
171
|
+
translator.feed({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '":1}' } }] } }] });
|
|
172
|
+
|
|
173
|
+
const done = translator.finish();
|
|
174
|
+
const [args] = eventData(done);
|
|
175
|
+
assert.equal(args?.arguments, '{"q":1}');
|
|
176
|
+
assert.ok(eventNames(done).includes("response.function_call_arguments.done"));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("closes an open text run before opening a tool call so output stays ordered", () => {
|
|
180
|
+
const translator = createResponsesStreamTranslator("m", "resp-1");
|
|
181
|
+
translator.feed({ choices: [{ delta: { content: "thinking" } }] });
|
|
182
|
+
const withTool = translator.feed({
|
|
183
|
+
choices: [{ delta: { tool_calls: [{ index: 0, id: "c1", function: { name: "f" } }] } }],
|
|
184
|
+
});
|
|
185
|
+
assert.deepEqual(eventNames(withTool), [
|
|
186
|
+
"response.output_text.done",
|
|
187
|
+
"response.output_item.done",
|
|
188
|
+
"response.output_item.added",
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("emits nothing but the lifecycle for an empty chunk", () => {
|
|
193
|
+
const translator = createResponsesStreamTranslator("m", "resp-1");
|
|
194
|
+
assert.deepEqual(eventNames(translator.feed({})), ["response.created"]);
|
|
195
|
+
assert.deepEqual(eventNames(translator.finish()), ["response.completed"]);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("readResponsesOutput", () => {
|
|
200
|
+
it("prefers the flattened output_text", () => {
|
|
201
|
+
const { text } = readResponsesOutput({
|
|
202
|
+
output_text: " the answer ",
|
|
203
|
+
output: [{ content: [{ text: "ignored" }] }],
|
|
204
|
+
});
|
|
205
|
+
assert.equal(text, "the answer");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("falls back to joining the output items", () => {
|
|
209
|
+
const { text } = readResponsesOutput({
|
|
210
|
+
output: [{ content: [{ text: "one" }, { text: "two" }] }],
|
|
211
|
+
});
|
|
212
|
+
assert.equal(text, "one\ntwo");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("collects url_citation annotations, deduplicated by url", () => {
|
|
216
|
+
const { citations } = readResponsesOutput({
|
|
217
|
+
output: [
|
|
218
|
+
{
|
|
219
|
+
content: [
|
|
220
|
+
{
|
|
221
|
+
text: "answer",
|
|
222
|
+
annotations: [
|
|
223
|
+
{ type: "url_citation", url: "https://a.example", title: "A" },
|
|
224
|
+
{ type: "url_citation", url: "https://a.example", title: "A again" },
|
|
225
|
+
{ type: "url_citation", url: "https://b.example" },
|
|
226
|
+
{ type: "file_citation" },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
assert.deepEqual(citations, [
|
|
234
|
+
{ url: "https://a.example", title: "A" },
|
|
235
|
+
{ url: "https://b.example" },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("returns empty results for a payload with no output", () => {
|
|
240
|
+
assert.deepEqual(readResponsesOutput({}), { text: "", citations: [] });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
+
{
|
|
3
|
+
"extends": "../tsconfig.json",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"noEmit": true,
|
|
6
|
+
"rootDir": ".."
|
|
7
|
+
},
|
|
8
|
+
"include": [
|
|
9
|
+
"**/*.ts"
|
|
10
|
+
],
|
|
11
|
+
"exclude": [
|
|
12
|
+
"node_modules"
|
|
13
|
+
]
|
|
14
|
+
}
|