@dbx-tools/model 0.9.166 → 0.9.173

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.
@@ -1,11 +1,13 @@
1
1
  {
2
- "generatedAt": 1791144427,
2
+ "generatedAt": 1793283170,
3
3
  "models": [
4
4
  "Anthropic Claude 3.7 Sonnet",
5
5
  "Anthropic Claude Sonnet 4",
6
6
  "DBRX",
7
7
  "DBRX Instruct",
8
+ "DeepSeek V4 Flash (0731)",
8
9
  "DeepSeek V4 Pro (0813)",
10
+ "DeepSeek V4.1 Flash",
9
11
  "Gemini 2.5 Flash",
10
12
  "Gemini 2.5 Pro",
11
13
  "Gemini 3 Pro",
@@ -23,6 +23,12 @@ export const MODEL_RATE_LIMITS_URL =
23
23
  export const QUERY_REASON_MODELS_URL =
24
24
  "https://docs.databricks.com/aws/en/machine-learning/model-serving/query-reason-models";
25
25
 
26
+ /** models.dev provider catalogue URL. */
27
+ export const MODELS_DEV_API_URL = "https://models.dev/api.json";
28
+
29
+ /** modelparams.dev normalized model-parameter catalogue URL. */
30
+ export const MODELPARAMS_DEV_MODELS_URL = "https://modelparams.dev/api/v1/models.json";
31
+
26
32
  /** Build-generated retired-model snapshot. */
27
33
  export interface RetiredModelsSnapshot {
28
34
  readonly generatedAt: number;
@@ -64,3 +70,30 @@ export interface ReasoningModelsSnapshot {
64
70
  readonly generatedAt: number;
65
71
  readonly catalogue: ReasoningModelCatalogue;
66
72
  }
73
+
74
+ /** Portable capability sets normalized from models.dev. */
75
+ export interface ModelsDevCapabilityCatalogue {
76
+ readonly imageInput: readonly string[];
77
+ readonly reasoning: readonly string[];
78
+ readonly tools: readonly string[];
79
+ readonly structuredOutput: readonly string[];
80
+ readonly temperatureSupported: readonly string[];
81
+ readonly temperatureUnsupported: readonly string[];
82
+ }
83
+
84
+ /** Build-generated models.dev capability and reasoning snapshot. */
85
+ export interface ModelsDevSnapshot {
86
+ readonly generatedAt: number;
87
+ readonly capabilities: ModelsDevCapabilityCatalogue;
88
+ }
89
+
90
+ /** Reasoning support and discrete effort levels normalized from modelparams.dev. */
91
+ export interface ModelParamsCatalogue extends ReasoningModelCatalogue {
92
+ readonly reasoning: readonly string[];
93
+ }
94
+
95
+ /** Build-generated modelparams.dev reasoning snapshot. */
96
+ export interface ModelParamsSnapshot {
97
+ readonly generatedAt: number;
98
+ readonly catalogue: ModelParamsCatalogue;
99
+ }
package/src/metadata.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Build-generated Databricks model retirement, capability, rate-limit, and
3
- * reasoning-effort metadata, plus a daily write-through cache for refreshed
4
- * docs and error-learned reasoning ladders.
2
+ * Build-generated Databricks and public model capability metadata, plus a
3
+ * daily write-through cache for refreshed sources and error-learned reasoning
4
+ * ladders.
5
5
  *
6
6
  * Committed snapshots seed every process. {@link refreshModelMetadata} pulls
7
- * documentation through the shared memory-first {@link createMetadataCache} and
8
- * rebuilds the in-memory index. Error-learned efforts use the same store via
9
- * {@link rememberReasoningLevels}. Known-good values are written behind to the
10
- * shared model project cache.
7
+ * Databricks documentation, models.dev, and modelparams.dev through the shared
8
+ * memory-first {@link createMetadataCache} and rebuilds the in-memory index.
9
+ * Error-learned efforts use the same store via {@link rememberReasoningLevels}.
10
+ * Known-good values are written behind to the shared model project cache.
11
11
  *
12
12
  * @module
13
13
  */
@@ -20,12 +20,16 @@ import type {
20
20
  } from "@dbx-tools/shared-model";
21
21
 
22
22
  import {
23
+ parseModelParams,
23
24
  parseModelCapabilities,
24
25
  parseModelRateLimits,
26
+ parseModelsDev,
25
27
  parseReasoningModels,
26
28
  parseRetiredModels,
27
29
  } from "./_metadata-generator.ts";
28
30
  import capabilitiesSnapshotJson from "./generated/model-capabilities.json" with { type: "json" };
31
+ import modelParamsSnapshotJson from "./generated/modelparams-reasoning.json" with { type: "json" };
32
+ import modelsDevSnapshotJson from "./generated/models-dev-capabilities.json" with { type: "json" };
29
33
  import rateLimitsSnapshotJson from "./generated/model-rate-limits.json" with { type: "json" };
30
34
  import reasoningSnapshotJson from "./generated/model-reasoning.json" with { type: "json" };
31
35
  import {
@@ -35,17 +39,29 @@ import {
35
39
  type MetadataCache,
36
40
  } from "./metadata-cache.ts";
37
41
  import {
42
+ MODELPARAMS_DEV_MODELS_URL,
43
+ MODELS_DEV_API_URL,
38
44
  MODEL_RATE_LIMITS_URL,
39
45
  OPENAI_RESPONSES_MODELS_URL,
40
46
  QUERY_REASON_MODELS_URL,
41
47
  RETIRED_MODELS_URL,
42
48
  WEB_SEARCH_MODELS_URL,
49
+ type ModelParamsCatalogue,
50
+ type ModelParamsSnapshot,
43
51
  type ModelCapabilitiesSnapshot,
44
52
  type ModelCapabilityCatalogue,
45
53
  type ModelRateLimitsSnapshot,
54
+ type ModelsDevCapabilityCatalogue,
55
+ type ModelsDevSnapshot,
46
56
  type ReasoningModelsSnapshot,
47
57
  } from "./metadata-contract.ts";
48
- import { inheritsNativeWebSearch, isFoundationModelIdentity, modelSearchQuery } from "./policy.ts";
58
+ import {
59
+ inheritsNativeWebSearch,
60
+ isFoundationModelIdentity,
61
+ modelReasoningEfforts,
62
+ modelSearchQuery,
63
+ modelSupportsTools,
64
+ } from "./policy.ts";
49
65
  import {
50
66
  defaultReasoningLevels,
51
67
  documentedReasoningLevels,
@@ -63,19 +79,25 @@ import {
63
79
  } from "./retirement.ts";
64
80
 
65
81
  const capabilitiesSnapshot = capabilitiesSnapshotJson as ModelCapabilitiesSnapshot;
82
+ const modelParamsSnapshot = modelParamsSnapshotJson as ModelParamsSnapshot;
83
+ const modelsDevSnapshot = modelsDevSnapshotJson as ModelsDevSnapshot;
66
84
  const rateLimitsSnapshot = rateLimitsSnapshotJson as ModelRateLimitsSnapshot;
67
85
  const reasoningSnapshot = reasoningSnapshotJson as ReasoningModelsSnapshot;
68
86
 
69
87
  interface MetadataIndex {
70
88
  readonly capabilities: {
71
- readonly [Key in keyof ModelCapabilityCatalogue]: ReadonlySet<string>;
89
+ readonly [Key in keyof ModelCapabilities]: ReadonlySet<string>;
72
90
  };
73
91
  readonly rateLimits: ReadonlyMap<string, ModelRateLimits>;
74
92
  readonly reasoning: ReasoningModelCatalogue;
93
+ readonly reasoningDocs: ReasoningModelCatalogue;
94
+ readonly modelParamsReasoning: ReasoningModelCatalogue;
75
95
  readonly learnedReasoning: ReasoningModelCatalogue;
96
+ readonly temperatureUnsupported: ReadonlySet<string>;
76
97
  }
77
98
 
78
99
  type CapabilitySets = ModelCapabilityCatalogue;
100
+ type ModelsDevData = ModelsDevCapabilityCatalogue;
79
101
 
80
102
  const USER_AGENT = "dbx-tools-model-metadata/1";
81
103
 
@@ -136,6 +158,35 @@ const reasoningDocsCache = createMetadataCache<Readonly<Record<string, readonly
136
158
  merge: mergePreferFreshRecord,
137
159
  });
138
160
 
161
+ const modelsDevCache = createMetadataCache<ModelsDevData>({
162
+ key: "models-dev-capabilities",
163
+ fallback: modelsDevSnapshot.capabilities,
164
+ load: async () => {
165
+ const snapshot = parseModelsDev(await loadJson(MODELS_DEV_API_URL), nowSeconds());
166
+ return snapshot.capabilities;
167
+ },
168
+ merge: mergeModelsDevData,
169
+ });
170
+
171
+ const modelParamsCache = createMetadataCache<ModelParamsCatalogue>({
172
+ key: "modelparams-reasoning",
173
+ fallback: modelParamsSnapshot.catalogue,
174
+ load: async () =>
175
+ parseModelParams(await loadJson(MODELPARAMS_DEV_MODELS_URL), nowSeconds()).catalogue,
176
+ merge: (input) => ({
177
+ reasoning: mergePreferFreshList({
178
+ fresh: input.fresh?.reasoning,
179
+ previous: input.previous?.reasoning,
180
+ fallback: input.fallback.reasoning,
181
+ }),
182
+ models: mergePreferFreshRecord({
183
+ fresh: input.fresh?.models,
184
+ previous: input.previous?.models,
185
+ fallback: input.fallback.models,
186
+ }),
187
+ }),
188
+ });
189
+
139
190
  const learnedReasoningCache = createMetadataCache<
140
191
  Readonly<Record<string, readonly ReasoningLevel[]>>
141
192
  >({
@@ -146,12 +197,18 @@ const learnedReasoningCache = createMetadataCache<
146
197
 
147
198
  let liveIndex: MetadataIndex | undefined;
148
199
 
149
- const metadataIndex = memoize<MetadataIndex>(() => liveIndex ?? buildIndexFromCommitted());
200
+ const committedMetadataIndex = memoize<MetadataIndex>(buildIndexFromCommitted);
201
+
202
+ function metadataIndex(): MetadataIndex {
203
+ return liveIndex ?? committedMetadataIndex();
204
+ }
150
205
 
151
206
  function buildIndexFromCommitted(): MetadataIndex {
152
207
  return buildIndex({
153
208
  retiredNames: COMMITTED_RETIRED_MODELS,
154
209
  capabilities: capabilitiesSnapshot.capabilities,
210
+ modelsDev: modelsDevSnapshot.capabilities,
211
+ modelParams: modelParamsSnapshot.catalogue,
155
212
  rateLimits: rateLimitsSnapshot.catalogue.models,
156
213
  reasoning: reasoningSnapshot.catalogue.models,
157
214
  learnedReasoning: {},
@@ -161,21 +218,41 @@ function buildIndexFromCommitted(): MetadataIndex {
161
218
  function buildIndex(input: {
162
219
  readonly retiredNames: readonly string[];
163
220
  readonly capabilities: CapabilitySets;
221
+ readonly modelsDev: ModelsDevData;
222
+ readonly modelParams: ModelParamsCatalogue;
164
223
  readonly rateLimits: Readonly<Record<string, ModelRateLimits>>;
165
224
  readonly reasoning: Readonly<Record<string, readonly string[]>>;
166
225
  readonly learnedReasoning: Readonly<Record<string, readonly ReasoningLevel[]>>;
167
226
  }): MetadataIndex {
168
227
  replaceRetiredModelNames(input.retiredNames);
228
+ const reasoningDocs = freezeReasoningCatalogue(input.reasoning);
229
+ const modelParamsReasoning = freezeReasoningCatalogue(input.modelParams.models);
169
230
  return {
170
231
  capabilities: {
171
232
  responses: new Set(input.capabilities.responses),
172
- imageInput: new Set(input.capabilities.imageInput),
233
+ imageInput: new Set([
234
+ ...input.capabilities.imageInput,
235
+ ...input.modelsDev.imageInput,
236
+ ]),
173
237
  applyPatch: new Set(input.capabilities.applyPatch),
174
238
  webSearch: new Set(input.capabilities.webSearch),
239
+ reasoning: new Set([
240
+ ...input.modelsDev.reasoning,
241
+ ...input.modelParams.reasoning,
242
+ ]),
243
+ tools: new Set(input.modelsDev.tools),
244
+ structuredOutput: new Set(input.modelsDev.structuredOutput),
245
+ temperature: new Set(input.modelsDev.temperatureSupported),
175
246
  },
176
247
  rateLimits: new Map(Object.entries(input.rateLimits)),
177
- reasoning: freezeReasoningCatalogue(input.reasoning),
248
+ reasoning: Object.freeze({
249
+ ...modelParamsReasoning,
250
+ ...reasoningDocs,
251
+ }),
252
+ reasoningDocs,
253
+ modelParamsReasoning,
178
254
  learnedReasoning: freezeLearnedCatalogue(input.learnedReasoning),
255
+ temperatureUnsupported: new Set(input.modelsDev.temperatureUnsupported),
179
256
  };
180
257
  }
181
258
 
@@ -217,18 +294,28 @@ let refreshInflight: Promise<MetadataIndex> | undefined;
217
294
  export async function refreshModelMetadata(): Promise<MetadataIndex> {
218
295
  if (refreshInflight) return refreshInflight;
219
296
  refreshInflight = (async () => {
220
- const [retiredNames, capabilities, rateLimits, reasoning, learnedReasoning] = await Promise.all(
221
- [
222
- retiredCache.get(),
223
- capabilitiesCache.get(),
224
- rateLimitsCache.get(),
225
- reasoningDocsCache.get(),
226
- learnedReasoningCache.get(),
227
- ],
228
- );
297
+ const [
298
+ retiredNames,
299
+ capabilities,
300
+ modelsDev,
301
+ modelParams,
302
+ rateLimits,
303
+ reasoning,
304
+ learnedReasoning,
305
+ ] = await Promise.all([
306
+ retiredCache.get(),
307
+ capabilitiesCache.get(),
308
+ modelsDevCache.get(),
309
+ modelParamsCache.get(),
310
+ rateLimitsCache.get(),
311
+ reasoningDocsCache.get(),
312
+ learnedReasoningCache.get(),
313
+ ]);
229
314
  liveIndex = buildIndex({
230
315
  retiredNames,
231
316
  capabilities,
317
+ modelsDev,
318
+ modelParams,
232
319
  rateLimits,
233
320
  reasoning,
234
321
  learnedReasoning,
@@ -242,9 +329,19 @@ export async function refreshModelMetadata(): Promise<MetadataIndex> {
242
329
 
243
330
  /** Load shared cache entries without forcing a network refresh. */
244
331
  export async function hydrateModelMetadata(): Promise<MetadataIndex> {
245
- const [retiredNames, capabilities, rateLimits, reasoning, learnedReasoning] = await Promise.all([
332
+ const [
333
+ retiredNames,
334
+ capabilities,
335
+ modelsDev,
336
+ modelParams,
337
+ rateLimits,
338
+ reasoning,
339
+ learnedReasoning,
340
+ ] = await Promise.all([
246
341
  peekOrFallback(retiredCache, COMMITTED_RETIRED_MODELS),
247
342
  peekOrFallback(capabilitiesCache, capabilitiesSnapshot.capabilities),
343
+ peekOrFallback(modelsDevCache, modelsDevSnapshot.capabilities),
344
+ peekOrFallback(modelParamsCache, modelParamsSnapshot.catalogue),
248
345
  peekOrFallback(rateLimitsCache, rateLimitsSnapshot.catalogue.models),
249
346
  peekOrFallback(reasoningDocsCache, reasoningSnapshot.catalogue.models),
250
347
  peekOrFallback(learnedReasoningCache, {}),
@@ -252,6 +349,8 @@ export async function hydrateModelMetadata(): Promise<MetadataIndex> {
252
349
  liveIndex = buildIndex({
253
350
  retiredNames,
254
351
  capabilities,
352
+ modelsDev,
353
+ modelParams,
255
354
  rateLimits,
256
355
  reasoning,
257
356
  learnedReasoning,
@@ -305,17 +404,21 @@ export async function rememberReasoningLevels(
305
404
  * Parse an upstream error body and persist the supported ladder for `model`.
306
405
  *
307
406
  * Accepts the same nested Databricks envelopes as {@link parseReasoningLevels}.
308
- * Returns the levels that were stored (or the current lookup when parse yields
309
- * nothing). The raw error body is never cached.
407
+ * Returns a published ladder without parsing or storing error-derived levels
408
+ * when one exists. Otherwise returns the levels that were stored, or the
409
+ * current fallback when parsing yields nothing. The raw error body is never
410
+ * cached.
310
411
  */
311
412
  export async function learnReasoningLevelsFromError(
312
413
  model: string,
313
414
  body: unknown,
314
415
  ): Promise<ReasoningLevel[]> {
416
+ const published = publishedReasoningLevelsFor(model, metadataIndex());
417
+ if (published) return published;
315
418
  return rememberReasoningLevels(model, parseReasoningLevels(body));
316
419
  }
317
420
 
318
- /** Resolve documented model capabilities from a name or endpoint summary. */
421
+ /** Resolve published model capabilities from a name or endpoint summary. */
319
422
  export function modelCapabilitiesFor(model: string | ServingEndpointSummary): ModelCapabilities {
320
423
  const capabilities = metadataIndex().capabilities;
321
424
  const identities = modelIdentities(model, false);
@@ -332,9 +435,27 @@ export function modelCapabilitiesFor(model: string | ServingEndpointSummary): Mo
332
435
  isFoundationModelIdentity(identity) &&
333
436
  inheritsNativeWebSearch(identity, capabilities.webSearch),
334
437
  ),
438
+ reasoning:
439
+ keys.some((key) => capabilities.reasoning.has(key)) ||
440
+ identities.some((identity) => modelReasoningEfforts(identity).length > 0),
441
+ tools:
442
+ keys.some((key) => capabilities.tools.has(key)) || identities.some(modelSupportsTools),
443
+ structuredOutput: keys.some((key) => capabilities.structuredOutput.has(key)),
444
+ temperature: modelTemperatureSupportFor(model) === true,
335
445
  };
336
446
  }
337
447
 
448
+ /** Resolve explicit Databricks temperature support, or undefined when unpublished. */
449
+ export function modelTemperatureSupportFor(
450
+ model: string | ServingEndpointSummary,
451
+ ): boolean | undefined {
452
+ const index = metadataIndex();
453
+ const keys = modelIdentities(model).flatMap((identity) => modelKey(identity) ?? []);
454
+ if (keys.some((key) => index.temperatureUnsupported.has(key))) return false;
455
+ if (keys.some((key) => index.capabilities.temperature.has(key))) return true;
456
+ return undefined;
457
+ }
458
+
338
459
  /** Resolve published ITPM, OTPM, and QPH limits from a model identity. */
339
460
  export function modelRateLimitsFor(
340
461
  model: string | ServingEndpointSummary,
@@ -350,26 +471,40 @@ export function modelRateLimitsFor(
350
471
  }
351
472
 
352
473
  /**
353
- * Resolve reasoning levels: learned from errors, then documentation, then
354
- * family defaults.
474
+ * Resolve reasoning levels: Databricks documentation, modelparams.dev,
475
+ * learned errors, then family defaults.
355
476
  *
356
- * Does not parse error bodies; callers should use {@link parseReasoningLevels}
357
- * and then {@link rememberReasoningLevels}.
477
+ * Does not parse error bodies; callers handling an upstream error should use
478
+ * {@link learnReasoningLevelsFromError} so published levels keep precedence.
358
479
  */
359
480
  export function modelReasoningLevelsFor(model: string | ServingEndpointSummary): ReasoningLevel[] {
360
481
  const index = metadataIndex();
482
+ const published = publishedReasoningLevelsFor(model, index);
483
+ if (published) return published;
361
484
  for (const identity of modelIdentities(model)) {
362
485
  const learned = documentedReasoningLevels(identity, index.learnedReasoning);
363
486
  if (learned?.length) return learned;
364
487
  }
365
- for (const identity of modelIdentities(model)) {
366
- const documented = documentedReasoningLevels(identity, index.reasoning);
367
- if (documented?.length) return documented;
368
- }
369
488
  const primary = typeof model === "string" ? model : (model.modelServiceName ?? model.name);
370
489
  return defaultReasoningLevels(primary);
371
490
  }
372
491
 
492
+ function publishedReasoningLevelsFor(
493
+ model: string | ServingEndpointSummary,
494
+ index: MetadataIndex,
495
+ ): ReasoningLevel[] | undefined {
496
+ const identities = modelIdentities(model);
497
+ for (const identity of identities) {
498
+ const documented = documentedReasoningLevels(identity, index.reasoningDocs);
499
+ if (documented?.length) return documented;
500
+ }
501
+ for (const identity of identities) {
502
+ const modelParams = documentedReasoningLevels(identity, index.modelParamsReasoning);
503
+ if (modelParams?.length) return modelParams;
504
+ }
505
+ return undefined;
506
+ }
507
+
373
508
  /** Resolve retirement, capability, and rate-limit metadata together. */
374
509
  export function modelMetadataFor(model: string | ServingEndpointSummary): ModelMetadata {
375
510
  const rateLimits = modelRateLimitsFor(model);
@@ -384,12 +519,16 @@ export function modelMetadataFor(model: string | ServingEndpointSummary): ModelM
384
519
  export function modelMetadataGeneratedAt(): Readonly<{
385
520
  retiredModels: number;
386
521
  capabilities: number;
522
+ modelsDev: number;
523
+ modelParams: number;
387
524
  rateLimits: number;
388
525
  reasoning: number;
389
526
  }> {
390
527
  return {
391
528
  retiredModels: RETIRED_MODELS_GENERATED_AT,
392
529
  capabilities: capabilitiesSnapshot.generatedAt,
530
+ modelsDev: modelsDevSnapshot.generatedAt,
531
+ modelParams: modelParamsSnapshot.generatedAt,
393
532
  rateLimits: rateLimitsSnapshot.generatedAt,
394
533
  reasoning: reasoningSnapshot.generatedAt,
395
534
  };
@@ -423,6 +562,38 @@ async function loadPage(url: string): Promise<string> {
423
562
  return response.text();
424
563
  }
425
564
 
565
+ async function loadJson(url: string): Promise<unknown> {
566
+ const response = await fetch(url, {
567
+ headers: { "user-agent": USER_AGENT },
568
+ signal: AbortSignal.timeout(15_000),
569
+ });
570
+ if (!response.ok) {
571
+ throw new Error(`Model metadata source returned HTTP ${response.status}: ${url}`);
572
+ }
573
+ return response.json();
574
+ }
575
+
576
+ function mergeModelsDevData(input: {
577
+ readonly fresh: ModelsDevData | undefined;
578
+ readonly previous: ModelsDevData | undefined;
579
+ readonly fallback: ModelsDevData;
580
+ }): ModelsDevData {
581
+ const capability = (key: keyof ModelsDevCapabilityCatalogue) =>
582
+ mergePreferFreshList({
583
+ fresh: input.fresh?.[key],
584
+ previous: input.previous?.[key],
585
+ fallback: input.fallback[key],
586
+ });
587
+ return {
588
+ imageInput: capability("imageInput"),
589
+ reasoning: capability("reasoning"),
590
+ tools: capability("tools"),
591
+ structuredOutput: capability("structuredOutput"),
592
+ temperatureSupported: capability("temperatureSupported"),
593
+ temperatureUnsupported: capability("temperatureUnsupported"),
594
+ };
595
+ }
596
+
426
597
  function nowSeconds(): number {
427
598
  return Math.floor(Date.now() / 1000);
428
599
  }
@@ -273,23 +273,24 @@ const DISABLED_REASONING_TOKENS = new Set(["none", "off", "disable", "disabled"]
273
273
  /**
274
274
  * Remap a requested effort onto the nearest supported {@link ReasoningLevel}.
275
275
  *
276
- * `none` / `off` (and disable aliases) are not a low budget: when the model
277
- * requires reasoning, they snap to the supported level closest to
278
- * {@link ReasoningLevel.Medium}. Other requests prefer the highest allowed
279
- * level at or below the request, otherwise the lowest allowed level.
276
+ * `none` / `off` (and disable aliases), null, and unrecognized explicit values
277
+ * are not treated as a low budget: when the model requires reasoning, they
278
+ * snap to the supported level closest to {@link ReasoningLevel.Medium}. Other
279
+ * requests prefer the highest allowed level at or below the request, otherwise
280
+ * the lowest allowed level.
280
281
  *
281
- * Returns `undefined` when the request cannot be parsed or `levels` is empty.
282
+ * Returns `undefined` only when `levels` is empty.
282
283
  */
283
284
  export function remapReasoning(
284
285
  requested: unknown,
285
286
  levels: readonly ReasoningLevel[],
286
287
  ): ReasoningLevel | undefined {
287
288
  if (levels.length === 0) return undefined;
288
- if (isDisabledReasoningToken(requested)) {
289
+ if (requested == null || isDisabledReasoningToken(requested)) {
289
290
  return nearestReasoning(ReasoningLevel.Medium, levels);
290
291
  }
291
292
  const parsed = parseReasoning(requested);
292
- if (!parsed) return undefined;
293
+ if (!parsed) return nearestReasoning(ReasoningLevel.Medium, levels);
293
294
  if (levels.includes(parsed)) return parsed;
294
295
  const requestedIndex = REASONING_LEVELS.indexOf(parsed);
295
296
  let best: ReasoningLevel | undefined;
@@ -349,7 +350,8 @@ export interface AdaptedRequestReasoning {
349
350
  * Rewrite `reasoning_effort` / `reasoning.effort` onto a supported ladder.
350
351
  *
351
352
  * Model-agnostic: callers supply the allowed levels (learned, documented, or
352
- * defaults). Bodies without reasoning fields are returned unchanged.
353
+ * defaults). Explicit null or unrecognized values use the medium-centered
354
+ * fallback. Bodies without reasoning fields are returned unchanged.
353
355
  */
354
356
  export function adaptRequestReasoning(
355
357
  body: Readonly<Record<string, unknown>>,
@@ -357,11 +359,13 @@ export function adaptRequestReasoning(
357
359
  ): AdaptedRequestReasoning {
358
360
  if (levels.length === 0) return { body, changed: false };
359
361
 
362
+ const hasTopLevel = Object.hasOwn(body, "reasoning_effort");
360
363
  const topLevel = body.reasoning_effort;
361
364
  const reasoning = isRecord(body.reasoning) ? body.reasoning : undefined;
365
+ const hasNested = reasoning ? Object.hasOwn(reasoning, "effort") : false;
362
366
  const nested = reasoning?.effort;
363
- const requested = topLevel ?? nested;
364
- if (requested === undefined) return { body, changed: false };
367
+ if (!hasTopLevel && !hasNested) return { body, changed: false };
368
+ const requested = hasTopLevel ? topLevel : nested;
365
369
 
366
370
  const remapped = remapReasoning(requested, levels);
367
371
  if (!remapped) return { body, changed: false };
@@ -370,13 +374,13 @@ export function adaptRequestReasoning(
370
374
  let changed = false;
371
375
  let next: Record<string, unknown> = { ...body };
372
376
 
373
- if (typeof topLevel === "string" || typeof topLevel === "number") {
377
+ if (hasTopLevel) {
374
378
  if (String(topLevel) !== wire) {
375
379
  next = { ...next, reasoning_effort: wire };
376
380
  changed = true;
377
381
  }
378
382
  }
379
- if (reasoning && (typeof nested === "string" || typeof nested === "number")) {
383
+ if (reasoning && hasNested) {
380
384
  if (String(nested) !== wire) {
381
385
  next = { ...next, reasoning: { ...reasoning, effort: wire } };
382
386
  changed = true;