@dbx-tools/model 0.9.32 → 0.9.37
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 +3 -3
- package/exports.ts +1 -0
- package/index.ts +12 -8
- package/lib/index.d.ts +12 -8
- package/lib/index.js +8 -5
- package/lib/scripts/generate-metadata.d.ts +1 -0
- package/lib/scripts/generate-metadata.js +65 -0
- package/lib/src/_metadata-contract.d.ts +12 -6
- package/lib/src/_metadata-contract.js +3 -1
- package/lib/src/_metadata-generator.d.ts +3 -1
- package/lib/src/_metadata-generator.js +148 -1
- package/lib/src/_ranking.js +39 -12
- package/lib/src/_selection.d.ts +1 -17
- package/lib/src/_selection.js +1 -1
- package/lib/src/classify.d.ts +1 -17
- package/lib/src/classify.js +1 -1
- package/lib/src/generated/model-reasoning.json +147 -0
- package/lib/src/invoke.d.ts +8 -0
- package/lib/src/invoke.js +13 -1
- package/lib/src/metadata-cache.d.ts +84 -0
- package/lib/src/metadata-cache.js +234 -0
- package/lib/src/metadata.d.ts +52 -17
- package/lib/src/metadata.js +251 -18
- package/lib/src/model-catalog.d.ts +1 -19
- package/lib/src/model-catalog.js +9 -2
- package/lib/src/model-client.d.ts +11 -19
- package/lib/src/model-client.js +42 -24
- package/lib/src/reasoning-translation.d.ts +105 -0
- package/lib/src/reasoning-translation.js +444 -0
- package/lib/src/resolve.d.ts +2 -4
- package/lib/src/resolve.js +2 -2
- package/lib/src/serving-wire.d.ts +37 -0
- package/lib/src/serving-wire.js +211 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +110 -17
- package/src/_metadata-contract.ts +17 -7
- package/src/_metadata-generator.ts +160 -0
- package/src/_ranking.ts +40 -11
- package/src/_selection.ts +4 -19
- package/src/classify.ts +2 -18
- package/src/generated/model-reasoning.json +147 -0
- package/src/invoke.ts +16 -0
- package/src/metadata-cache.ts +322 -0
- package/src/metadata.ts +334 -43
- package/src/model-catalog.ts +8 -25
- package/src/model-client.ts +67 -44
- package/src/reasoning-translation.ts +495 -0
- package/src/resolve.ts +10 -11
- package/src/serving-wire.ts +231 -0
- package/lib/src/python.d.ts +0 -2
- package/lib/src/python.js +0 -3
- package/src/python.ts +0 -2
package/src/_ranking.ts
CHANGED
|
@@ -52,15 +52,28 @@ export function rankEndpoints(
|
|
|
52
52
|
const requestedClass = options.modelClass ?? query.modelClass;
|
|
53
53
|
const eligible = requestedClass ? classesAtOrBelow(requestedClass) : CHAT_CLASS_ORDER;
|
|
54
54
|
const candidates: RankedModel[] = [];
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const search = query.search?.trim();
|
|
56
|
+
if (search && requestedClass === undefined) {
|
|
57
|
+
const classByName = classifyEndpointClasses(normalized);
|
|
58
|
+
for (const endpoint of normalized) {
|
|
57
59
|
if (!options.includeDeprecated && endpoint.status?.deprecated) continue;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
const capabilities = endpointCapabilities(endpoint);
|
|
61
|
+
if (!capabilities.chat || (query.requiresTools && !capabilities.tools)) continue;
|
|
62
|
+
candidates.push({
|
|
63
|
+
endpoint,
|
|
64
|
+
modelClass: classByName.get(endpoint.name) ?? ModelClass.ChatBalanced,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
for (const modelClass of eligible) {
|
|
69
|
+
for (const endpoint of classified[modelClass]) {
|
|
70
|
+
if (!options.includeDeprecated && endpoint.status?.deprecated) continue;
|
|
71
|
+
if (query.requiresTools && !endpointCapabilities(endpoint).tools) continue;
|
|
72
|
+
candidates.push({ endpoint, modelClass });
|
|
73
|
+
}
|
|
60
74
|
}
|
|
61
75
|
}
|
|
62
76
|
|
|
63
|
-
const search = query.search?.trim();
|
|
64
77
|
let ranked = candidates;
|
|
65
78
|
if (search) {
|
|
66
79
|
const exact = candidates.find((candidate) => candidate.endpoint.name === search);
|
|
@@ -147,11 +160,19 @@ export function normalizeEndpoints(endpoints: readonly unknown[]): ServingEndpoi
|
|
|
147
160
|
stringValue(record(entity.foundation_model).name) ?? stringValue(entity.entity_name),
|
|
148
161
|
)
|
|
149
162
|
.find(Boolean);
|
|
150
|
-
const
|
|
163
|
+
const foundationModels = entities.map((entity) => record(entity.foundation_model));
|
|
164
|
+
const family =
|
|
165
|
+
foundationModels.map((model) => stringValue(model.model_class)).find(Boolean) ??
|
|
166
|
+
identities.map(modelFamily).find(Boolean);
|
|
151
167
|
const reasoningEfforts =
|
|
152
168
|
identities.map(modelReasoningEfforts).sort((left, right) => right.length - left.length)[0] ??
|
|
153
169
|
[];
|
|
154
170
|
const profile = entities.map(modelProfile).find(Boolean);
|
|
171
|
+
const capabilitiesRecord = record(endpoint.capabilities);
|
|
172
|
+
const foundationDescription = foundationModels
|
|
173
|
+
.map((model) => stringValue(model.description))
|
|
174
|
+
.find(Boolean);
|
|
175
|
+
const description = stringValue(endpoint.description) ?? foundationDescription;
|
|
155
176
|
const summary: ServingEndpointSummary = {
|
|
156
177
|
name,
|
|
157
178
|
displayName: toModelDisplayName(name, providedDisplayName(endpoint, entities)),
|
|
@@ -160,10 +181,9 @@ export function normalizeEndpoints(endpoints: readonly unknown[]): ServingEndpoi
|
|
|
160
181
|
...(stringValue(record(endpoint.state).ready)
|
|
161
182
|
? { state: stringValue(record(endpoint.state).ready) }
|
|
162
183
|
: {}),
|
|
163
|
-
...(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
supportsTools: identities.some(supportsToolsByFamily),
|
|
184
|
+
...(description ? { description } : {}),
|
|
185
|
+
supportsTools:
|
|
186
|
+
booleanValue(capabilitiesRecord.function_calling) ?? identities.some(supportsToolsByFamily),
|
|
167
187
|
...(profile ? { profile } : {}),
|
|
168
188
|
serviceNames: Object.assign({}, ...identities.map(modelServiceNames)),
|
|
169
189
|
...(modelServiceName ? { modelServiceName } : {}),
|
|
@@ -236,7 +256,12 @@ function providedDisplayName(
|
|
|
236
256
|
const value = stringValue(tag.value);
|
|
237
257
|
if (value) return value;
|
|
238
258
|
}
|
|
239
|
-
return
|
|
259
|
+
return (
|
|
260
|
+
entities
|
|
261
|
+
.map((entity) => stringValue(record(entity.foundation_model).display_name))
|
|
262
|
+
.find(Boolean) ??
|
|
263
|
+
entities.map((entity) => stringValue(record(entity.external_model).name)).find(Boolean)
|
|
264
|
+
);
|
|
240
265
|
}
|
|
241
266
|
|
|
242
267
|
function record(value: unknown): Record<string, unknown> {
|
|
@@ -258,3 +283,7 @@ function stringValue(value: unknown): string | undefined {
|
|
|
258
283
|
function finiteNumber(value: unknown): number | undefined {
|
|
259
284
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
260
285
|
}
|
|
286
|
+
|
|
287
|
+
function booleanValue(value: unknown): boolean | undefined {
|
|
288
|
+
return typeof value === "boolean" ? value : undefined;
|
|
289
|
+
}
|
package/src/_selection.ts
CHANGED
|
@@ -5,36 +5,21 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as object from "@dbx-tools/shared-core/object";
|
|
7
7
|
import type {
|
|
8
|
-
ModelClass,
|
|
9
8
|
ModelQuery,
|
|
10
9
|
RankedModel,
|
|
10
|
+
ResolvedModel,
|
|
11
|
+
ResolvedModelSelection,
|
|
12
|
+
ResolveModelInput,
|
|
13
|
+
ResolveModelOptions,
|
|
11
14
|
ServingEndpointSummary,
|
|
12
15
|
} from "@dbx-tools/shared-model/contracts";
|
|
13
16
|
|
|
14
17
|
import { rankEndpoints } from "./_ranking.ts";
|
|
15
18
|
import { FALLBACK_MODEL_IDS, modelsForClass } from "./fallback.ts";
|
|
16
|
-
import type { ResolvedModel, ResolveModelOptions } from "./model-catalog.ts";
|
|
17
19
|
import { endpointSupportsTools } from "./policy.ts";
|
|
18
20
|
|
|
19
21
|
const DEFAULT_MODEL_FAMILY_SEARCH = "gpt";
|
|
20
22
|
|
|
21
|
-
/** Caller intent passed to {@link resolveModel}. */
|
|
22
|
-
export interface ResolveModelInput {
|
|
23
|
-
explicit?: string;
|
|
24
|
-
fuzzy?: boolean;
|
|
25
|
-
threshold?: number;
|
|
26
|
-
requiresTools?: boolean;
|
|
27
|
-
modelClass?: ModelClass;
|
|
28
|
-
fallbacks?: readonly string[];
|
|
29
|
-
liveOnly?: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Outcome of {@link resolveModel}: the chosen id plus how it was reached. */
|
|
33
|
-
export interface ResolvedModelSelection {
|
|
34
|
-
modelId: string;
|
|
35
|
-
source: "explicit" | "fuzzy-match" | "class" | "fallback";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
23
|
/** Rank a catalogue against a model query, best-first. */
|
|
39
24
|
export function lookupModels(
|
|
40
25
|
endpoints: readonly ServingEndpointSummary[],
|
package/src/classify.ts
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import {
|
|
30
|
+
type EndpointCapabilities,
|
|
31
|
+
type FamilyClass,
|
|
30
32
|
ModelClass as ModelClassValues,
|
|
31
33
|
type ModelClass as ModelClassType,
|
|
32
34
|
type ServingEndpointSummary,
|
|
@@ -41,16 +43,6 @@ const CHAT_TASK = "llm/v1/chat";
|
|
|
41
43
|
/** Task hint Databricks stamps on embedding endpoints. */
|
|
42
44
|
const EMBEDDING_TASK = "llm/v1/embeddings";
|
|
43
45
|
|
|
44
|
-
/** What an endpoint can be asked to do, as derived by {@link endpointCapabilities}. */
|
|
45
|
-
export interface EndpointCapabilities {
|
|
46
|
-
/** OpenAI chat/completions + Responses: the surface a chat agent needs. */
|
|
47
|
-
chat: boolean;
|
|
48
|
-
/** Embedding (vector) endpoint. */
|
|
49
|
-
embedding: boolean;
|
|
50
|
-
/** Complete function-tool round-trip (call plus function_call_output replay). */
|
|
51
|
-
tools: boolean;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
46
|
/**
|
|
55
47
|
* Provider families verified against Databricks Responses/Open Responses with
|
|
56
48
|
* both a forced function call and a stateless `function_call_output` replay.
|
|
@@ -92,14 +84,6 @@ export function endpointCapabilities(endpoint: ServingEndpointSummary): Endpoint
|
|
|
92
84
|
return { chat, embedding, tools };
|
|
93
85
|
}
|
|
94
86
|
|
|
95
|
-
/** Family-heuristic classification of a single endpoint name. */
|
|
96
|
-
export interface FamilyClass {
|
|
97
|
-
/** Chat capability band the family maps to (never embedding). */
|
|
98
|
-
class: ModelClass;
|
|
99
|
-
/** Intra-family ordering hint (higher is newer / more capable). */
|
|
100
|
-
rank: number;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
87
|
/**
|
|
104
88
|
* Numeric `[major, minor, patch]` version parsed from an endpoint
|
|
105
89
|
* name, used to order siblings within a family/tier. Starts at the
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generatedAt": 1791233726,
|
|
3
|
+
"catalogue": {
|
|
4
|
+
"models": {
|
|
5
|
+
"claude-5-1-fable": [
|
|
6
|
+
"low",
|
|
7
|
+
"medium",
|
|
8
|
+
"high",
|
|
9
|
+
"extra-high",
|
|
10
|
+
"max"
|
|
11
|
+
],
|
|
12
|
+
"claude-5-5-opus": [
|
|
13
|
+
"low",
|
|
14
|
+
"medium",
|
|
15
|
+
"high",
|
|
16
|
+
"extra-high",
|
|
17
|
+
"max"
|
|
18
|
+
],
|
|
19
|
+
"deepseek-4-1-flash": [
|
|
20
|
+
"low",
|
|
21
|
+
"high",
|
|
22
|
+
"extra-high",
|
|
23
|
+
"max"
|
|
24
|
+
],
|
|
25
|
+
"deepseek-4-flash-0731": [
|
|
26
|
+
"low",
|
|
27
|
+
"high",
|
|
28
|
+
"max"
|
|
29
|
+
],
|
|
30
|
+
"deepseek-4-pro-0813": [
|
|
31
|
+
"low",
|
|
32
|
+
"high",
|
|
33
|
+
"max"
|
|
34
|
+
],
|
|
35
|
+
"gemini-3-1-flash-lite": [
|
|
36
|
+
"low",
|
|
37
|
+
"medium",
|
|
38
|
+
"high"
|
|
39
|
+
],
|
|
40
|
+
"gemini-3-1-pro": [
|
|
41
|
+
"low",
|
|
42
|
+
"medium",
|
|
43
|
+
"high"
|
|
44
|
+
],
|
|
45
|
+
"gemini-3-5-flash": [
|
|
46
|
+
"low",
|
|
47
|
+
"medium",
|
|
48
|
+
"high"
|
|
49
|
+
],
|
|
50
|
+
"gemini-3-5-flash-lite": [
|
|
51
|
+
"low",
|
|
52
|
+
"medium",
|
|
53
|
+
"high"
|
|
54
|
+
],
|
|
55
|
+
"gemini-3-6-flash": [
|
|
56
|
+
"low",
|
|
57
|
+
"medium",
|
|
58
|
+
"high"
|
|
59
|
+
],
|
|
60
|
+
"gemini-3-7-flash": [
|
|
61
|
+
"low",
|
|
62
|
+
"medium",
|
|
63
|
+
"high"
|
|
64
|
+
],
|
|
65
|
+
"gemini-3-8-flash": [
|
|
66
|
+
"low",
|
|
67
|
+
"medium",
|
|
68
|
+
"high"
|
|
69
|
+
],
|
|
70
|
+
"gemini-3-flash": [
|
|
71
|
+
"low",
|
|
72
|
+
"medium",
|
|
73
|
+
"high"
|
|
74
|
+
],
|
|
75
|
+
"glm-5-2": [
|
|
76
|
+
"high",
|
|
77
|
+
"max"
|
|
78
|
+
],
|
|
79
|
+
"glm-5-3": [
|
|
80
|
+
"low",
|
|
81
|
+
"high",
|
|
82
|
+
"max"
|
|
83
|
+
],
|
|
84
|
+
"glm-5-3-flash": [
|
|
85
|
+
"low",
|
|
86
|
+
"high",
|
|
87
|
+
"max"
|
|
88
|
+
],
|
|
89
|
+
"gpt-6-1-sol": [
|
|
90
|
+
"low",
|
|
91
|
+
"medium",
|
|
92
|
+
"high",
|
|
93
|
+
"extra-high",
|
|
94
|
+
"max"
|
|
95
|
+
],
|
|
96
|
+
"gpt-6-astra": [
|
|
97
|
+
"low",
|
|
98
|
+
"medium",
|
|
99
|
+
"high",
|
|
100
|
+
"extra-high",
|
|
101
|
+
"max"
|
|
102
|
+
],
|
|
103
|
+
"gpt-6-luna": [
|
|
104
|
+
"low",
|
|
105
|
+
"medium",
|
|
106
|
+
"high",
|
|
107
|
+
"extra-high",
|
|
108
|
+
"max"
|
|
109
|
+
],
|
|
110
|
+
"gpt-6-sol": [
|
|
111
|
+
"low",
|
|
112
|
+
"medium",
|
|
113
|
+
"high",
|
|
114
|
+
"extra-high",
|
|
115
|
+
"max"
|
|
116
|
+
],
|
|
117
|
+
"gpt-oss-120b": [
|
|
118
|
+
"low",
|
|
119
|
+
"medium",
|
|
120
|
+
"high"
|
|
121
|
+
],
|
|
122
|
+
"gpt-oss-20b": [
|
|
123
|
+
"low",
|
|
124
|
+
"medium",
|
|
125
|
+
"high"
|
|
126
|
+
],
|
|
127
|
+
"grok-4-6": [
|
|
128
|
+
"low",
|
|
129
|
+
"medium",
|
|
130
|
+
"high",
|
|
131
|
+
"extra-high"
|
|
132
|
+
],
|
|
133
|
+
"inkling": [
|
|
134
|
+
"low",
|
|
135
|
+
"medium",
|
|
136
|
+
"high",
|
|
137
|
+
"extra-high",
|
|
138
|
+
"max"
|
|
139
|
+
],
|
|
140
|
+
"kimi-3": [
|
|
141
|
+
"low",
|
|
142
|
+
"high",
|
|
143
|
+
"max"
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/invoke.ts
CHANGED
|
@@ -48,6 +48,12 @@ export const OPEN_RESPONSES_PATH = "serving-endpoints/open-responses";
|
|
|
48
48
|
*/
|
|
49
49
|
export const CHAT_COMPLETIONS_PATH = "serving-endpoints/chat/completions";
|
|
50
50
|
|
|
51
|
+
/** Unity Gateway OpenAI Responses path used by Codex-compatible model services. */
|
|
52
|
+
export const AI_GATEWAY_CODEX_RESPONSES_PATH = "ai-gateway/codex/v1/responses";
|
|
53
|
+
|
|
54
|
+
/** Workspace Anthropic Messages path for native Claude requests. */
|
|
55
|
+
export const ANTHROPIC_MESSAGES_PATH = "serving-endpoints/anthropic/v1/messages";
|
|
56
|
+
|
|
51
57
|
/**
|
|
52
58
|
* The OpenAI-compatible chat-completions invocations URL for an endpoint id.
|
|
53
59
|
*
|
|
@@ -77,6 +83,16 @@ export function chatCompletionsUrl(host: string): string {
|
|
|
77
83
|
return new URL(CHAT_COMPLETIONS_PATH, host).toString();
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
/** Unity Gateway Codex Responses URL (`POST`, model service in the body). */
|
|
87
|
+
export function aiGatewayCodexResponsesUrl(host: string): string {
|
|
88
|
+
return new URL(AI_GATEWAY_CODEX_RESPONSES_PATH, host).toString();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Workspace Anthropic Messages URL (`POST`, model in the body). */
|
|
92
|
+
export function anthropicMessagesUrl(host: string): string {
|
|
93
|
+
return new URL(ANTHROPIC_MESSAGES_PATH, host).toString();
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
/**
|
|
81
97
|
* True when the endpoint is known to reject Chat Completions `/invocations`
|
|
82
98
|
* and require the Responses API (Codex models today). Callers should route
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agnostic daily metadata cache on {@link https://www.npmjs.com/package/cacache | cacache}.
|
|
3
|
+
*
|
|
4
|
+
* Uses cacache's built-in memory memoization (`memoize: true` on put; gets read
|
|
5
|
+
* the in-memory layer by default) with content-addressed disk under
|
|
6
|
+
* `env-paths("dbx-tools").cache/model-gateway/<version>/`. Disk persistence is
|
|
7
|
+
* disabled inside a Databricks App ({@link environmentUtils.isDatabricksAppEnv});
|
|
8
|
+
* those processes keep a process-local Map only.
|
|
9
|
+
*
|
|
10
|
+
* Each cache owns a key, hard-coded fallback, optional network loader, and a
|
|
11
|
+
* merger that combines fresh / previous / fallback values. Freshness is the
|
|
12
|
+
* envelope `refreshedAt` window (default one day), not cacache's short LRU TTL.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
import * as environmentUtils from "@dbx-tools/shared-core/environment-utils";
|
|
20
|
+
import * as log from "@dbx-tools/shared-core/log";
|
|
21
|
+
import cacache from "cacache";
|
|
22
|
+
import envPaths from "env-paths";
|
|
23
|
+
|
|
24
|
+
import { MODEL_METADATA_TTL_MS } from "./_metadata-contract.ts";
|
|
25
|
+
|
|
26
|
+
const logger = log.logger("model/metadata-cache");
|
|
27
|
+
|
|
28
|
+
interface PackageIdentity {
|
|
29
|
+
readonly identifier: string;
|
|
30
|
+
readonly version: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let packageIdentityPromise: Promise<PackageIdentity> | undefined;
|
|
34
|
+
|
|
35
|
+
function packageIdentity(): Promise<PackageIdentity> {
|
|
36
|
+
packageIdentityPromise ??= import("../index.ts").then((mod) => ({
|
|
37
|
+
identifier: mod.PACKAGE_IDENTIFIER,
|
|
38
|
+
version: mod.PACKAGE_VERSION,
|
|
39
|
+
}));
|
|
40
|
+
return packageIdentityPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Envelope stored as JSON bytes in cacache for every key. */
|
|
44
|
+
export interface MetadataCacheRecord<T> {
|
|
45
|
+
readonly refreshedAt: number;
|
|
46
|
+
readonly value: T;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Factory inputs for one named metadata cache. */
|
|
50
|
+
export interface MetadataCacheOptions<T> {
|
|
51
|
+
/** Stable cacache key. */
|
|
52
|
+
readonly key: string;
|
|
53
|
+
/** Hard-coded / committed baseline used when store and load both miss. */
|
|
54
|
+
readonly fallback: T;
|
|
55
|
+
/**
|
|
56
|
+
* Refresh source. When omitted the cache only serves store + fallback and
|
|
57
|
+
* {@link MetadataCache.update} mutations (for example error-learned values).
|
|
58
|
+
*/
|
|
59
|
+
readonly load?: () => Promise<T>;
|
|
60
|
+
/** Combine a fresh load, prior store value, and the hard-coded fallback. */
|
|
61
|
+
readonly merge: (input: MetadataCacheMergeInput<T>) => T;
|
|
62
|
+
/** Freshness window; defaults to {@link MODEL_METADATA_TTL_MS} (one day). */
|
|
63
|
+
readonly ttlMs?: number;
|
|
64
|
+
/** Package version segment in the cache path. Defaults to this package. */
|
|
65
|
+
readonly version?: string;
|
|
66
|
+
/** Override the cacache root directory (tests). */
|
|
67
|
+
readonly cacheDir?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Force disk on/off. Defaults to off inside Databricks Apps, on otherwise.
|
|
70
|
+
*/
|
|
71
|
+
readonly disk?: boolean;
|
|
72
|
+
/** Clock override for tests. */
|
|
73
|
+
readonly now?: () => number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Arguments passed to a cache merger. */
|
|
77
|
+
export interface MetadataCacheMergeInput<T> {
|
|
78
|
+
readonly fresh: T | undefined;
|
|
79
|
+
readonly previous: T | undefined;
|
|
80
|
+
readonly fallback: T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One versioned metadata cache (cacache memory + optional disk). */
|
|
84
|
+
export interface MetadataCache<T> {
|
|
85
|
+
/** Absolute cacache root for this package version (unused when disk is off). */
|
|
86
|
+
readonly path: string;
|
|
87
|
+
/** Whether cacache disk persistence is enabled for this instance. */
|
|
88
|
+
readonly diskEnabled: boolean;
|
|
89
|
+
/** Return a fresh-or-fallback value, refreshing when stale. */
|
|
90
|
+
get(): Promise<T>;
|
|
91
|
+
/** Read memory / disk without refreshing. */
|
|
92
|
+
peek(): Promise<T | undefined>;
|
|
93
|
+
/** Mutate the cached value and persist it. */
|
|
94
|
+
update(mutator: (current: T) => T): Promise<T>;
|
|
95
|
+
/** Force a refresh attempt even when the entry is still fresh. */
|
|
96
|
+
refresh(): Promise<T>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
100
|
+
|
|
101
|
+
/** Default cacache root for a package version under the dbx-tools cache dir. */
|
|
102
|
+
export function defaultMetadataCacheDir(version: string): string {
|
|
103
|
+
const cacheDirectory = envPaths("dbx-tools", { suffix: "" }).cache;
|
|
104
|
+
return resolve(cacheDirectory, "model-gateway", version);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether metadata caches should persist to disk in this process. */
|
|
108
|
+
export function metadataCacheDiskEnabled(environment: NodeJS.ProcessEnv = process.env): boolean {
|
|
109
|
+
return !environmentUtils.isDatabricksAppEnv(environment);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create one daily cache backed by cacache memoization, with optional disk.
|
|
114
|
+
*/
|
|
115
|
+
export function createMetadataCache<T>(options: MetadataCacheOptions<T>): MetadataCache<T> {
|
|
116
|
+
const ttlMs = options.ttlMs ?? MODEL_METADATA_TTL_MS;
|
|
117
|
+
const diskEnabled = options.disk ?? metadataCacheDiskEnabled();
|
|
118
|
+
const now = options.now ?? Date.now;
|
|
119
|
+
const pathPromise = resolveCacheDir(options);
|
|
120
|
+
let path = options.cacheDir;
|
|
121
|
+
|
|
122
|
+
const flightKey = (cachePath: string, suffix = ""): string =>
|
|
123
|
+
`${cachePath}::${options.key}${suffix}`;
|
|
124
|
+
|
|
125
|
+
/** Process-local store used when disk is off (Databricks Apps). */
|
|
126
|
+
const memoryOnly = new Map<string, MetadataCacheRecord<T>>();
|
|
127
|
+
|
|
128
|
+
const resolveValue = (input: MetadataCacheMergeInput<T>): T => options.merge(input);
|
|
129
|
+
|
|
130
|
+
const isFresh = (record: MetadataCacheRecord<T> | undefined): boolean =>
|
|
131
|
+
Boolean(record && now() - record.refreshedAt < ttlMs);
|
|
132
|
+
|
|
133
|
+
const cachePath = async (): Promise<string> => {
|
|
134
|
+
path ??= await pathPromise;
|
|
135
|
+
return path;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const readRecord = async (): Promise<MetadataCacheRecord<T> | undefined> => {
|
|
139
|
+
if (!diskEnabled) return memoryOnly.get(options.key);
|
|
140
|
+
const resolvedPath = await cachePath();
|
|
141
|
+
try {
|
|
142
|
+
// cacache reads its in-memory memo first unless memoize is false
|
|
143
|
+
const entry = await cacache.get(resolvedPath, options.key);
|
|
144
|
+
return decodeRecord<T>(entry.data);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (isCacheMiss(error)) return undefined;
|
|
147
|
+
logger.warn("metadata cache cacache read failed", {
|
|
148
|
+
key: options.key,
|
|
149
|
+
path: resolvedPath,
|
|
150
|
+
error,
|
|
151
|
+
});
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const writeRecord = async (record: MetadataCacheRecord<T>): Promise<void> => {
|
|
157
|
+
if (!diskEnabled) {
|
|
158
|
+
memoryOnly.set(options.key, record);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const resolvedPath = await cachePath();
|
|
162
|
+
try {
|
|
163
|
+
await cacache.put(resolvedPath, options.key, `${JSON.stringify(record)}\n`, {
|
|
164
|
+
memoize: true,
|
|
165
|
+
metadata: { refreshedAt: record.refreshedAt },
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.warn("metadata cache cacache write failed", {
|
|
169
|
+
key: options.key,
|
|
170
|
+
path: resolvedPath,
|
|
171
|
+
error,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const persist = async (value: T, refreshedAt = now()): Promise<T> => {
|
|
177
|
+
await writeRecord({ refreshedAt, value });
|
|
178
|
+
return value;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const refresh = async (force: boolean): Promise<T> => {
|
|
182
|
+
const resolvedPath = await cachePath();
|
|
183
|
+
return coalesce(flightKey(resolvedPath), async () => {
|
|
184
|
+
const previous = await readRecord();
|
|
185
|
+
if (!force && isFresh(previous)) {
|
|
186
|
+
return resolveValue({
|
|
187
|
+
fresh: undefined,
|
|
188
|
+
previous: previous?.value,
|
|
189
|
+
fallback: options.fallback,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let fresh: T | undefined;
|
|
194
|
+
if (options.load) {
|
|
195
|
+
try {
|
|
196
|
+
fresh = await options.load();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
logger.warn("metadata cache refresh failed; keeping last known good", {
|
|
199
|
+
key: options.key,
|
|
200
|
+
error,
|
|
201
|
+
path: resolvedPath,
|
|
202
|
+
diskEnabled,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const value = resolveValue({
|
|
208
|
+
fresh,
|
|
209
|
+
previous: previous?.value,
|
|
210
|
+
fallback: options.fallback,
|
|
211
|
+
});
|
|
212
|
+
if (fresh !== undefined || !previous) {
|
|
213
|
+
await persist(value);
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
get path() {
|
|
221
|
+
if (!path) {
|
|
222
|
+
throw new Error("metadata cache path is not resolved until the first cache read");
|
|
223
|
+
}
|
|
224
|
+
return path;
|
|
225
|
+
},
|
|
226
|
+
diskEnabled,
|
|
227
|
+
get: () => refresh(false),
|
|
228
|
+
peek: async () => (await readRecord())?.value,
|
|
229
|
+
refresh: () => refresh(true),
|
|
230
|
+
update: async (mutator) => {
|
|
231
|
+
const resolvedPath = await cachePath();
|
|
232
|
+
return coalesce(flightKey(resolvedPath, ":update"), async () => {
|
|
233
|
+
const previous = await readRecord();
|
|
234
|
+
const baseline = resolveValue({
|
|
235
|
+
fresh: undefined,
|
|
236
|
+
previous: previous?.value,
|
|
237
|
+
fallback: options.fallback,
|
|
238
|
+
});
|
|
239
|
+
return persist(mutator(baseline));
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function resolveCacheDir(options: {
|
|
246
|
+
readonly cacheDir?: string;
|
|
247
|
+
readonly version?: string;
|
|
248
|
+
}): Promise<string> {
|
|
249
|
+
if (options.cacheDir) return options.cacheDir;
|
|
250
|
+
const version = options.version ?? (await packageIdentity()).version;
|
|
251
|
+
return defaultMetadataCacheDir(version);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Prefer a non-empty fresh array, else previous, else fallback. */
|
|
255
|
+
export function mergePreferFreshList<T>(input: MetadataCacheMergeInput<readonly T[]>): T[] {
|
|
256
|
+
if (input.fresh && input.fresh.length > 0) return [...input.fresh];
|
|
257
|
+
if (input.previous && input.previous.length > 0) return [...input.previous];
|
|
258
|
+
return [...input.fallback];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Shallow-merge record catalogues: fallback, then previous, then fresh keys.
|
|
263
|
+
* Fresh values win per key.
|
|
264
|
+
*/
|
|
265
|
+
export function mergePreferFreshRecord<T>(
|
|
266
|
+
input: MetadataCacheMergeInput<Readonly<Record<string, T>>>,
|
|
267
|
+
): Record<string, T> {
|
|
268
|
+
return {
|
|
269
|
+
...input.fallback,
|
|
270
|
+
...(input.previous ?? {}),
|
|
271
|
+
...(input.fresh ?? {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Test helper: wipe a cacache root and clear cacache memoization. */
|
|
276
|
+
export async function resetMetadataCacheDir(cacheDir: string): Promise<void> {
|
|
277
|
+
try {
|
|
278
|
+
await cacache.rm.all(cacheDir);
|
|
279
|
+
} catch {
|
|
280
|
+
// empty / missing cache is fine
|
|
281
|
+
}
|
|
282
|
+
cacache.clearMemoized();
|
|
283
|
+
for (const key of [...inflight.keys()]) {
|
|
284
|
+
if (key.startsWith(`${cacheDir}::`)) inflight.delete(key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function coalesce<T>(key: string, work: () => Promise<T>): Promise<T> {
|
|
289
|
+
const existing = inflight.get(key);
|
|
290
|
+
if (existing) return existing as Promise<T>;
|
|
291
|
+
const pending = work().finally(() => {
|
|
292
|
+
if (inflight.get(key) === pending) inflight.delete(key);
|
|
293
|
+
});
|
|
294
|
+
inflight.set(key, pending);
|
|
295
|
+
return pending;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function decodeRecord<T>(data: Buffer): MetadataCacheRecord<T> | undefined {
|
|
299
|
+
try {
|
|
300
|
+
const parsed = JSON.parse(data.toString("utf8")) as unknown;
|
|
301
|
+
if (
|
|
302
|
+
!parsed ||
|
|
303
|
+
typeof parsed !== "object" ||
|
|
304
|
+
typeof (parsed as MetadataCacheRecord<T>).refreshedAt !== "number"
|
|
305
|
+
) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
return parsed as MetadataCacheRecord<T>;
|
|
309
|
+
} catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function isCacheMiss(error: unknown): boolean {
|
|
315
|
+
return Boolean(
|
|
316
|
+
error &&
|
|
317
|
+
typeof error === "object" &&
|
|
318
|
+
"code" in error &&
|
|
319
|
+
(error as { code?: string }).code === "ENOENT",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|