@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.
Files changed (52) hide show
  1. package/README.md +3 -3
  2. package/exports.ts +1 -0
  3. package/index.ts +12 -8
  4. package/lib/index.d.ts +12 -8
  5. package/lib/index.js +8 -5
  6. package/lib/scripts/generate-metadata.d.ts +1 -0
  7. package/lib/scripts/generate-metadata.js +65 -0
  8. package/lib/src/_metadata-contract.d.ts +12 -6
  9. package/lib/src/_metadata-contract.js +3 -1
  10. package/lib/src/_metadata-generator.d.ts +3 -1
  11. package/lib/src/_metadata-generator.js +148 -1
  12. package/lib/src/_ranking.js +39 -12
  13. package/lib/src/_selection.d.ts +1 -17
  14. package/lib/src/_selection.js +1 -1
  15. package/lib/src/classify.d.ts +1 -17
  16. package/lib/src/classify.js +1 -1
  17. package/lib/src/generated/model-reasoning.json +147 -0
  18. package/lib/src/invoke.d.ts +8 -0
  19. package/lib/src/invoke.js +13 -1
  20. package/lib/src/metadata-cache.d.ts +84 -0
  21. package/lib/src/metadata-cache.js +234 -0
  22. package/lib/src/metadata.d.ts +52 -17
  23. package/lib/src/metadata.js +251 -18
  24. package/lib/src/model-catalog.d.ts +1 -19
  25. package/lib/src/model-catalog.js +9 -2
  26. package/lib/src/model-client.d.ts +11 -19
  27. package/lib/src/model-client.js +42 -24
  28. package/lib/src/reasoning-translation.d.ts +105 -0
  29. package/lib/src/reasoning-translation.js +444 -0
  30. package/lib/src/resolve.d.ts +2 -4
  31. package/lib/src/resolve.js +2 -2
  32. package/lib/src/serving-wire.d.ts +37 -0
  33. package/lib/src/serving-wire.js +211 -0
  34. package/lib/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +110 -17
  36. package/src/_metadata-contract.ts +17 -7
  37. package/src/_metadata-generator.ts +160 -0
  38. package/src/_ranking.ts +40 -11
  39. package/src/_selection.ts +4 -19
  40. package/src/classify.ts +2 -18
  41. package/src/generated/model-reasoning.json +147 -0
  42. package/src/invoke.ts +16 -0
  43. package/src/metadata-cache.ts +322 -0
  44. package/src/metadata.ts +334 -43
  45. package/src/model-catalog.ts +8 -25
  46. package/src/model-client.ts +67 -44
  47. package/src/reasoning-translation.ts +495 -0
  48. package/src/resolve.ts +10 -11
  49. package/src/serving-wire.ts +231 -0
  50. package/lib/src/python.d.ts +0 -2
  51. package/lib/src/python.js +0 -3
  52. package/src/python.ts +0 -2
@@ -8,30 +8,28 @@
8
8
  * @module
9
9
  */
10
10
  import {
11
- DatabricksClient,
11
+ client as authClient,
12
+ profile as authProfile,
12
13
  type AuthClient,
13
14
  type DatabricksAuthDependencies,
14
- type DatabricksAuthOptions,
15
- type DatabricksProfileSummary,
16
15
  } from "@dbx-tools/auth";
16
+ import type { DatabricksProfileSummary } from "@dbx-tools/shared-auth";
17
17
  import * as log from "@dbx-tools/shared-core/log";
18
18
  import * as object from "@dbx-tools/shared-core/object";
19
19
  import {
20
20
  ModelClass,
21
+ type ModelMetadata,
21
22
  type ModelQuery,
22
23
  type RankedModel,
24
+ type ResolvedModelSelection,
25
+ type ResolveModelInput,
23
26
  type ServingEndpointSummary,
24
27
  } from "@dbx-tools/shared-model/contracts";
25
28
 
26
29
  import { normalizeEndpoints } from "./_ranking.ts";
27
- import {
28
- lookupModels,
29
- type ResolveModelInput,
30
- type ResolvedModelSelection,
31
- resolveModel,
32
- } from "./_selection.ts";
30
+ import { lookupModels, resolveModel } from "./_selection.ts";
33
31
  import { chatCompletionsUrl, invocationsUrl, responsesUpstreamUrl } from "./invoke.ts";
34
- import { type ModelMetadata, modelMetadataFor } from "./metadata.ts";
32
+ import { modelMetadataFor } from "./metadata.ts";
35
33
  import { modelServingApi } from "./policy.ts";
36
34
 
37
35
  const logger = log.logger("model/client");
@@ -39,17 +37,23 @@ const logger = log.logger("model/client");
39
37
  /** Default lifetime for one live endpoint catalogue. */
40
38
  export const DEFAULT_MODEL_CLIENT_CACHE_TTL_MS = 5 * 60 * 1000;
41
39
 
40
+ /** Authentication options exposed by the portable model client. */
41
+ export interface ModelAuthOptions {
42
+ /** Databricks profile name. */
43
+ profile?: string;
44
+ }
45
+
42
46
  /** Construction options for {@link createModelClient}. */
43
47
  export interface ModelClientOptions {
44
48
  /** Databricks authentication and profile selection options. */
45
- auth?: DatabricksAuthOptions;
49
+ auth?: ModelAuthOptions;
46
50
  /** In-memory endpoint catalogue TTL in milliseconds. */
47
51
  cacheTtlMs?: number;
48
52
  }
49
53
 
50
54
  /** Secret-free runtime identity and cache configuration. */
51
55
  export interface ModelClientStatus {
52
- readonly profile: string;
56
+ readonly profile?: string;
53
57
  readonly host: string;
54
58
  readonly principal: string;
55
59
  readonly workspaceId?: string;
@@ -86,13 +90,9 @@ export interface ModelRoute {
86
90
  export interface ModelClient {
87
91
  listModels(refresh?: boolean): Promise<ServingEndpointSummary[]>;
88
92
  searchModels(query?: ModelQuery, refresh?: boolean): Promise<RankedModel[]>;
89
- resolveModel(input?: ResolveModelInput, refresh?: boolean): Promise<ResolvedModelSelection>;
90
93
  route(input?: ModelRouteInput): Promise<ModelRoute>;
91
- authenticate(login?: boolean): Promise<Record<string, string>>;
92
94
  metadata(model: string | ServingEndpointSummary): ModelMetadata;
93
95
  status(): ModelClientStatus;
94
- profile(name?: string): DatabricksProfileSummary;
95
- listProfiles(refresh?: boolean): DatabricksProfileSummary[];
96
96
  }
97
97
 
98
98
  interface DatabricksModelClient {
@@ -103,6 +103,42 @@ interface DatabricksModelClient {
103
103
  request(path: string): Promise<unknown>;
104
104
  }
105
105
 
106
+ class AuthenticatedModelClient implements DatabricksModelClient {
107
+ constructor(
108
+ readonly auth: AuthClient,
109
+ private readonly fetcher: typeof globalThis.fetch,
110
+ ) {}
111
+
112
+ host(): string {
113
+ return this.auth.host;
114
+ }
115
+
116
+ principal(): string {
117
+ return this.auth.principal;
118
+ }
119
+
120
+ workspaceId(): string | undefined {
121
+ return this.auth.workspaceId;
122
+ }
123
+
124
+ async request(path: string): Promise<unknown> {
125
+ const url = new URL(path, `${this.host().replace(/\/$/, "")}/`).toString();
126
+ let headers = await this.auth.headers();
127
+ let response = await this.fetcher(url, { headers });
128
+ if (response.status === 401) {
129
+ headers = await this.auth.headers({ refresh: true });
130
+ response = await this.fetcher(url, { headers });
131
+ }
132
+ const text = await response.text();
133
+ if (!response.ok) {
134
+ throw new Error(
135
+ `Databricks Model Serving API ${path} returned HTTP ${response.status}: ${text}`,
136
+ );
137
+ }
138
+ return text ? JSON.parse(text) : undefined;
139
+ }
140
+ }
141
+
106
142
  interface CatalogueEntry {
107
143
  readonly promise: Promise<readonly ServingEndpointSummary[]>;
108
144
  expiresAt: number;
@@ -127,18 +163,6 @@ class DefaultModelClient implements ModelClient {
127
163
  }));
128
164
  }
129
165
 
130
- async resolveModel(
131
- input: ResolveModelInput = {},
132
- refresh = false,
133
- ): Promise<ResolvedModelSelection> {
134
- const catalogue = await this.catalogue(refresh);
135
- const resolved = resolveModel(catalogue, input);
136
- if (isUnmatchedExplicit(catalogue, input, resolved)) {
137
- return resolveModel(await this.catalogue(true), input);
138
- }
139
- return resolved;
140
- }
141
-
142
166
  async route(input: ModelRouteInput = {}): Promise<ModelRoute> {
143
167
  const { refresh = false, protocol: requestedProtocol, login, ...selection } = input;
144
168
  const effectiveSelection =
@@ -156,7 +180,7 @@ class DefaultModelClient implements ModelClient {
156
180
  const protocol = requestedProtocol ?? inferProtocol(endpoint, resolved.modelId);
157
181
  const host = this.client.host();
158
182
  const url = routeUrl(host, resolved.modelId, protocol);
159
- const headers = await this.client.auth.requestHeadersForUrl(url, login);
183
+ const headers = await this.client.auth.headers({ login });
160
184
  return {
161
185
  modelId: resolved.modelId,
162
186
  ...(endpoint ? { endpoint: cloneEndpoint(endpoint) } : {}),
@@ -170,17 +194,13 @@ class DefaultModelClient implements ModelClient {
170
194
  };
171
195
  }
172
196
 
173
- authenticate(login?: boolean): Promise<Record<string, string>> {
174
- return this.client.auth.authenticate(login);
175
- }
176
-
177
197
  metadata(model: string | ServingEndpointSummary): ModelMetadata {
178
198
  return modelMetadataFor(model);
179
199
  }
180
200
 
181
201
  status(): ModelClientStatus {
182
202
  return {
183
- profile: this.client.auth.status().profile,
203
+ ...(this.client.auth.profile ? { profile: this.client.auth.profile } : {}),
184
204
  host: this.client.host(),
185
205
  principal: this.client.principal(),
186
206
  ...(this.client.workspaceId() ? { workspaceId: this.client.workspaceId() } : {}),
@@ -188,14 +208,6 @@ class DefaultModelClient implements ModelClient {
188
208
  };
189
209
  }
190
210
 
191
- profile(name?: string): DatabricksProfileSummary {
192
- return this.client.auth.profile(name);
193
- }
194
-
195
- listProfiles(refresh = false): DatabricksProfileSummary[] {
196
- return this.client.auth.listProfiles(refresh);
197
- }
198
-
199
211
  private catalogue(refresh: boolean): Promise<readonly ServingEndpointSummary[]> {
200
212
  const key = catalogueKey(this.client);
201
213
  if (refresh) catalogueCache.delete(key);
@@ -234,7 +246,15 @@ class DefaultModelClient implements ModelClient {
234
246
  /** Create an authentication-aware Databricks model client. */
235
247
  export async function createModelClient(options: ModelClientOptions = {}): Promise<ModelClient> {
236
248
  const cacheTtlMs = validateCacheTtl(options.cacheTtlMs);
237
- return new DefaultModelClient(await DatabricksClient.create(options.auth), cacheTtlMs);
249
+ return new DefaultModelClient(
250
+ new AuthenticatedModelClient(await authClient.createAuthClient(options.auth), globalThis.fetch),
251
+ cacheTtlMs,
252
+ );
253
+ }
254
+
255
+ /** List configured Databricks profiles outside the model client facade. */
256
+ export function listProfiles(refresh = false): DatabricksProfileSummary[] {
257
+ return authProfile.listProfiles({ refresh });
238
258
  }
239
259
 
240
260
  /** @internal Construct a model client around a test or host-owned Databricks client. */
@@ -251,7 +271,10 @@ export async function createModelClientWithDependencies(
251
271
  dependencies: DatabricksAuthDependencies,
252
272
  ): Promise<ModelClient> {
253
273
  return new DefaultModelClient(
254
- await DatabricksClient.create(options.auth, dependencies),
274
+ new AuthenticatedModelClient(
275
+ await authClient.createAuthClient(options.auth, dependencies),
276
+ dependencies.fetch ?? globalThis.fetch,
277
+ ),
255
278
  validateCacheTtl(options.cacheTtlMs),
256
279
  );
257
280
  }
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Generic reasoning-budget translation for Model Serving and gateway callers.
3
+ *
4
+ * Provider wires disagree on effort labels (`xhigh`, `ultra`, `med`, `none`, …).
5
+ * This module owns one industry-shaped ladder and two parsers:
6
+ *
7
+ * - {@link parseReasoning}: fuzzy-match one label onto {@link ReasoningLevel}
8
+ * - {@link parseReasoningLevels}: unwrap nested / double-encoded error bodies
9
+ * - {@link remapReasoning} / {@link adaptRequestReasoning}: map a request onto a
10
+ * supported ladder and rewrite `reasoning_effort` / `reasoning.effort`
11
+ *
12
+ * Documented model ladders come from the committed HTML snapshot refreshed by
13
+ * `bun run --filter '@dbx-tools/model' metadata` (same cache path as
14
+ * capabilities / rate limits). Error-body parsing is never cached; callers
15
+ * persist learned ladders via the metadata module.
16
+ *
17
+ * Error bodies arrive as complete HTTP JSON whether the request asked for
18
+ * streaming or not (Databricks rejects unsupported efforts with 400 before SSE
19
+ * starts), so {@link parseReasoningLevels} applies to both paths.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { json } from "@dbx-tools/shared-core";
25
+ import { isRecord } from "@dbx-tools/shared-core/object";
26
+ import * as stringUtils from "@dbx-tools/shared-core/string-utils";
27
+ import Fuse from "fuse.js";
28
+
29
+ import { modelFamily, modelSearchQuery } from "./policy.ts";
30
+
31
+ /** Industry-generic reasoning budget levels, lowest to highest. */
32
+ export type ReasoningLevel = "low" | "medium" | "high" | "extra-high" | "max";
33
+
34
+ /** Runtime values for {@link ReasoningLevel}. */
35
+ export const ReasoningLevel = {
36
+ Low: "low",
37
+ Medium: "medium",
38
+ High: "high",
39
+ ExtraHigh: "extra-high",
40
+ Max: "max",
41
+ } as const satisfies Record<string, ReasoningLevel>;
42
+
43
+ /** Canonical ladder order used when sorting or ranking remaps. */
44
+ export const REASONING_LEVELS: readonly ReasoningLevel[] = [
45
+ ReasoningLevel.Low,
46
+ ReasoningLevel.Medium,
47
+ ReasoningLevel.High,
48
+ ReasoningLevel.ExtraHigh,
49
+ ReasoningLevel.Max,
50
+ ];
51
+
52
+ /** Model identity → accepted {@link ReasoningLevel} ladder. */
53
+ export type ReasoningModelCatalogue = Readonly<Record<string, readonly ReasoningLevel[]>>;
54
+
55
+ const STANDARD: readonly ReasoningLevel[] = [
56
+ ReasoningLevel.Low,
57
+ ReasoningLevel.Medium,
58
+ ReasoningLevel.High,
59
+ ];
60
+
61
+ const WITH_EXTRA_HIGH: readonly ReasoningLevel[] = [
62
+ ReasoningLevel.Low,
63
+ ReasoningLevel.Medium,
64
+ ReasoningLevel.High,
65
+ ReasoningLevel.ExtraHigh,
66
+ ];
67
+
68
+ const WITH_MAX: readonly ReasoningLevel[] = [
69
+ ReasoningLevel.Low,
70
+ ReasoningLevel.Medium,
71
+ ReasoningLevel.High,
72
+ ReasoningLevel.ExtraHigh,
73
+ ReasoningLevel.Max,
74
+ ];
75
+
76
+ const GPT_5_5_PRO: readonly ReasoningLevel[] = [
77
+ ReasoningLevel.Medium,
78
+ ReasoningLevel.High,
79
+ ReasoningLevel.ExtraHigh,
80
+ ];
81
+
82
+ /**
83
+ * Alias table for exact / near-exact labels. Keys are {@link normalizeReasoningToken}
84
+ * results. Provider wires (`xhigh`) and plain English (`ultra`, `med`) both land
85
+ * on the generic ladder.
86
+ */
87
+ const ALIASES: Readonly<Record<string, ReasoningLevel>> = {
88
+ low: ReasoningLevel.Low,
89
+ l: ReasoningLevel.Low,
90
+ none: ReasoningLevel.Low,
91
+ off: ReasoningLevel.Low,
92
+ disable: ReasoningLevel.Low,
93
+ disabled: ReasoningLevel.Low,
94
+ zero: ReasoningLevel.Low,
95
+ minimal: ReasoningLevel.Low,
96
+ min: ReasoningLevel.Low,
97
+ tiny: ReasoningLevel.Low,
98
+ light: ReasoningLevel.Low,
99
+ medium: ReasoningLevel.Medium,
100
+ med: ReasoningLevel.Medium,
101
+ mid: ReasoningLevel.Medium,
102
+ moderate: ReasoningLevel.Medium,
103
+ default: ReasoningLevel.Medium,
104
+ normal: ReasoningLevel.Medium,
105
+ balanced: ReasoningLevel.Medium,
106
+ high: ReasoningLevel.High,
107
+ hi: ReasoningLevel.High,
108
+ heavy: ReasoningLevel.High,
109
+ "extra-high": ReasoningLevel.ExtraHigh,
110
+ extrahigh: ReasoningLevel.ExtraHigh,
111
+ xhigh: ReasoningLevel.ExtraHigh,
112
+ xh: ReasoningLevel.ExtraHigh,
113
+ ultra: ReasoningLevel.ExtraHigh,
114
+ "ultra-high": ReasoningLevel.ExtraHigh,
115
+ ultrahigh: ReasoningLevel.ExtraHigh,
116
+ max: ReasoningLevel.Max,
117
+ maximum: ReasoningLevel.Max,
118
+ highest: ReasoningLevel.Max,
119
+ full: ReasoningLevel.Max,
120
+ };
121
+
122
+ const SUPPORTED_VALUES_PATTERN =
123
+ /supported\s+values?\s+are\s*:?\s*(.+?)(?:\.?\s*(?:param|type|code)\b|$)/is;
124
+ const TOKEN_PATTERN = /'([^']+)'|"([^"]+)"|`([^`]+)`|\b([a-z][a-z0-9_-]*)\b/gi;
125
+ const MAX_JSON_UNWRAP_DEPTH = 8;
126
+
127
+ interface AliasDocument {
128
+ readonly token: string;
129
+ readonly level: ReasoningLevel;
130
+ }
131
+
132
+ const aliasDocuments: readonly AliasDocument[] = Object.entries(ALIASES).map(
133
+ ([token, level]) => ({ token, level }),
134
+ );
135
+
136
+ const aliasFuse = new Fuse(aliasDocuments, {
137
+ keys: ["token"],
138
+ includeScore: true,
139
+ threshold: 0.35,
140
+ ignoreLocation: true,
141
+ });
142
+
143
+ /**
144
+ * Fuzzy-match one reasoning label onto {@link ReasoningLevel}.
145
+ *
146
+ * Returns `undefined` when the input is empty or cannot be mapped confidently.
147
+ */
148
+ export function parseReasoning(value: unknown): ReasoningLevel | undefined {
149
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
150
+ return undefined;
151
+ }
152
+ const normalized = normalizeReasoningToken(String(value));
153
+ if (!normalized) return undefined;
154
+ const exact = ALIASES[normalized] ?? ALIASES[normalized.replace(/-/g, "")];
155
+ if (exact) return exact;
156
+ const [match] = aliasFuse.search(normalized);
157
+ if (!match || (match.score ?? 1) > 0.35) return undefined;
158
+ return match.item.level;
159
+ }
160
+
161
+ /**
162
+ * Extract supported reasoning levels from an upstream error body.
163
+ *
164
+ * Accepts raw response text, already-parsed JSON, or Databricks envelopes that
165
+ * nest JSON strings several times. Returns `[]` when no supported-values list
166
+ * can be recovered. Results are not cached.
167
+ */
168
+ export function parseReasoningLevels(body: unknown): ReasoningLevel[] {
169
+ const levels: ReasoningLevel[] = [];
170
+ const seen = new Set<ReasoningLevel>();
171
+ for (const text of collectTextLeaves(body)) {
172
+ for (const token of extractSupportedTokens(text)) {
173
+ const level = parseReasoning(token);
174
+ if (!level || seen.has(level)) continue;
175
+ seen.add(level);
176
+ levels.push(level);
177
+ }
178
+ }
179
+ return levels;
180
+ }
181
+
182
+ /**
183
+ * Look up documented levels for a model identity inside a catalogue.
184
+ *
185
+ * Matches exact catalogue keys and suffix identities.
186
+ */
187
+ export function documentedReasoningLevels(
188
+ model: string,
189
+ catalogue: ReasoningModelCatalogue,
190
+ ): ReasoningLevel[] | undefined {
191
+ const keys = catalogueKeysFor(model);
192
+ for (const key of keys) {
193
+ const levels = catalogue[key];
194
+ if (levels?.length) return [...levels];
195
+ }
196
+ for (const [key, levels] of Object.entries(catalogue)) {
197
+ if (keys.some((candidate) => key.endsWith(candidate) || candidate.endsWith(key))) {
198
+ return [...levels];
199
+ }
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ /**
205
+ * Hard-coded family defaults used when documentation has no entry.
206
+ *
207
+ * Unknown families return the standard low/medium/high ladder.
208
+ */
209
+ export function defaultReasoningLevels(model: string): ReasoningLevel[] {
210
+ const family = modelFamily(model)?.toLowerCase();
211
+ const normalized = model.toLowerCase();
212
+ if (family === "grok" || normalized.includes("grok")) return [...WITH_EXTRA_HIGH];
213
+ if (family === "gemini" || normalized.includes("gemini")) return [...STANDARD];
214
+ if (family === "claude" || normalized.includes("claude")) return [...WITH_MAX];
215
+ if (family === "glm" || normalized.includes("glm")) {
216
+ return [ReasoningLevel.High, ReasoningLevel.Max];
217
+ }
218
+ if (
219
+ family === "deepseek" ||
220
+ normalized.includes("deepseek") ||
221
+ family === "kimi" ||
222
+ normalized.includes("kimi")
223
+ ) {
224
+ return [ReasoningLevel.Low, ReasoningLevel.High, ReasoningLevel.Max];
225
+ }
226
+ if (family === "inkling" || normalized.includes("inkling")) return [...WITH_MAX];
227
+ if (family === "gpt" || normalized.includes("gpt") || normalized.includes("codex")) {
228
+ if (normalized.includes("oss")) return [...STANDARD];
229
+ if (/(?:^|[-_.])5[-_.]?5(?:[-_.]|$)/.test(normalized) && normalized.includes("pro")) {
230
+ return [...GPT_5_5_PRO];
231
+ }
232
+ if (
233
+ /(?:^|[-_.])5[-_.]?6(?:[-_.]|$)/.test(normalized) ||
234
+ /(?:^|[-_.])6(?:[-_.]|$)/.test(normalized)
235
+ ) {
236
+ return [...WITH_MAX];
237
+ }
238
+ return [...STANDARD];
239
+ }
240
+ if (/(?:^|[-_./])o(?:1|3|4)(?:[-_./]|$)/.test(model) || normalized.includes("codex")) {
241
+ return [...STANDARD];
242
+ }
243
+ return [...STANDARD];
244
+ }
245
+
246
+ /**
247
+ * Prefer a documentation catalogue when provided, otherwise
248
+ * {@link defaultReasoningLevels}.
249
+ */
250
+ export function reasoningLevelsFor(
251
+ model: string,
252
+ catalogue?: ReasoningModelCatalogue,
253
+ ): ReasoningLevel[] {
254
+ if (catalogue) {
255
+ const documented = documentedReasoningLevels(model, catalogue);
256
+ if (documented?.length) return documented;
257
+ }
258
+ return defaultReasoningLevels(model);
259
+ }
260
+
261
+ /**
262
+ * Map a generic level onto the most common provider wire token.
263
+ *
264
+ * `extra-high` becomes `xhigh` because that is what Databricks / OpenAI error
265
+ * messages advertise today.
266
+ */
267
+ export function formatReasoning(level: ReasoningLevel): string {
268
+ return level === ReasoningLevel.ExtraHigh ? "xhigh" : level;
269
+ }
270
+
271
+ /** Wire tokens that mean "do not reason" rather than "use a low budget". */
272
+ const DISABLED_REASONING_TOKENS = new Set(["none", "off", "disable", "disabled"]);
273
+
274
+ /**
275
+ * Remap a requested effort onto the nearest supported {@link ReasoningLevel}.
276
+ *
277
+ * `none` / `off` (and disable aliases) are not a low budget: when the model
278
+ * requires reasoning, they snap to the supported level closest to
279
+ * {@link ReasoningLevel.Medium}. Other requests prefer the highest allowed
280
+ * level at or below the request, otherwise the lowest allowed level.
281
+ *
282
+ * Returns `undefined` when the request cannot be parsed or `levels` is empty.
283
+ */
284
+ export function remapReasoning(
285
+ requested: unknown,
286
+ levels: readonly ReasoningLevel[],
287
+ ): ReasoningLevel | undefined {
288
+ if (levels.length === 0) return undefined;
289
+ if (isDisabledReasoningToken(requested)) {
290
+ return nearestReasoning(ReasoningLevel.Medium, levels);
291
+ }
292
+ const parsed = parseReasoning(requested);
293
+ if (!parsed) return undefined;
294
+ if (levels.includes(parsed)) return parsed;
295
+ const requestedIndex = REASONING_LEVELS.indexOf(parsed);
296
+ let best: ReasoningLevel | undefined;
297
+ let bestIndex = Number.NEGATIVE_INFINITY;
298
+ for (const level of levels) {
299
+ const index = REASONING_LEVELS.indexOf(level);
300
+ if (index <= requestedIndex && index > bestIndex) {
301
+ best = level;
302
+ bestIndex = index;
303
+ }
304
+ }
305
+ return best ?? levels[0];
306
+ }
307
+
308
+ function isDisabledReasoningToken(value: unknown): boolean {
309
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
310
+ return false;
311
+ }
312
+ return DISABLED_REASONING_TOKENS.has(normalizeReasoningToken(String(value)));
313
+ }
314
+
315
+ /** Closest supported level to `target`; ties prefer the higher budget. */
316
+ function nearestReasoning(
317
+ target: ReasoningLevel,
318
+ levels: readonly ReasoningLevel[],
319
+ ): ReasoningLevel | undefined {
320
+ if (levels.length === 0) return undefined;
321
+ if (levels.includes(target)) return target;
322
+ const targetIndex = REASONING_LEVELS.indexOf(target);
323
+ let best: ReasoningLevel | undefined;
324
+ let bestDistance = Number.POSITIVE_INFINITY;
325
+ for (const level of levels) {
326
+ const distance = Math.abs(REASONING_LEVELS.indexOf(level) - targetIndex);
327
+ if (
328
+ distance < bestDistance ||
329
+ (distance === bestDistance &&
330
+ REASONING_LEVELS.indexOf(level) > REASONING_LEVELS.indexOf(best ?? level))
331
+ ) {
332
+ best = level;
333
+ bestDistance = distance;
334
+ }
335
+ }
336
+ return best;
337
+ }
338
+
339
+ /** Result of adapting reasoning fields on a gateway request body. */
340
+ export interface AdaptedRequestReasoning {
341
+ /** Body with remapped wire tokens (same reference when unchanged). */
342
+ readonly body: Readonly<Record<string, unknown>>;
343
+ /** True when any reasoning field was rewritten. */
344
+ readonly changed: boolean;
345
+ /** Wire token written when a reasoning field was present and remapped. */
346
+ readonly wireEffort?: string;
347
+ }
348
+
349
+ /**
350
+ * Rewrite `reasoning_effort` / `reasoning.effort` onto a supported ladder.
351
+ *
352
+ * Model-agnostic: callers supply the allowed levels (learned, documented, or
353
+ * defaults). Bodies without reasoning fields are returned unchanged.
354
+ */
355
+ export function adaptRequestReasoning(
356
+ body: Readonly<Record<string, unknown>>,
357
+ levels: readonly ReasoningLevel[],
358
+ ): AdaptedRequestReasoning {
359
+ if (levels.length === 0) return { body, changed: false };
360
+
361
+ const topLevel = body.reasoning_effort;
362
+ const reasoning = isRecord(body.reasoning) ? body.reasoning : undefined;
363
+ const nested = reasoning?.effort;
364
+ const requested = topLevel ?? nested;
365
+ if (requested === undefined) return { body, changed: false };
366
+
367
+ const remapped = remapReasoning(requested, levels);
368
+ if (!remapped) return { body, changed: false };
369
+ const wire = formatReasoning(remapped);
370
+
371
+ let changed = false;
372
+ let next: Record<string, unknown> = { ...body };
373
+
374
+ if (typeof topLevel === "string" || typeof topLevel === "number") {
375
+ if (String(topLevel) !== wire) {
376
+ next = { ...next, reasoning_effort: wire };
377
+ changed = true;
378
+ }
379
+ }
380
+ if (reasoning && (typeof nested === "string" || typeof nested === "number")) {
381
+ if (String(nested) !== wire) {
382
+ next = { ...next, reasoning: { ...reasoning, effort: wire } };
383
+ changed = true;
384
+ }
385
+ }
386
+
387
+ return changed ? { body: next, changed: true, wireEffort: wire } : { body, changed: false, wireEffort: wire };
388
+ }
389
+
390
+ /** Normalize labels for alias lookup (`Extra High` → `extra-high`). */
391
+ export function normalizeReasoningToken(value: string): string {
392
+ return (
393
+ stringUtils
394
+ .trimToNull(value)
395
+ ?.toLowerCase()
396
+ .replace(/[_\s]+/g, "-")
397
+ .replace(/[^a-z0-9-]/g, "")
398
+ .replace(/-+/g, "-")
399
+ .replace(/^-|-$/g, "") ?? ""
400
+ );
401
+ }
402
+
403
+ /** Deduplicate levels while preserving first-seen order. */
404
+ export function uniqueReasoningLevels(levels: readonly ReasoningLevel[]): ReasoningLevel[] {
405
+ const seen = new Set<ReasoningLevel>();
406
+ const ordered: ReasoningLevel[] = [];
407
+ for (const level of levels) {
408
+ if (seen.has(level)) continue;
409
+ seen.add(level);
410
+ ordered.push(level);
411
+ }
412
+ return ordered;
413
+ }
414
+
415
+ function extractSupportedTokens(text: string): string[] {
416
+ const match = SUPPORTED_VALUES_PATTERN.exec(text);
417
+ if (!match?.[1]) return [];
418
+ return tokensFromFragment(match[1]);
419
+ }
420
+
421
+ function catalogueKeysFor(model: string): string[] {
422
+ const keys = new Set<string>();
423
+ const direct = modelCatalogueKey(model);
424
+ if (direct) keys.add(direct);
425
+ const stripped = model
426
+ .toLowerCase()
427
+ .replace(/^databricks\//, "")
428
+ .replace(/^system\.ai\./, "")
429
+ .replace(/^databricks-/, "");
430
+ const strippedKey = modelCatalogueKey(stripped);
431
+ if (strippedKey) keys.add(strippedKey);
432
+ const withPrefix = modelCatalogueKey(`databricks-${stripped}`);
433
+ if (withPrefix) keys.add(withPrefix);
434
+ return [...keys];
435
+ }
436
+
437
+ function modelCatalogueKey(name: string): string | undefined {
438
+ const trimmed = stringUtils.trimToNull(name);
439
+ if (!trimmed) return undefined;
440
+ return (
441
+ modelSearchQuery(trimmed)?.replaceAll(" ", "-") ??
442
+ trimmed
443
+ .toLowerCase()
444
+ .replace(/^databricks-/, "")
445
+ .replace(/[^a-z0-9._-]+/g, "-")
446
+ );
447
+ }
448
+
449
+ function tokensFromFragment(fragment: string): string[] {
450
+ const tokens: string[] = [];
451
+ for (const part of fragment.matchAll(TOKEN_PATTERN)) {
452
+ const token = part[1] ?? part[2] ?? part[3] ?? part[4];
453
+ if (!token || token === "and" || token === "or" || token === "of") continue;
454
+ tokens.push(token.replace(/^["'`]+|["'`]+$/g, ""));
455
+ }
456
+ return tokens;
457
+ }
458
+
459
+ /**
460
+ * Walk a value tree, unwrapping nested JSON strings, and collect text leaves
461
+ * that may contain a supported-values sentence.
462
+ */
463
+ function collectTextLeaves(value: unknown, depth = 0): string[] {
464
+ if (value == null || depth > MAX_JSON_UNWRAP_DEPTH) return [];
465
+ if (typeof value === "string") {
466
+ const trimmed = value.trim();
467
+ if (!trimmed) return [];
468
+ const parsed = tryUnwrapJson(trimmed);
469
+ if (parsed !== undefined && parsed !== trimmed) {
470
+ return collectTextLeaves(parsed, depth + 1);
471
+ }
472
+ return [trimmed];
473
+ }
474
+ if (typeof value === "number" || typeof value === "boolean") return [String(value)];
475
+ if (Array.isArray(value)) {
476
+ return value.flatMap((entry) => collectTextLeaves(entry, depth + 1));
477
+ }
478
+ if (!isRecord(value)) return [];
479
+ return Object.values(value).flatMap((entry) => collectTextLeaves(entry, depth + 1));
480
+ }
481
+
482
+ function tryUnwrapJson(text: string): unknown {
483
+ const asRecord = json.parseRecord(text);
484
+ if (asRecord) return asRecord;
485
+ const parsed = json.parse(text);
486
+ if (parsed !== undefined) return parsed;
487
+ if (text.includes('\\"') || text.includes("\\n")) {
488
+ const unescaped = text.replace(/\\"/g, '"').replace(/\\n/g, "\n").replace(/\\\\/g, "\\");
489
+ if (unescaped !== text) {
490
+ const nested = json.parse(unescaped);
491
+ if (nested !== undefined) return nested;
492
+ }
493
+ }
494
+ return undefined;
495
+ }