@dbx-tools/model 0.1.42

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.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@dbx-tools/model` public surface.
3
+ *
4
+ * Bundles the package's Node-side Model Serving access (cached
5
+ * `/serving-endpoints` listing plus fuzzy name resolution), the
6
+ * workspace-aware {@link resolveModel} selector, and the server-only
7
+ * offline fallback opinion (`FALLBACK_MODEL_IDS` / `modelsForTier`)
8
+ * with a re-export of the pure `@dbx-tools/model-shared` surface
9
+ * (capability tiers, the score profile, the endpoint descriptor, and
10
+ * the tier classifier) so a single `from "@dbx-tools/model"` import
11
+ * serves server-side consumers.
12
+ *
13
+ * Browser-side consumers should import `@dbx-tools/model-shared`
14
+ * directly: the serving driver pulls in `WorkspaceClient` and AppKit's
15
+ * `CacheManager` and is Node-only, whereas the re-exported surface is
16
+ * pure (types + sync functions) and safe for any runtime.
17
+ */
18
+ export * from "@dbx-tools/model-shared";
19
+ export * from "./src/fallback.js";
20
+ export * from "./src/resolve.js";
21
+ export * from "./src/serving.js";
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@dbx-tools/model` public surface.
3
+ *
4
+ * Bundles the package's Node-side Model Serving access (cached
5
+ * `/serving-endpoints` listing plus fuzzy name resolution), the
6
+ * workspace-aware {@link resolveModel} selector, and the server-only
7
+ * offline fallback opinion (`FALLBACK_MODEL_IDS` / `modelsForTier`)
8
+ * with a re-export of the pure `@dbx-tools/model-shared` surface
9
+ * (capability tiers, the score profile, the endpoint descriptor, and
10
+ * the tier classifier) so a single `from "@dbx-tools/model"` import
11
+ * serves server-side consumers.
12
+ *
13
+ * Browser-side consumers should import `@dbx-tools/model-shared`
14
+ * directly: the serving driver pulls in `WorkspaceClient` and AppKit's
15
+ * `CacheManager` and is Node-only, whereas the re-exported surface is
16
+ * pure (types + sync functions) and safe for any runtime.
17
+ */
18
+ export * from "@dbx-tools/model-shared";
19
+ export * from "./src/fallback.js";
20
+ export * from "./src/resolve.js";
21
+ export * from "./src/serving.js";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Server-side offline fallback opinion for model selection.
3
+ *
4
+ * When the live `/serving-endpoints` catalogue can't be read at all -
5
+ * no user token, the service principal can't list, or the workspace is
6
+ * unreachable - the resolver still has to name *some* endpoint. This
7
+ * module holds that floor: a small, hard-coded set of well-known
8
+ * Foundation Model API endpoint names, each bucketed into a
9
+ * {@link ModelTier} by the shared {@link classifyByFamily} heuristic
10
+ * (no tiers are hard-coded here) and ordered best-first.
11
+ *
12
+ * This is deliberately *server-only*. A browser client never talks to
13
+ * Databricks directly - it always goes through this server - so it has
14
+ * nothing to fall back to and must not assume a stale, baked-in model
15
+ * list; it consumes the live `/models` response instead. The pure
16
+ * classifier ({@link classifyEndpoints}) is what the client shares.
17
+ */
18
+ import { ModelTier } from "@dbx-tools/model-shared";
19
+ /**
20
+ * Static fallback model ids for a tier, drawn from the small built-in
21
+ * {@link FALLBACK_MODEL_NAMES} list and ordered best-first by family
22
+ * rank. Sync and workspace-independent: this is the *fallback opinion*
23
+ * used to seed default lists or when the live catalogue is unreachable
24
+ * - live resolution prefers `classifyEndpoints`.
25
+ */
26
+ export declare function modelsForTier(tier: ModelTier): readonly string[];
27
+ /** Top static fallback model id for a tier. */
28
+ export declare function modelForTier(tier: ModelTier): string;
29
+ /**
30
+ * Priority-ordered fallback chain (Thinking -> Balanced -> Fast) over
31
+ * the small built-in list. The floor walked at resolve time when no
32
+ * agent / plugin / env / request model is set *and* the live
33
+ * catalogue yields nothing.
34
+ */
35
+ export declare const FALLBACK_MODEL_IDS: readonly string[];
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Server-side offline fallback opinion for model selection.
3
+ *
4
+ * When the live `/serving-endpoints` catalogue can't be read at all -
5
+ * no user token, the service principal can't list, or the workspace is
6
+ * unreachable - the resolver still has to name *some* endpoint. This
7
+ * module holds that floor: a small, hard-coded set of well-known
8
+ * Foundation Model API endpoint names, each bucketed into a
9
+ * {@link ModelTier} by the shared {@link classifyByFamily} heuristic
10
+ * (no tiers are hard-coded here) and ordered best-first.
11
+ *
12
+ * This is deliberately *server-only*. A browser client never talks to
13
+ * Databricks directly - it always goes through this server - so it has
14
+ * nothing to fall back to and must not assume a stale, baked-in model
15
+ * list; it consumes the live `/models` response instead. The pure
16
+ * classifier ({@link classifyEndpoints}) is what the client shares.
17
+ */
18
+ import { classifyByFamily, ModelTier } from "@dbx-tools/model-shared";
19
+ /**
20
+ * Small, last-resort set of well-known Foundation Model API endpoint
21
+ * names, ordered best-first within each tier by {@link classifyByFamily}.
22
+ * Used only as the floor when the live `/serving-endpoints` catalogue
23
+ * can't be read at resolve time; the live, score-driven classification
24
+ * supersedes it whenever the workspace listing is available. Tiers are
25
+ * not hard-coded here - each name is classified by family heuristic.
26
+ */
27
+ const FALLBACK_MODEL_NAMES = [
28
+ "databricks-claude-opus-4-8",
29
+ "databricks-gpt-5-5-pro",
30
+ "databricks-gemini-3-1-pro",
31
+ "databricks-claude-sonnet-4-6",
32
+ "databricks-gpt-5-5",
33
+ "databricks-meta-llama-3-3-70b-instruct",
34
+ "databricks-claude-haiku-4-5",
35
+ "databricks-gpt-5-nano",
36
+ "databricks-meta-llama-3-1-8b-instruct",
37
+ ];
38
+ /**
39
+ * Static fallback model ids for a tier, drawn from the small built-in
40
+ * {@link FALLBACK_MODEL_NAMES} list and ordered best-first by family
41
+ * rank. Sync and workspace-independent: this is the *fallback opinion*
42
+ * used to seed default lists or when the live catalogue is unreachable
43
+ * - live resolution prefers `classifyEndpoints`.
44
+ */
45
+ export function modelsForTier(tier) {
46
+ return FALLBACK_MODEL_NAMES.map((name) => ({ name, c: classifyByFamily(name) }))
47
+ .filter((x) => x.c !== null && x.c.tier === tier)
48
+ .sort((a, b) => b.c.rank - a.c.rank)
49
+ .map((x) => x.name);
50
+ }
51
+ /** Top static fallback model id for a tier. */
52
+ export function modelForTier(tier) {
53
+ return modelsForTier(tier)[0];
54
+ }
55
+ /**
56
+ * Priority-ordered fallback chain (Thinking -> Balanced -> Fast) over
57
+ * the small built-in list. The floor walked at resolve time when no
58
+ * agent / plugin / env / request model is set *and* the live
59
+ * catalogue yields nothing.
60
+ */
61
+ export const FALLBACK_MODEL_IDS = [
62
+ ...modelsForTier(ModelTier.Thinking),
63
+ ...modelsForTier(ModelTier.Balanced),
64
+ ...modelsForTier(ModelTier.Fast),
65
+ ];
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Workspace-aware model selection.
3
+ *
4
+ * Given a caller's intent (an explicit name, a capability
5
+ * {@link ModelTier}, or nothing), the resolver returns the endpoint id
6
+ * to use - always one the workspace actually has, degrading from "best
7
+ * in tier" down to the static fallback floor. Two entry points:
8
+ *
9
+ * - {@link selectModel} is the high-level, I/O-doing call: hand it a
10
+ * `WorkspaceClient` and intent and it lists `/serving-endpoints`
11
+ * (cached) and resolves in one step. This is what a non-Mastra
12
+ * consumer (e.g. a job that just needs a model name) reaches for.
13
+ * - {@link resolveModel} is the pure form over an endpoint list you
14
+ * already hold; `selectModel` is a thin wrapper over it.
15
+ */
16
+ import { ModelTier, type ServingEndpointSummary } from "@dbx-tools/model-shared";
17
+ import { type WorkspaceClientLike } from "./serving.js";
18
+ /** Caller intent passed to {@link resolveModel}. */
19
+ export interface ResolveModelInput {
20
+ /**
21
+ * Explicit model id / loose name (per-request override, agent /
22
+ * plugin default, or env var). When set it wins over `tier` and
23
+ * `fallbacks`.
24
+ */
25
+ explicit?: string;
26
+ /**
27
+ * Fuzzy-match an `explicit` name against the live catalogue so loose
28
+ * names like `"claude sonnet"` resolve. Default `true`. When `false`
29
+ * the explicit input is returned verbatim (Databricks surfaces the
30
+ * canonical 404 if it doesn't exist).
31
+ */
32
+ fuzzy?: boolean;
33
+ /** Fuse.js threshold forwarded to {@link resolveModelId}. */
34
+ threshold?: number;
35
+ /**
36
+ * Capability tier to resolve when no `explicit` id is given. The
37
+ * live catalogue is classified by its Foundation Model API scores
38
+ * and the top available model in the tier wins, falling back to the
39
+ * tier's small static list.
40
+ */
41
+ tier?: ModelTier;
42
+ /**
43
+ * Operator-supplied fallback ids tried *first* in the no-explicit,
44
+ * no-tier path (e.g. a regulated workspace pinned to an approved
45
+ * subset), ahead of the auto-classified catalogue.
46
+ */
47
+ fallbacks?: readonly string[];
48
+ }
49
+ /** Outcome of {@link resolveModel}: the chosen id plus how it was reached. */
50
+ export interface ResolvedModelSelection {
51
+ modelId: string;
52
+ source: "explicit" | "fuzzy-match" | "tier" | "fallback";
53
+ }
54
+ /** Intent + catalogue knobs passed to {@link selectModel}. */
55
+ export interface SelectModelInput extends ResolveModelInput {
56
+ /** TTL override for the cached `/serving-endpoints` listing, in ms. */
57
+ ttlMs?: number;
58
+ }
59
+ /**
60
+ * Resolve a model id for a workspace in one call: list its
61
+ * `/serving-endpoints` (cached) and run {@link resolveModel} over the
62
+ * result. This is the entry point for any consumer that holds a
63
+ * `WorkspaceClient` and just wants a usable model name - a Lakeflow
64
+ * job, a one-off script, or the Mastra plugin alike.
65
+ *
66
+ * Cheap exit: when an `explicit` name is given and `fuzzy` is off, the
67
+ * catalogue is never fetched - the name is returned verbatim and
68
+ * Databricks surfaces the canonical 404 if it doesn't exist. Catalogue
69
+ * fetches otherwise fail loud: network / auth errors propagate so the
70
+ * caller sees the real SDK message instead of a silent fallback.
71
+ *
72
+ * @param host - Workspace host used as the cache key. Pass the value
73
+ * resolved from `client.config.getHost()`.
74
+ */
75
+ export declare function selectModel(client: WorkspaceClientLike, host: string, input?: SelectModelInput): Promise<ResolvedModelSelection>;
76
+ /**
77
+ * Resolve a model id from the live catalogue and caller intent.
78
+ *
79
+ * 1. **Explicit ask**: with `fuzzy` off, returned verbatim; otherwise
80
+ * snapped to the closest live endpoint via {@link resolveModelId}.
81
+ * 2. **Tier intent**: classify the live catalogue and return the top
82
+ * available model in that tier, then the tier's static list, then
83
+ * the {@link FALLBACK_MODEL_IDS} floor.
84
+ * 3. **Neither**: walk `fallbacks` first, then the live
85
+ * score-classified catalogue (Thinking -> Balanced -> Fast), then
86
+ * the static floor, and return the first id actually present.
87
+ */
88
+ export declare function resolveModel(endpoints: readonly ServingEndpointSummary[], input?: ResolveModelInput): ResolvedModelSelection;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Workspace-aware model selection.
3
+ *
4
+ * Given a caller's intent (an explicit name, a capability
5
+ * {@link ModelTier}, or nothing), the resolver returns the endpoint id
6
+ * to use - always one the workspace actually has, degrading from "best
7
+ * in tier" down to the static fallback floor. Two entry points:
8
+ *
9
+ * - {@link selectModel} is the high-level, I/O-doing call: hand it a
10
+ * `WorkspaceClient` and intent and it lists `/serving-endpoints`
11
+ * (cached) and resolves in one step. This is what a non-Mastra
12
+ * consumer (e.g. a job that just needs a model name) reaches for.
13
+ * - {@link resolveModel} is the pure form over an endpoint list you
14
+ * already hold; `selectModel` is a thin wrapper over it.
15
+ */
16
+ import { classifyEndpoints, ModelTier, } from "@dbx-tools/model-shared";
17
+ import { FALLBACK_MODEL_IDS, modelsForTier } from "./fallback.js";
18
+ import { listServingEndpoints, resolveModelId, } from "./serving.js";
19
+ /**
20
+ * Resolve a model id for a workspace in one call: list its
21
+ * `/serving-endpoints` (cached) and run {@link resolveModel} over the
22
+ * result. This is the entry point for any consumer that holds a
23
+ * `WorkspaceClient` and just wants a usable model name - a Lakeflow
24
+ * job, a one-off script, or the Mastra plugin alike.
25
+ *
26
+ * Cheap exit: when an `explicit` name is given and `fuzzy` is off, the
27
+ * catalogue is never fetched - the name is returned verbatim and
28
+ * Databricks surfaces the canonical 404 if it doesn't exist. Catalogue
29
+ * fetches otherwise fail loud: network / auth errors propagate so the
30
+ * caller sees the real SDK message instead of a silent fallback.
31
+ *
32
+ * @param host - Workspace host used as the cache key. Pass the value
33
+ * resolved from `client.config.getHost()`.
34
+ */
35
+ export async function selectModel(client, host, input = {}) {
36
+ if (input.explicit !== undefined && input.fuzzy === false) {
37
+ return { modelId: input.explicit, source: "explicit" };
38
+ }
39
+ const endpoints = await listServingEndpoints(client, host, {
40
+ ...(input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {}),
41
+ });
42
+ return resolveModel(endpoints, input);
43
+ }
44
+ /**
45
+ * Resolve a model id from the live catalogue and caller intent.
46
+ *
47
+ * 1. **Explicit ask**: with `fuzzy` off, returned verbatim; otherwise
48
+ * snapped to the closest live endpoint via {@link resolveModelId}.
49
+ * 2. **Tier intent**: classify the live catalogue and return the top
50
+ * available model in that tier, then the tier's static list, then
51
+ * the {@link FALLBACK_MODEL_IDS} floor.
52
+ * 3. **Neither**: walk `fallbacks` first, then the live
53
+ * score-classified catalogue (Thinking -> Balanced -> Fast), then
54
+ * the static floor, and return the first id actually present.
55
+ */
56
+ export function resolveModel(endpoints, input = {}) {
57
+ if (input.explicit !== undefined) {
58
+ if (input.fuzzy === false) {
59
+ return { modelId: input.explicit, source: "explicit" };
60
+ }
61
+ const { modelId } = resolveModelId(input.explicit, endpoints, {
62
+ threshold: input.threshold,
63
+ });
64
+ return { modelId, source: "fuzzy-match" };
65
+ }
66
+ const classified = classifyEndpoints(endpoints);
67
+ const chain = input.tier !== undefined
68
+ ? tierChain(classified, input.tier)
69
+ : defaultChain(classified, input.fallbacks);
70
+ return {
71
+ modelId: pickFirstAvailable(chain, endpoints),
72
+ source: input.tier !== undefined ? "tier" : "fallback",
73
+ };
74
+ }
75
+ /**
76
+ * Candidate chain for an explicit {@link ModelTier} ask: live models
77
+ * classified into that tier (best-first), then the tier's small static
78
+ * list, then the full fallback floor as a last resort.
79
+ */
80
+ function tierChain(classified, tier) {
81
+ return dedupe([
82
+ ...classified[tier].map((e) => e.name),
83
+ ...modelsForTier(tier),
84
+ ...FALLBACK_MODEL_IDS,
85
+ ]);
86
+ }
87
+ /**
88
+ * Candidate chain for the no-explicit default: any operator-supplied
89
+ * `fallbacks` first, then the live score-classified catalogue in
90
+ * descending tier order, then the static fallback floor.
91
+ */
92
+ function defaultChain(classified, fallbacks) {
93
+ const live = [
94
+ ...classified[ModelTier.Thinking],
95
+ ...classified[ModelTier.Balanced],
96
+ ...classified[ModelTier.Fast],
97
+ ].map((e) => e.name);
98
+ return dedupe([...(fallbacks ?? []), ...live, ...FALLBACK_MODEL_IDS]);
99
+ }
100
+ /** Drop duplicate ids while preserving first-seen order. */
101
+ function dedupe(ids) {
102
+ return [...new Set(ids)];
103
+ }
104
+ /**
105
+ * Find the first id in `candidates` whose endpoint is present in
106
+ * `endpoints`. Returns the top candidate when the workspace has none
107
+ * of them so callers always get a string; an offline workspace then
108
+ * receives a clean 404 from Databricks instead of a malformed config.
109
+ */
110
+ function pickFirstAvailable(candidates, endpoints) {
111
+ const present = new Set(endpoints.map((e) => e.name));
112
+ for (const candidate of candidates) {
113
+ if (present.has(candidate))
114
+ return candidate;
115
+ }
116
+ return candidates[0] ?? FALLBACK_MODEL_IDS[0];
117
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Live Databricks Model Serving catalogue access.
3
+ *
4
+ * Lists the workspace's `/serving-endpoints` once per host and caches
5
+ * the result with a TTL via AppKit's `CacheManager`, with concurrent
6
+ * callers sharing one in-flight promise (the coalescing pattern of
7
+ * Python's `cachetools-async`). Surfaces each endpoint as a stable
8
+ * {@link ServingEndpointSummary} - including the Foundation Model API
9
+ * `quality` / `speed` / `cost` profile when present - and snaps loose,
10
+ * human-typed names to real endpoint ids through `fuse.js` extended
11
+ * search so tokens like `"claude sonnet"` resolve to
12
+ * `databricks-claude-sonnet-4-6`.
13
+ */
14
+ import { type getExecutionContext } from "@databricks/appkit";
15
+ import type { ServingEndpointSummary } from "@dbx-tools/model-shared";
16
+ /**
17
+ * Structural type for the Databricks workspace client. Derived from
18
+ * AppKit's `ExecutionContext` so this module doesn't take a direct
19
+ * dependency on `@databricks/sdk-experimental`; the dep flows in
20
+ * transitively through `@databricks/appkit`.
21
+ */
22
+ export type WorkspaceClientLike = ReturnType<typeof getExecutionContext>["client"];
23
+ /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
24
+ export declare const DEFAULT_MODEL_CACHE_TTL_MS: number;
25
+ /** Default Fuse.js score threshold below which a fuzzy match is accepted. */
26
+ export declare const DEFAULT_FUZZY_THRESHOLD = 0.4;
27
+ /**
28
+ * List Model Serving endpoints for the workspace owning `client`,
29
+ * routed through AppKit's `CacheManager`. The manager gives us
30
+ * everything `cachetools.TTLCache` provides plus what
31
+ * `cachetools-async` adds on top: per-entry TTL, in-flight request
32
+ * coalescing (concurrent callers share one fetch via the manager's
33
+ * internal `inFlightRequests` map), bounded size, telemetry spans
34
+ * (`cache.getOrExecute`), and optional Lakebase persistence so the
35
+ * catalogue survives restarts when the lakebase plugin is wired up.
36
+ *
37
+ * Returns plain {@link ServingEndpointSummary} objects (a stable
38
+ * subset of the SDK type) so cache hits never expose stale SDK
39
+ * internals. Errors from `CacheManager` or the SDK fetch propagate
40
+ * to the caller - we don't swallow them so users see the real
41
+ * auth / network issue.
42
+ *
43
+ * @param host - Workspace host used as the cache key. Pass the value
44
+ * resolved from `client.config.getHost()` so multi-host apps share
45
+ * one entry per workspace.
46
+ * @param opts.ttlMs - Override the default TTL just for this call.
47
+ * Forwarded to `CacheManager` as seconds.
48
+ */
49
+ export declare function listServingEndpoints(client: WorkspaceClientLike, host: string, opts?: {
50
+ ttlMs?: number;
51
+ }): Promise<ServingEndpointSummary[]>;
52
+ /**
53
+ * Force-evict cached endpoint listings via AppKit's `CacheManager`.
54
+ * With a `host` deletes that one workspace's entry; without one
55
+ * clears every cache entry on the manager (since `CacheManager`
56
+ * doesn't expose a namespace-scoped clear, this is the brute-force
57
+ * path - fine for tests, avoid in steady-state code).
58
+ */
59
+ export declare function clearServingEndpointsCache(host?: string): Promise<void>;
60
+ /**
61
+ * Result of fuzzy-resolving a user-supplied model name against the
62
+ * live endpoint list. `score` is Fuse.js's distance (`0` is exact,
63
+ * `1` is no match); `matched` is `false` when the score exceeds the
64
+ * configured threshold so callers can fall back to the original
65
+ * input (Databricks will then return a clean 404).
66
+ */
67
+ export interface ResolvedModel {
68
+ modelId: string;
69
+ matched: boolean;
70
+ score?: number;
71
+ }
72
+ /** Options accepted by {@link resolveModelId}. */
73
+ export interface ResolveModelOptions {
74
+ /** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
75
+ threshold?: number;
76
+ }
77
+ /**
78
+ * Snap a user-supplied model name to the closest configured serving
79
+ * endpoint:
80
+ *
81
+ * 1. Exact name match wins immediately (no fuzzy needed).
82
+ * 2. Otherwise the input is tokenized (dashes / underscores / spaces
83
+ * become separators) and fed through Fuse.js extended search,
84
+ * which AND-s each token with fuzzy matching enabled. This is the
85
+ * "tokenized fuzzy match" the user reaches for when they type
86
+ * `"claude sonnet"` instead of the full endpoint name.
87
+ * 3. If the best Fuse score is above `threshold`, return the input
88
+ * unchanged and let the upstream call surface the 404. This keeps
89
+ * deliberate model ids (e.g. brand new endpoints) from being
90
+ * silently rewritten to a similar-looking neighbour.
91
+ *
92
+ * Pass an empty endpoint list to short-circuit fuzzy matching - the
93
+ * input is returned verbatim. This is what callers do when the
94
+ * workspace client can't be reached at resolve time.
95
+ */
96
+ export declare function resolveModelId(input: string, endpoints: readonly ServingEndpointSummary[], opts?: ResolveModelOptions): ResolvedModel;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Live Databricks Model Serving catalogue access.
3
+ *
4
+ * Lists the workspace's `/serving-endpoints` once per host and caches
5
+ * the result with a TTL via AppKit's `CacheManager`, with concurrent
6
+ * callers sharing one in-flight promise (the coalescing pattern of
7
+ * Python's `cachetools-async`). Surfaces each endpoint as a stable
8
+ * {@link ServingEndpointSummary} - including the Foundation Model API
9
+ * `quality` / `speed` / `cost` profile when present - and snaps loose,
10
+ * human-typed names to real endpoint ids through `fuse.js` extended
11
+ * search so tokens like `"claude sonnet"` resolve to
12
+ * `databricks-claude-sonnet-4-6`.
13
+ */
14
+ import { CacheManager } from "@databricks/appkit";
15
+ import { logUtils, stringUtils } from "@dbx-tools/shared";
16
+ import Fuse from "fuse.js";
17
+ const log = logUtils.logger("model/serving");
18
+ /** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
19
+ export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
20
+ /** Default Fuse.js score threshold below which a fuzzy match is accepted. */
21
+ export const DEFAULT_FUZZY_THRESHOLD = 0.4;
22
+ /** Cache key parts under which endpoint listings are stored. */
23
+ const CACHE_KEY_NAMESPACE = "serving-endpoints";
24
+ /**
25
+ * Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`.
26
+ * Endpoint visibility is effectively workspace-scoped (we cache by
27
+ * host in the key parts), so a single shared key lets every user of
28
+ * the same workspace share one cached fetch and coalesce on the
29
+ * in-flight promise. Permissions can differ in theory, but the
30
+ * Foundation Model API catalogue is the same view for every caller.
31
+ */
32
+ const SHARED_USER_KEY = "model-shared";
33
+ /**
34
+ * List Model Serving endpoints for the workspace owning `client`,
35
+ * routed through AppKit's `CacheManager`. The manager gives us
36
+ * everything `cachetools.TTLCache` provides plus what
37
+ * `cachetools-async` adds on top: per-entry TTL, in-flight request
38
+ * coalescing (concurrent callers share one fetch via the manager's
39
+ * internal `inFlightRequests` map), bounded size, telemetry spans
40
+ * (`cache.getOrExecute`), and optional Lakebase persistence so the
41
+ * catalogue survives restarts when the lakebase plugin is wired up.
42
+ *
43
+ * Returns plain {@link ServingEndpointSummary} objects (a stable
44
+ * subset of the SDK type) so cache hits never expose stale SDK
45
+ * internals. Errors from `CacheManager` or the SDK fetch propagate
46
+ * to the caller - we don't swallow them so users see the real
47
+ * auth / network issue.
48
+ *
49
+ * @param host - Workspace host used as the cache key. Pass the value
50
+ * resolved from `client.config.getHost()` so multi-host apps share
51
+ * one entry per workspace.
52
+ * @param opts.ttlMs - Override the default TTL just for this call.
53
+ * Forwarded to `CacheManager` as seconds.
54
+ */
55
+ export async function listServingEndpoints(client, host, opts = {}) {
56
+ const ttlSec = Math.max(1, Math.round((opts.ttlMs ?? DEFAULT_MODEL_CACHE_TTL_MS) / 1000));
57
+ return CacheManager.getInstanceSync().getOrExecute([CACHE_KEY_NAMESPACE, host], () => fetchEndpoints(client), SHARED_USER_KEY, { ttl: ttlSec });
58
+ }
59
+ async function fetchEndpoints(client) {
60
+ const startedAt = Date.now();
61
+ const out = [];
62
+ for await (const ep of client.servingEndpoints.list()) {
63
+ if (!ep.name)
64
+ continue;
65
+ const profile = extractProfile(ep);
66
+ out.push({
67
+ name: ep.name,
68
+ ...(ep.task !== undefined ? { task: ep.task } : {}),
69
+ ...(ep.state?.ready !== undefined ? { state: String(ep.state.ready) } : {}),
70
+ ...(ep.description !== undefined ? { description: ep.description } : {}),
71
+ ...(profile ? { profile } : {}),
72
+ });
73
+ }
74
+ log.debug("listed", { count: out.length, elapsedMs: Date.now() - startedAt });
75
+ return out;
76
+ }
77
+ /**
78
+ * Pull the Foundation Model API `quality` / `speed` / `cost` scores
79
+ * off a serving-endpoint listing entry. Databricks returns these
80
+ * under `config.served_entities[].foundation_model.ai_gateway_model_profile`
81
+ * (snake_case at the wire level, preserved verbatim by the SDK), but
82
+ * the field is newer than the typed `FoundationModel` interface, so we
83
+ * read it through a structural cast. Returns `undefined` when no
84
+ * served entity carries a profile (custom models, embeddings, and
85
+ * brand-new endpoints that Databricks has not scored yet).
86
+ */
87
+ function extractProfile(ep) {
88
+ const entities = ep.config?.served_entities;
89
+ if (!entities)
90
+ return undefined;
91
+ for (const entity of entities) {
92
+ const raw = entity.foundation_model?.ai_gateway_model_profile;
93
+ if (!raw)
94
+ continue;
95
+ const profile = {};
96
+ if (Number.isFinite(raw.quality))
97
+ profile.quality = raw.quality;
98
+ if (Number.isFinite(raw.speed))
99
+ profile.speed = raw.speed;
100
+ if (Number.isFinite(raw.cost))
101
+ profile.cost = raw.cost;
102
+ if (Object.keys(profile).length > 0)
103
+ return profile;
104
+ }
105
+ return undefined;
106
+ }
107
+ /**
108
+ * Force-evict cached endpoint listings via AppKit's `CacheManager`.
109
+ * With a `host` deletes that one workspace's entry; without one
110
+ * clears every cache entry on the manager (since `CacheManager`
111
+ * doesn't expose a namespace-scoped clear, this is the brute-force
112
+ * path - fine for tests, avoid in steady-state code).
113
+ */
114
+ export async function clearServingEndpointsCache(host) {
115
+ const cache = CacheManager.getInstanceSync();
116
+ if (host) {
117
+ const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
118
+ await cache.delete(key);
119
+ }
120
+ else {
121
+ await cache.clear();
122
+ }
123
+ }
124
+ /**
125
+ * Snap a user-supplied model name to the closest configured serving
126
+ * endpoint:
127
+ *
128
+ * 1. Exact name match wins immediately (no fuzzy needed).
129
+ * 2. Otherwise the input is tokenized (dashes / underscores / spaces
130
+ * become separators) and fed through Fuse.js extended search,
131
+ * which AND-s each token with fuzzy matching enabled. This is the
132
+ * "tokenized fuzzy match" the user reaches for when they type
133
+ * `"claude sonnet"` instead of the full endpoint name.
134
+ * 3. If the best Fuse score is above `threshold`, return the input
135
+ * unchanged and let the upstream call surface the 404. This keeps
136
+ * deliberate model ids (e.g. brand new endpoints) from being
137
+ * silently rewritten to a similar-looking neighbour.
138
+ *
139
+ * Pass an empty endpoint list to short-circuit fuzzy matching - the
140
+ * input is returned verbatim. This is what callers do when the
141
+ * workspace client can't be reached at resolve time.
142
+ */
143
+ export function resolveModelId(input, endpoints, opts = {}) {
144
+ if (endpoints.length === 0) {
145
+ log.debug("resolve:no-endpoints", { input });
146
+ return { modelId: input, matched: false };
147
+ }
148
+ for (const ep of endpoints) {
149
+ if (ep.name === input) {
150
+ log.debug("resolve:exact", { input });
151
+ return { modelId: ep.name, matched: true, score: 0 };
152
+ }
153
+ }
154
+ const threshold = opts.threshold ?? DEFAULT_FUZZY_THRESHOLD;
155
+ const fuse = new Fuse(endpoints, {
156
+ keys: ["name"],
157
+ threshold,
158
+ ignoreLocation: true,
159
+ includeScore: true,
160
+ useExtendedSearch: true,
161
+ isCaseSensitive: false,
162
+ });
163
+ // Fuse 7.3 has no built-in tokenize hook; in extended search,
164
+ // space-separated tokens are AND-ed with fuzzy matching enabled. We
165
+ // lean on the shared tokenizer so the splitting rules stay
166
+ // consistent with the rest of the toolkit.
167
+ const query = Array.from(stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input)).join(" ");
168
+ if (!query) {
169
+ log.debug("resolve:empty-tokens", { input });
170
+ return { modelId: input, matched: false };
171
+ }
172
+ const results = fuse.search(query);
173
+ const best = results[0];
174
+ if (best?.item.name && (best.score ?? 0) <= threshold) {
175
+ log.debug("resolve:fuzzy-match", {
176
+ input,
177
+ modelId: best.item.name,
178
+ score: best.score,
179
+ });
180
+ return { modelId: best.item.name, matched: true, score: best.score };
181
+ }
182
+ log.debug("resolve:no-match", {
183
+ input,
184
+ bestScore: best?.score,
185
+ threshold,
186
+ });
187
+ return { modelId: input, matched: false };
188
+ }