@dbx-tools/appkit-model-gateway 0.9.36
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/LICENSE +202 -0
- package/README.md +63 -0
- package/index.ts +28 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +21 -0
- package/lib/scripts/copy-manifest.d.ts +1 -0
- package/lib/scripts/copy-manifest.js +14 -0
- package/lib/src/gateway.d.ts +29 -0
- package/lib/src/gateway.js +127 -0
- package/lib/src/manifest.json +13 -0
- package/lib/src/models.d.ts +10 -0
- package/lib/src/models.js +119 -0
- package/lib/src/plugin.d.ts +32 -0
- package/lib/src/plugin.js +70 -0
- package/lib/src/protocols/decode.d.ts +17 -0
- package/lib/src/protocols/decode.js +262 -0
- package/lib/src/protocols/encode.d.ts +18 -0
- package/lib/src/protocols/encode.js +498 -0
- package/lib/src/reasoning-adapt.d.ts +27 -0
- package/lib/src/reasoning-adapt.js +61 -0
- package/lib/src/registry.d.ts +32 -0
- package/lib/src/registry.js +167 -0
- package/lib/src/router.d.ts +18 -0
- package/lib/src/router.js +139 -0
- package/lib/src/routes.d.ts +14 -0
- package/lib/src/routes.js +158 -0
- package/lib/src/translation.d.ts +8 -0
- package/lib/src/translation.js +156 -0
- package/lib/src/transport.d.ts +19 -0
- package/lib/src/transport.js +168 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +150 -0
- package/src/gateway.ts +167 -0
- package/src/manifest.json +13 -0
- package/src/models.ts +152 -0
- package/src/plugin.ts +95 -0
- package/src/protocols/decode.ts +281 -0
- package/src/protocols/encode.ts +515 -0
- package/src/reasoning-adapt.ts +87 -0
- package/src/registry.ts +225 -0
- package/src/router.ts +174 -0
- package/src/routes.ts +181 -0
- package/src/translation.ts +178 -0
- package/src/transport.ts +182 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-scoped Databricks model discovery for the AppKit gateway.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getExecutionContext, type WorkspaceClient } from "@databricks/appkit";
|
|
8
|
+
import { metadata, modelCatalog, policy, resolve as modelResolve } from "@dbx-tools/model";
|
|
9
|
+
import { log } from "@dbx-tools/shared-core";
|
|
10
|
+
import {
|
|
11
|
+
ModelClass,
|
|
12
|
+
type ResolveModelInput,
|
|
13
|
+
type ServingEndpointSummary,
|
|
14
|
+
} from "@dbx-tools/shared-model/contracts";
|
|
15
|
+
import type {
|
|
16
|
+
ModelCapabilities,
|
|
17
|
+
ModelCapabilityOverride,
|
|
18
|
+
ModelTarget,
|
|
19
|
+
} from "@dbx-tools/shared-model-gateway";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CACHE_TTL_MS = 60_000;
|
|
22
|
+
const logger = log.logger("appkit/model-gateway/registry");
|
|
23
|
+
|
|
24
|
+
/** Resolution controls passed through to the model policy owner. */
|
|
25
|
+
export type ModelRegistryResolveOptions = Pick<ResolveModelInput, "modelClass" | "requiresTools">;
|
|
26
|
+
|
|
27
|
+
/** Dynamic model catalogue used by request routing and model-list responses. */
|
|
28
|
+
export interface ModelRegistry {
|
|
29
|
+
list(): Promise<ModelTarget[]>;
|
|
30
|
+
search(query: string): Promise<ModelTarget[]>;
|
|
31
|
+
resolve(model: string, options?: ModelRegistryResolveOptions): Promise<ModelTarget | undefined>;
|
|
32
|
+
refresh(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Configuration for {@link DatabricksModelRegistry}. */
|
|
36
|
+
export interface ModelRegistryOptions {
|
|
37
|
+
readonly ttlMs?: number;
|
|
38
|
+
readonly overrides?: readonly ModelCapabilityOverride[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface RegistryContext {
|
|
42
|
+
readonly client: WorkspaceClient;
|
|
43
|
+
readonly host: string;
|
|
44
|
+
readonly identity: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Model registry backed by the active AppKit execution context. */
|
|
48
|
+
export class DatabricksModelRegistry implements ModelRegistry {
|
|
49
|
+
private readonly ttlMs: number;
|
|
50
|
+
private readonly overrides: readonly ModelCapabilityOverride[];
|
|
51
|
+
|
|
52
|
+
constructor(options: ModelRegistryOptions = {}) {
|
|
53
|
+
this.ttlMs = positiveTtl(options.ttlMs);
|
|
54
|
+
this.overrides = options.overrides ?? [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async list(): Promise<ModelTarget[]> {
|
|
58
|
+
const context = await registryContext();
|
|
59
|
+
logger.debug("loading catalogue", { host: context.host, ttlMs: this.ttlMs });
|
|
60
|
+
const endpoints = await modelCatalog.listServingEndpoints(context.client, context.host, {
|
|
61
|
+
cacheIdentity: context.identity,
|
|
62
|
+
ttlMs: this.ttlMs,
|
|
63
|
+
});
|
|
64
|
+
const targets = endpoints.map((endpoint) => this.target(endpoint));
|
|
65
|
+
logger.debug("loaded catalogue", {
|
|
66
|
+
endpointCount: endpoints.length,
|
|
67
|
+
host: context.host,
|
|
68
|
+
targetCount: targets.length,
|
|
69
|
+
});
|
|
70
|
+
return targets;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async resolve(
|
|
74
|
+
model: string,
|
|
75
|
+
options: ModelRegistryResolveOptions = {},
|
|
76
|
+
): Promise<ModelTarget | undefined> {
|
|
77
|
+
const requested = unqualifiedModel(model);
|
|
78
|
+
const targets = await this.list();
|
|
79
|
+
const exact = targets.find((target) =>
|
|
80
|
+
target.aliases.some(
|
|
81
|
+
(alias) => alias.localeCompare(requested, undefined, { sensitivity: "accent" }) === 0,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
if (exact && matchesResolveOptions(exact, options)) {
|
|
85
|
+
logger.debug("resolved exact model", { requested, resolved: exact.id });
|
|
86
|
+
return exact;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const endpoints = targets
|
|
90
|
+
.map((target) => target.endpoint)
|
|
91
|
+
.filter((endpoint): endpoint is ServingEndpointSummary => endpoint !== undefined);
|
|
92
|
+
const resolved = modelResolve.resolveModel(endpoints, {
|
|
93
|
+
explicit: requested,
|
|
94
|
+
...options,
|
|
95
|
+
});
|
|
96
|
+
const target = targets.find((candidate) => candidate.id === resolved.modelId);
|
|
97
|
+
logger.debug("resolved fuzzy model", {
|
|
98
|
+
requested,
|
|
99
|
+
resolved: target?.id,
|
|
100
|
+
source: resolved.source,
|
|
101
|
+
});
|
|
102
|
+
return target;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async search(query: string): Promise<ModelTarget[]> {
|
|
106
|
+
const targets = await this.list();
|
|
107
|
+
const byId = new Map(targets.map((target) => [target.id, target]));
|
|
108
|
+
const endpoints = targets
|
|
109
|
+
.map((target) => target.endpoint)
|
|
110
|
+
.filter((endpoint): endpoint is ServingEndpointSummary => endpoint !== undefined);
|
|
111
|
+
const ranked = [
|
|
112
|
+
...modelResolve.lookupModels(endpoints, { search: query }),
|
|
113
|
+
...modelResolve.lookupModels(endpoints, {
|
|
114
|
+
search: query,
|
|
115
|
+
modelClass: ModelClass.Embedding,
|
|
116
|
+
}),
|
|
117
|
+
].sort((left, right) => (left.score ?? 0) - (right.score ?? 0));
|
|
118
|
+
return ranked
|
|
119
|
+
.map(({ endpoint }) => byId.get(endpoint.name))
|
|
120
|
+
.filter((target): target is ModelTarget => target !== undefined)
|
|
121
|
+
.filter(
|
|
122
|
+
(target, index, all) => all.findIndex((candidate) => candidate.id === target.id) === index,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async refresh(): Promise<void> {
|
|
127
|
+
const context = await registryContext();
|
|
128
|
+
logger.debug("clearing catalogue", { host: context.host });
|
|
129
|
+
await modelCatalog.clearServingEndpointsCache(context.host, context.identity);
|
|
130
|
+
await modelCatalog.listServingEndpoints(context.client, context.host, {
|
|
131
|
+
cacheIdentity: context.identity,
|
|
132
|
+
ttlMs: this.ttlMs,
|
|
133
|
+
});
|
|
134
|
+
logger.debug("refreshed catalogue", { host: context.host });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private target(endpoint: ServingEndpointSummary): ModelTarget {
|
|
138
|
+
const published = metadata.modelCapabilitiesFor(endpoint);
|
|
139
|
+
const family = endpoint.family?.toLowerCase();
|
|
140
|
+
const openAiFamily = family === "gpt" || family === "openai" || endpoint.name.includes("codex");
|
|
141
|
+
const modelServiceName = normalizedModelServiceName(endpoint.modelServiceName);
|
|
142
|
+
const embeddings =
|
|
143
|
+
endpoint.class === ModelClass.Embedding || endpoint.task === "llm/v1/embeddings";
|
|
144
|
+
const capabilities: ModelCapabilities = {
|
|
145
|
+
responses: !embeddings && (openAiFamily || published.responses),
|
|
146
|
+
openResponses: !embeddings && !openAiFamily,
|
|
147
|
+
chat: !embeddings,
|
|
148
|
+
anthropic: family === "claude",
|
|
149
|
+
embeddings,
|
|
150
|
+
aiGatewayCodex:
|
|
151
|
+
!embeddings && Boolean(modelServiceName) && family !== "claude" && family !== "gemini",
|
|
152
|
+
tools: endpoint.supportsTools === true,
|
|
153
|
+
reasoning: policy.modelReasoningEfforts(endpoint.name).length > 0,
|
|
154
|
+
streaming: true,
|
|
155
|
+
parallelTools: endpoint.supportsTools === true,
|
|
156
|
+
customTools: published.applyPatch || endpoint.name.includes("codex"),
|
|
157
|
+
structuredOutput: true,
|
|
158
|
+
webSearch: published.webSearch,
|
|
159
|
+
};
|
|
160
|
+
const aliases = new Set(
|
|
161
|
+
[
|
|
162
|
+
endpoint.name,
|
|
163
|
+
endpoint.displayName,
|
|
164
|
+
endpoint.modelServiceName,
|
|
165
|
+
modelServiceName,
|
|
166
|
+
...Object.values(endpoint.serviceNames ?? {}),
|
|
167
|
+
].filter((value): value is string => Boolean(value?.trim())),
|
|
168
|
+
);
|
|
169
|
+
const override = this.overrides.find(
|
|
170
|
+
(candidate) =>
|
|
171
|
+
candidate.model === endpoint.name ||
|
|
172
|
+
[...aliases].some((alias) => alias === candidate.model),
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
id: endpoint.name,
|
|
176
|
+
aliases: [...aliases],
|
|
177
|
+
displayName: endpoint.displayName ?? endpoint.name,
|
|
178
|
+
...(endpoint.family ? { family: endpoint.family } : {}),
|
|
179
|
+
...(modelServiceName ? { modelServiceName } : {}),
|
|
180
|
+
endpoint,
|
|
181
|
+
capabilities: { ...capabilities, ...override?.capabilities },
|
|
182
|
+
reasoningEfforts: endpoint.reasoningEfforts ?? [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function registryContext(): Promise<RegistryContext> {
|
|
188
|
+
const context = getExecutionContext();
|
|
189
|
+
const host = (await context.client.config.getHost()).toString();
|
|
190
|
+
return {
|
|
191
|
+
client: context.client,
|
|
192
|
+
host,
|
|
193
|
+
identity: "userId" in context ? context.userId : context.serviceUserId,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function unqualifiedModel(model: string): string {
|
|
198
|
+
return model.trim().replace(/^(?:dbx|databricks)\//i, "");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function positiveTtl(value = DEFAULT_CACHE_TTL_MS): number {
|
|
202
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
203
|
+
throw new Error("Model registry ttlMs must be a positive finite number");
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizedModelServiceName(value: string | undefined): string | undefined {
|
|
209
|
+
const normalized = value?.trim().replace(/^databricks\//, "");
|
|
210
|
+
if (!normalized) return undefined;
|
|
211
|
+
if (normalized.startsWith("system.ai.databricks-")) {
|
|
212
|
+
return `system.ai.${normalized.slice("system.ai.databricks-".length)}`;
|
|
213
|
+
}
|
|
214
|
+
if (normalized.startsWith("databricks-")) {
|
|
215
|
+
return `system.ai.${normalized.slice("databricks-".length)}`;
|
|
216
|
+
}
|
|
217
|
+
return normalized;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function matchesResolveOptions(target: ModelTarget, options: ModelRegistryResolveOptions): boolean {
|
|
221
|
+
if (options.requiresTools && !target.capabilities.tools) return false;
|
|
222
|
+
if (options.modelClass === ModelClass.Embedding) return target.capabilities.embeddings;
|
|
223
|
+
if (options.modelClass !== undefined) return target.capabilities.chat;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure capability-driven route selection for model-gateway requests.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
GatewayRoute,
|
|
9
|
+
ModelTarget,
|
|
10
|
+
RequestedFeatures,
|
|
11
|
+
ResolveRouteInput,
|
|
12
|
+
UpstreamProtocol,
|
|
13
|
+
} from "@dbx-tools/shared-model-gateway";
|
|
14
|
+
|
|
15
|
+
/** Error returned when preserving a requested protocol feature is impossible. */
|
|
16
|
+
export class UnsupportedGatewayFeatureError extends Error {
|
|
17
|
+
readonly status = 400;
|
|
18
|
+
|
|
19
|
+
constructor(readonly features: readonly string[]) {
|
|
20
|
+
super(`The selected model cannot preserve requested features: ${features.join(", ")}`);
|
|
21
|
+
this.name = "UnsupportedGatewayFeatureError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Select the lowest-overhead upstream that preserves the client contract. */
|
|
26
|
+
export function resolveRoute(input: ResolveRouteInput): GatewayRoute {
|
|
27
|
+
const { clientProtocol, requestedModel, features, target } = input;
|
|
28
|
+
if (clientProtocol === "openai-embeddings") {
|
|
29
|
+
if (!target.capabilities.embeddings) {
|
|
30
|
+
throw new UnsupportedGatewayFeatureError(["embeddings"]);
|
|
31
|
+
}
|
|
32
|
+
return directRoute(input, "databricks-embeddings", target.id);
|
|
33
|
+
}
|
|
34
|
+
if (clientProtocol === "openai-responses") {
|
|
35
|
+
rejectStatefulResponses(features);
|
|
36
|
+
if (
|
|
37
|
+
isCodexOriginator(input.originator) &&
|
|
38
|
+
targetsModelService(requestedModel, target) &&
|
|
39
|
+
target.capabilities.aiGatewayCodex &&
|
|
40
|
+
supportsDirectResponses(target, features)
|
|
41
|
+
) {
|
|
42
|
+
return directRoute(input, "databricks-ai-gateway-codex", target.modelServiceName!);
|
|
43
|
+
}
|
|
44
|
+
if (target.capabilities.responses && supportsDirectResponses(target, features)) {
|
|
45
|
+
return directRoute(input, "databricks-responses", target.id);
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
target.capabilities.aiGatewayCodex &&
|
|
49
|
+
target.modelServiceName &&
|
|
50
|
+
supportsDirectResponses(target, features)
|
|
51
|
+
) {
|
|
52
|
+
return directRoute(input, "databricks-ai-gateway-codex", target.modelServiceName);
|
|
53
|
+
}
|
|
54
|
+
if (target.capabilities.openResponses && supportsOpenResponses(target, features)) {
|
|
55
|
+
return directRoute(input, "databricks-open-responses", target.id);
|
|
56
|
+
}
|
|
57
|
+
return translatedRoute(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (clientProtocol === "openai-chat") {
|
|
61
|
+
if (target.capabilities.chat && supportsTools(target, features)) {
|
|
62
|
+
return directRoute(input, "databricks-chat", target.id);
|
|
63
|
+
}
|
|
64
|
+
return translatedRoute(input);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (target.capabilities.anthropic && supportsTools(target, features)) {
|
|
68
|
+
return directRoute(input, "databricks-anthropic", target.id);
|
|
69
|
+
}
|
|
70
|
+
return translatedRoute(input);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Detect route-affecting features from an OpenAI or Anthropic request body. */
|
|
74
|
+
export function requestedFeatures(body: Readonly<Record<string, unknown>>): RequestedFeatures {
|
|
75
|
+
const tools = Array.isArray(body.tools) ? body.tools : [];
|
|
76
|
+
const toolTypes = tools
|
|
77
|
+
.filter((tool): tool is Record<string, unknown> => isRecord(tool))
|
|
78
|
+
.map((tool) => tool.type);
|
|
79
|
+
const text = isRecord(body.text) ? body.text : {};
|
|
80
|
+
const format = isRecord(text.format) ? text.format.type : text.format;
|
|
81
|
+
return {
|
|
82
|
+
background: body.background === true,
|
|
83
|
+
customTools: toolTypes.some((type) => type === "custom"),
|
|
84
|
+
parallelTools: body.parallel_tool_calls === true,
|
|
85
|
+
previousResponse:
|
|
86
|
+
typeof body.previous_response_id === "string" && body.previous_response_id.length > 0,
|
|
87
|
+
reasoning: isRecord(body.reasoning) || body.reasoning_effort !== undefined,
|
|
88
|
+
storage: body.store === true,
|
|
89
|
+
structuredOutput: format !== undefined && format !== "text",
|
|
90
|
+
tools: tools.length > 0,
|
|
91
|
+
unsupportedOpenResponsesTools: toolTypes.some(
|
|
92
|
+
(type) => type !== "function" && type !== "web_search" && type !== "web_search_preview",
|
|
93
|
+
),
|
|
94
|
+
webSearch: toolTypes.some((type) => type === "web_search" || type === "web_search_preview"),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Return whether an Originator header selects Codex compatibility behavior. */
|
|
99
|
+
export function isCodexOriginator(originator: string | undefined): boolean {
|
|
100
|
+
return originator?.toLowerCase().includes("codex") === true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function directRoute(
|
|
104
|
+
input: ResolveRouteInput,
|
|
105
|
+
upstreamProtocol: Exclude<UpstreamProtocol, "ai-sdk">,
|
|
106
|
+
upstreamModel: string,
|
|
107
|
+
): GatewayRoute {
|
|
108
|
+
return {
|
|
109
|
+
clientProtocol: input.clientProtocol,
|
|
110
|
+
upstreamProtocol,
|
|
111
|
+
target: input.target,
|
|
112
|
+
upstreamModel,
|
|
113
|
+
translateRequest: false,
|
|
114
|
+
translateResponse: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function translatedRoute(input: ResolveRouteInput): GatewayRoute {
|
|
119
|
+
return {
|
|
120
|
+
clientProtocol: input.clientProtocol,
|
|
121
|
+
upstreamProtocol: "ai-sdk",
|
|
122
|
+
target: input.target,
|
|
123
|
+
upstreamModel: input.target.id,
|
|
124
|
+
translateRequest: true,
|
|
125
|
+
translateResponse: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function supportsDirectResponses(target: ModelTarget, features: RequestedFeatures): boolean {
|
|
130
|
+
return (
|
|
131
|
+
supportsTools(target, features) &&
|
|
132
|
+
(!features.customTools || target.capabilities.customTools) &&
|
|
133
|
+
(!features.structuredOutput || target.capabilities.structuredOutput)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function supportsOpenResponses(target: ModelTarget, features: RequestedFeatures): boolean {
|
|
138
|
+
return (
|
|
139
|
+
supportsTools(target, features) &&
|
|
140
|
+
!features.customTools &&
|
|
141
|
+
!features.previousResponse &&
|
|
142
|
+
!features.storage &&
|
|
143
|
+
!features.background &&
|
|
144
|
+
!features.unsupportedOpenResponsesTools &&
|
|
145
|
+
(!features.webSearch || target.capabilities.webSearch) &&
|
|
146
|
+
(!features.structuredOutput || target.capabilities.structuredOutput)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function supportsTools(target: ModelTarget, features: RequestedFeatures): boolean {
|
|
151
|
+
return (
|
|
152
|
+
(!features.tools || target.capabilities.tools) &&
|
|
153
|
+
(!features.parallelTools || target.capabilities.parallelTools)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rejectStatefulResponses(features: RequestedFeatures): void {
|
|
158
|
+
const unsupported = [
|
|
159
|
+
...(features.previousResponse ? ["previous_response_id"] : []),
|
|
160
|
+
...(features.storage ? ["store"] : []),
|
|
161
|
+
...(features.background ? ["background"] : []),
|
|
162
|
+
];
|
|
163
|
+
if (unsupported.length > 0) throw new UnsupportedGatewayFeatureError(unsupported);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function targetsModelService(requestedModel: string, target: ModelTarget): boolean {
|
|
167
|
+
if (!target.modelServiceName) return false;
|
|
168
|
+
const unqualified = requestedModel.trim().replace(/^(?:dbx|databricks)\//i, "");
|
|
169
|
+
return unqualified === target.modelServiceName;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
173
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
174
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express adapters for the shared model-gateway service.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { log } from "@dbx-tools/shared-core";
|
|
10
|
+
import type { ClientProtocol } from "@dbx-tools/shared-model-gateway";
|
|
11
|
+
import type express from "express";
|
|
12
|
+
|
|
13
|
+
import { ModelGateway, ModelGatewayError } from "./gateway.ts";
|
|
14
|
+
import { UnsupportedGatewayFeatureError } from "./router.ts";
|
|
15
|
+
|
|
16
|
+
const logger = log.logger("appkit/model-gateway/http");
|
|
17
|
+
|
|
18
|
+
/** Send the lightweight compatibility health response. */
|
|
19
|
+
export function sendHealth(response: express.Response): void {
|
|
20
|
+
response.json({ ready: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Send the active principal's dynamic model catalogue. */
|
|
24
|
+
export async function sendModels(
|
|
25
|
+
gateway: ModelGateway,
|
|
26
|
+
request: express.Request,
|
|
27
|
+
response: express.Response,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const startedAt = Date.now();
|
|
30
|
+
logger.debug("models request", requestContext(request));
|
|
31
|
+
try {
|
|
32
|
+
const payload = await gateway.models(
|
|
33
|
+
request.header("originator"),
|
|
34
|
+
optionalSearch(request.query.search),
|
|
35
|
+
);
|
|
36
|
+
response.json(payload);
|
|
37
|
+
logger.debug("models response", {
|
|
38
|
+
...requestContext(request),
|
|
39
|
+
elapsedMs: Date.now() - startedAt,
|
|
40
|
+
status: response.statusCode,
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
logger.error("models request failed", {
|
|
44
|
+
...requestContext(request),
|
|
45
|
+
elapsedMs: Date.now() - startedAt,
|
|
46
|
+
error,
|
|
47
|
+
});
|
|
48
|
+
sendError("openai-responses", response, error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Execute and stream one model request through Express with cancellation. */
|
|
53
|
+
export async function sendInference(
|
|
54
|
+
gateway: ModelGateway,
|
|
55
|
+
protocol: ClientProtocol,
|
|
56
|
+
request: express.Request,
|
|
57
|
+
response: express.Response,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
const startedAt = Date.now();
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const abort = () => {
|
|
62
|
+
if (response.writableEnded) return;
|
|
63
|
+
logger.debug("client disconnected", {
|
|
64
|
+
...requestContext(request),
|
|
65
|
+
elapsedMs: Date.now() - startedAt,
|
|
66
|
+
protocol,
|
|
67
|
+
});
|
|
68
|
+
controller.abort(new Error("Model gateway client disconnected"));
|
|
69
|
+
};
|
|
70
|
+
request.once("aborted", abort);
|
|
71
|
+
response.once("close", abort);
|
|
72
|
+
try {
|
|
73
|
+
const body = record(request.body);
|
|
74
|
+
logger.debug("inference request", {
|
|
75
|
+
...requestContext(request),
|
|
76
|
+
model: typeof body.model === "string" ? body.model : undefined,
|
|
77
|
+
protocol,
|
|
78
|
+
stream: body.stream === true,
|
|
79
|
+
});
|
|
80
|
+
const upstream = await gateway.inference(
|
|
81
|
+
protocol,
|
|
82
|
+
body,
|
|
83
|
+
requestHeaders(request),
|
|
84
|
+
controller.signal,
|
|
85
|
+
);
|
|
86
|
+
response.status(upstream.status);
|
|
87
|
+
upstream.headers.forEach((value, name) => response.setHeader(name, value));
|
|
88
|
+
if (!upstream.body) {
|
|
89
|
+
response.end();
|
|
90
|
+
logger.debug("inference response", {
|
|
91
|
+
...requestContext(request),
|
|
92
|
+
elapsedMs: Date.now() - startedAt,
|
|
93
|
+
protocol,
|
|
94
|
+
status: upstream.status,
|
|
95
|
+
upstreamProtocol: upstream.headers.get("x-dbx-tools-upstream"),
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await pipeline(Readable.fromWeb(upstream.body as never), response);
|
|
100
|
+
logger.debug("inference stream completed", {
|
|
101
|
+
...requestContext(request),
|
|
102
|
+
elapsedMs: Date.now() - startedAt,
|
|
103
|
+
protocol,
|
|
104
|
+
status: upstream.status,
|
|
105
|
+
upstreamProtocol: upstream.headers.get("x-dbx-tools-upstream"),
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (controller.signal.aborted) {
|
|
109
|
+
response.destroy();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
logger.error("inference request failed", {
|
|
113
|
+
...requestContext(request),
|
|
114
|
+
elapsedMs: Date.now() - startedAt,
|
|
115
|
+
error,
|
|
116
|
+
protocol,
|
|
117
|
+
});
|
|
118
|
+
if (response.headersSent) {
|
|
119
|
+
response.destroy(error instanceof Error ? error : undefined);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
sendError(protocol, response, error);
|
|
123
|
+
} finally {
|
|
124
|
+
request.off("aborted", abort);
|
|
125
|
+
response.off("close", abort);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sendError(protocol: ClientProtocol, response: express.Response, error: unknown): void {
|
|
130
|
+
const status =
|
|
131
|
+
error instanceof ModelGatewayError || error instanceof UnsupportedGatewayFeatureError
|
|
132
|
+
? error.status
|
|
133
|
+
: 500;
|
|
134
|
+
const message = error instanceof Error ? error.message : "Model gateway request failed";
|
|
135
|
+
if (protocol === "anthropic-messages") {
|
|
136
|
+
response.status(status).json({
|
|
137
|
+
type: "error",
|
|
138
|
+
error: { type: status < 500 ? "invalid_request_error" : "api_error", message },
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
response.status(status).json({
|
|
143
|
+
error: {
|
|
144
|
+
message,
|
|
145
|
+
type: status < 500 ? "invalid_request_error" : "api_error",
|
|
146
|
+
code: status,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function requestHeaders(request: express.Request): Headers {
|
|
152
|
+
const headers = new Headers();
|
|
153
|
+
for (const [name, raw] of Object.entries(request.headers)) {
|
|
154
|
+
if (typeof raw === "string") headers.set(name, raw);
|
|
155
|
+
else if (Array.isArray(raw)) headers.set(name, raw.join(", "));
|
|
156
|
+
}
|
|
157
|
+
return headers;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function record(value: unknown): Record<string, unknown> {
|
|
161
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
162
|
+
return value as Record<string, unknown>;
|
|
163
|
+
}
|
|
164
|
+
throw new ModelGatewayError(400, "Request body must be a JSON object");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function optionalSearch(value: unknown): string | undefined {
|
|
168
|
+
if (value === undefined) return undefined;
|
|
169
|
+
if (typeof value !== "string") {
|
|
170
|
+
throw new ModelGatewayError(400, "search must be a string");
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requestContext(request: express.Request): Record<string, unknown> {
|
|
176
|
+
return {
|
|
177
|
+
method: request.method,
|
|
178
|
+
path: request.path,
|
|
179
|
+
requestId: request.header("x-request-id") ?? request.header("request-id"),
|
|
180
|
+
};
|
|
181
|
+
}
|