@oh-my-pi/pi-catalog 18.0.6 → 18.0.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.7] - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - Added cached background refresh from the shared models.dev catalog so newly published models for known providers can appear without a new OMP release, while bundled models remain the offline fallback.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed LiteLLM model discovery so model pricing is correctly populated when pricing information is provided by a later metadata endpoint.
14
+
5
15
  ## [18.0.5] - 2026-08-25
6
16
 
7
17
  ### Added
@@ -11,6 +11,8 @@ export interface ModelsDevFallback<TApi extends Api = Api, TPayload = unknown> {
11
11
  fetch(): Promise<TPayload>;
12
12
  /** Maps payload into provider models. */
13
13
  map(payload: TPayload, providerId: Provider): readonly ModelSpec<TApi>[];
14
+ /** When true, mapped rows can add model ids but cannot replace static metadata. */
15
+ additiveOnly?: boolean;
14
16
  }
15
17
  /**
16
18
  * Configuration for provider model resolution.
@@ -45,18 +47,25 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
45
47
  /** Clock override for deterministic tests. */
46
48
  now?: () => number;
47
49
  }
50
+ /**
51
+ * Catalog source that most recently refreshed the resolved provider snapshot.
52
+ */
53
+ export type ModelResolutionSource = "bundled" | "cache" | "models.dev" | "provider";
48
54
  /**
49
55
  * Resolution result.
50
56
  *
51
57
  * `stale` is false when the resolved catalog is authoritative for the selected provider:
52
- * - a dynamic endpoint fetch succeeded in this call (an empty catalog is still
58
+ * - a provider endpoint fetch succeeded in this call (an empty catalog is still
53
59
  * authoritative for the cycle, so downstream pruning of removed models runs),
60
+ * - a models.dev fetch succeeded for a provider without endpoint discovery,
54
61
  * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or
55
- * - the provider has no dynamic fetcher configured.
62
+ * - the provider has no remote fetcher configured.
56
63
  */
57
64
  export interface ModelResolutionResult<TApi extends Api = Api> {
58
65
  models: Model<TApi>[];
59
66
  stale: boolean;
67
+ source: ModelResolutionSource;
68
+ updatedAt?: number;
60
69
  }
61
70
  /**
62
71
  * Stateful facade over provider model resolution.
@@ -70,8 +79,15 @@ export interface ModelManager<TApi extends Api = Api> {
70
79
  export declare function createModelManager<TApi extends Api = Api, TModelsDevPayload = unknown>(options: ModelManagerOptions<TApi, TModelsDevPayload>): ModelManager<TApi>;
71
80
  /**
72
81
  * Resolves provider models with source precedence:
73
- * static -> stencil.so -> cache -> dynamic.
82
+ * static -> cached fallback -> stencil.so -> dynamic.
74
83
  *
75
- * Later sources override earlier ones by model id.
84
+ * Later sources override earlier ones by model id. Cached rows participate only
85
+ * when at least one configured remote source did not refresh successfully.
76
86
  */
77
87
  export declare function resolveProviderModels<TApi extends Api = Api, TModelsDevPayload = unknown>(options: ModelManagerOptions<TApi, TModelsDevPayload>, strategy?: ModelRefreshStrategy): Promise<ModelResolutionResult<TApi>>;
88
+ /**
89
+ * Return the versioned, low-collision model-cache identity for a static provider
90
+ * slice. Results are cached by array reference so repeat cold-start paths skip
91
+ * the JSON serialization and hash.
92
+ */
93
+ export declare function fingerprintStaticModels<TApi extends Api>(models: readonly (ModelSpec<TApi> | Model<TApi>)[], dynamicModelsAuthoritative?: boolean): string;
@@ -0,0 +1,8 @@
1
+ import type { Api, ModelSpec } from "../types.js";
2
+ /**
3
+ * Remove models.dev rows that OMP cannot route successfully.
4
+ *
5
+ * Generation and runtime refresh share this policy so a live catalog cannot
6
+ * reintroduce selectors deliberately excluded from the bundled catalog.
7
+ */
8
+ export declare function filterModelsDevCatalogRows<TApi extends Api>(models: readonly ModelSpec<TApi>[]): ModelSpec<TApi>[];
@@ -1,4 +1,4 @@
1
- import type { ModelManagerOptions } from "../model-manager.js";
1
+ import type { ModelManagerOptions, ModelsDevFallback } from "../model-manager.js";
2
2
  import { getBundledModels } from "../models.js";
3
3
  import type { Api, FetchImpl, ModelSpec, Provider } from "../types.js";
4
4
  import { ALIBABA_TOKEN_PLAN_BASE_URL } from "../wire/alibaba-token-plan.js";
@@ -32,9 +32,12 @@ export interface ModelsDevModel {
32
32
  * The frame magic is sniffed rather than trusting content-type so plain-JSON
33
33
  * responses (test stubs, fallback mirrors) parse identically.
34
34
  *
35
- * Fetched fully once per process: concurrent callers share the in-flight
36
- * request, repeat callers send a conditional GET that the server answers
37
- * (and deliberately does not log) with `304`.
35
+ * Fetched fully once per fetch context: concurrent callers sharing a fetch
36
+ * implementation reuse one transport request, while repeat callers send a
37
+ * conditional GET that the server answers with `304`. Each subscriber may stop
38
+ * waiting independently; the shared transport retains its own hard deadline.
39
+ * Transient failures reuse the last in-memory payload for callers that only
40
+ * need best-effort metadata.
38
41
  */
39
42
  export declare function fetchWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise<unknown>;
40
43
  /**
@@ -685,3 +688,16 @@ export interface ModelsDevProviderDescriptor {
685
688
  export declare function mapModelsDevToModels(data: Record<string, unknown>, descriptors: readonly ModelsDevProviderDescriptor[]): ModelSpec<Api>[];
686
689
  /** All provider descriptors for models.dev data mapping in generate-models.ts. */
687
690
  export declare const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescriptor[];
691
+ /** Providers whose bundled catalog can receive additive models.dev updates at runtime. */
692
+ export declare const MODELS_DEV_CATALOG_PROVIDER_IDS: readonly string[];
693
+ /**
694
+ * Build the shared models.dev fallback for one known provider.
695
+ *
696
+ * Provider managers sharing one fetch implementation reuse its conditional
697
+ * catalog session. Each mapped provider slice is persisted independently so
698
+ * startup can restore it without parsing the full catalog.
699
+ *
700
+ * `timeoutMs` bounds the catalog request. It is configurable for callers with a
701
+ * stricter startup budget and for deterministic timeout tests.
702
+ */
703
+ export declare function modelsDevCatalogFallback(providerId: string, fetchImpl?: FetchImpl, timeoutMs?: number): ModelsDevFallback<Api> | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-catalog",
4
- "version": "18.0.6",
4
+ "version": "18.0.7",
5
5
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -34,11 +34,11 @@
34
34
  "gen:proto": "bun scripts/generate-protocols.ts"
35
35
  },
36
36
  "dependencies": {
37
- "@oh-my-pi/omptype": "18.0.6",
38
- "@oh-my-pi/pi-utils": "18.0.6"
37
+ "@oh-my-pi/omptype": "18.0.7",
38
+ "@oh-my-pi/pi-utils": "18.0.7"
39
39
  },
40
40
  "devDependencies": {
41
- "@oh-my-pi/pi-ai": "18.0.6",
41
+ "@oh-my-pi/pi-ai": "18.0.7",
42
42
  "@types/bun": "^1.3.14"
43
43
  },
44
44
  "engines": {
@@ -21,6 +21,8 @@ export interface ModelsDevFallback<TApi extends Api = Api, TPayload = unknown> {
21
21
  fetch(): Promise<TPayload>;
22
22
  /** Maps payload into provider models. */
23
23
  map(payload: TPayload, providerId: Provider): readonly ModelSpec<TApi>[];
24
+ /** When true, mapped rows can add model ids but cannot replace static metadata. */
25
+ additiveOnly?: boolean;
24
26
  }
25
27
 
26
28
  /**
@@ -57,18 +59,26 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
57
59
  now?: () => number;
58
60
  }
59
61
 
62
+ /**
63
+ * Catalog source that most recently refreshed the resolved provider snapshot.
64
+ */
65
+ export type ModelResolutionSource = "bundled" | "cache" | "models.dev" | "provider";
66
+
60
67
  /**
61
68
  * Resolution result.
62
69
  *
63
70
  * `stale` is false when the resolved catalog is authoritative for the selected provider:
64
- * - a dynamic endpoint fetch succeeded in this call (an empty catalog is still
71
+ * - a provider endpoint fetch succeeded in this call (an empty catalog is still
65
72
  * authoritative for the cycle, so downstream pruning of removed models runs),
73
+ * - a models.dev fetch succeeded for a provider without endpoint discovery,
66
74
  * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or
67
- * - the provider has no dynamic fetcher configured.
75
+ * - the provider has no remote fetcher configured.
68
76
  */
69
77
  export interface ModelResolutionResult<TApi extends Api = Api> {
70
78
  models: Model<TApi>[];
71
79
  stale: boolean;
80
+ source: ModelResolutionSource;
81
+ updatedAt?: number;
72
82
  }
73
83
 
74
84
  /**
@@ -171,9 +181,10 @@ function restoreCachedModelHeaders<TApi extends Api>(
171
181
 
172
182
  /**
173
183
  * Resolves provider models with source precedence:
174
- * static -> stencil.so -> cache -> dynamic.
184
+ * static -> cached fallback -> stencil.so -> dynamic.
175
185
  *
176
- * Later sources override earlier ones by model id.
186
+ * Later sources override earlier ones by model id. Cached rows participate only
187
+ * when at least one configured remote source did not refresh successfully.
177
188
  */
178
189
  export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPayload = unknown>(
179
190
  options: ModelManagerOptions<TApi, TModelsDevPayload>,
@@ -187,6 +198,10 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
187
198
  const staticModels = options.staticModels
188
199
  ? passModelList<TApi>(options.staticModels)
189
200
  : (getBundledModels(options.providerId as GeneratedProvider) as Model<TApi>[]);
201
+ const additiveStaticModelIds =
202
+ options.modelsDev?.additiveOnly && staticModels.length > 0
203
+ ? new Set(staticModels.map(model => model.id))
204
+ : undefined;
190
205
  const cache = readModelCache<TApi>(cacheProviderId, ttlMs, now, dbPath);
191
206
  const restoredCache = restoreCachedModelHeaders(
192
207
  cache?.models ?? [],
@@ -200,7 +215,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
200
215
  const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
201
216
  const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false;
202
217
  const cacheDropIds = options.dropCachedModelIdsOnStaticMismatch;
203
- const staticCatalogFingerprint = fingerprintStatic(staticModels, dynamicModelsAuthoritative);
218
+ const staticCatalogFingerprint = fingerprintStaticModels(staticModels, dynamicModelsAuthoritative);
204
219
  // Endpoint-migration policy is cache identity: adding an id must invalidate
205
220
  // matching-static-catalog caches written by the prior resolver.
206
221
  const staticFingerprint =
@@ -219,20 +234,18 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
219
234
  (!dynamicModelsAuthoritative || cacheFingerprintMatches);
220
235
  const dynamicFetcher = options.fetchDynamicModels;
221
236
  const hasDynamicFetcher = typeof dynamicFetcher === "function";
222
- const hasAuthoritativeCache = ((cache?.authoritative ?? false) && hasUsableFreshCache) || !hasDynamicFetcher;
237
+ const hasModelsDevFetcher = options.modelsDev !== undefined;
238
+ const hasRemoteFetcher = hasDynamicFetcher || hasModelsDevFetcher;
239
+ const hasAuthoritativeCache = ((cache?.authoritative ?? false) && hasUsableFreshCache) || !hasRemoteFetcher;
223
240
  const cacheAgeMs = cache ? now() - cache.updatedAt : Number.POSITIVE_INFINITY;
224
- const shouldFetchFromNetwork = shouldFetchRemoteSources(
225
- strategy,
226
- hasUsableFreshCache,
227
- hasAuthoritativeCache,
228
- cacheAgeMs,
229
- );
241
+ const shouldFetchFromNetwork =
242
+ hasRemoteFetcher && shouldFetchRemoteSources(strategy, hasUsableFreshCache, hasAuthoritativeCache, cacheAgeMs);
230
243
 
231
244
  // Cold-start fast path: when a fresh, authoritative cache exists, the network
232
245
  // fetch is skipped, AND the static catalog slice is byte-identical to what
233
246
  // was merged in last time, the cache row IS the authoritative merge result.
234
- // Re-running `mergeDynamicModels(static, cache)` would just rebuild the same
235
- // objects (~800ms in the steady-state cold-start profile for `omp -p hi`).
247
+ // Additive caches still need same-id rows stripped because an older binary
248
+ // may have written the snapshot before additive semantics were enabled.
236
249
  if (
237
250
  !shouldFetchFromNetwork &&
238
251
  cache?.fresh &&
@@ -240,17 +253,40 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
240
253
  cacheFingerprintMatches &&
241
254
  !cacheHasUnresolvedHeaders
242
255
  ) {
243
- return { models: collapseBuiltModelVariants(restoredCache.models), stale: false };
256
+ const cacheContribution = additiveStaticModelIds
257
+ ? restoredCache.models.filter(model => !additiveStaticModelIds.has(model.id))
258
+ : restoredCache.models;
259
+ const cachedModels = additiveStaticModelIds
260
+ ? mergeDynamicModels(staticModels, cacheContribution)
261
+ : restoredCache.models;
262
+ const source: ModelResolutionSource = cacheContribution.length > 0 ? "cache" : "bundled";
263
+ return {
264
+ models: collapseBuiltModelVariants(cachedModels),
265
+ stale: false,
266
+ source,
267
+ ...(source === "cache" ? { updatedAt: cache.updatedAt } : {}),
268
+ };
244
269
  }
245
270
 
246
271
  const [fetchedModelsDevModels, fetchedDynamicModels] = shouldFetchFromNetwork
247
272
  ? await Promise.all([fetchModelsDev(options), dynamicFetcher ? fetchDynamicModels(dynamicFetcher) : null])
248
273
  : [null, null];
249
- const modelsDevModels = normalizeModelList<TApi>(fetchedModelsDevModels ?? []);
274
+ const modelsDevFetchSucceeded = fetchedModelsDevModels !== null;
275
+ const normalizedModelsDevModels = normalizeModelList<TApi>(fetchedModelsDevModels ?? []);
276
+ const modelsDevModels = additiveStaticModelIds
277
+ ? normalizedModelsDevModels.filter(model => !additiveStaticModelIds.has(model.id))
278
+ : normalizedModelsDevModels;
250
279
  const shouldUseFreshCacheAsAuthoritative =
251
280
  strategy === "online-if-uncached" && hasUsableFreshCache && hasAuthoritativeCache;
252
281
  const dynamicFetchSucceeded = fetchedDynamicModels !== null;
253
- const cacheModels = dynamicFetchSucceeded
282
+ const anyRemoteFetchSucceeded = modelsDevFetchSucceeded || dynamicFetchSucceeded;
283
+ const allConfiguredRemoteFetchesSucceeded =
284
+ hasRemoteFetcher &&
285
+ (!hasModelsDevFetcher || modelsDevFetchSucceeded) &&
286
+ (!hasDynamicFetcher || dynamicFetchSucceeded);
287
+ const authoritativeDynamicFetchSucceeded = dynamicModelsAuthoritative && dynamicFetchSucceeded;
288
+ const remoteResolutionComplete = authoritativeDynamicFetchSucceeded || allConfiguredRemoteFetchesSucceeded;
289
+ const preparedCacheModels = remoteResolutionComplete
254
290
  ? []
255
291
  : prepareCacheModelsForStaticMismatch(
256
292
  usableCachedModels,
@@ -258,37 +294,44 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
258
294
  cacheFingerprintMatches,
259
295
  options.dropCachedModelIdsOnStaticMismatch,
260
296
  );
297
+ // Additive shared-catalog rows may only introduce IDs. Apply that boundary
298
+ // to cache fallback too, including snapshots written by an older binary.
299
+ const cacheModels = additiveStaticModelIds
300
+ ? preparedCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
301
+ : preparedCacheModels;
261
302
  const dynamicModels = fetchedDynamicModels ?? [];
262
- // A successful empty result stays authoritative for THIS cycle (so an
303
+ // A successful empty endpoint result stays authoritative for THIS cycle (so an
263
304
  // intentional catalog emptying still prunes removed models downstream), but
264
305
  // is NOT pinned into the cache as authoritative — that would suppress the
265
- // short retry that recovers a transient empty response (#6620). The two
266
- // concerns are deliberately separate: result authority vs. cache retry.
267
- const dynamicCacheAuthoritative = dynamicFetchSucceeded && dynamicModels.length > 0;
268
- const mergedWithCache = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), cacheModels);
269
- const mergedModels = mergeDynamicModels(mergedWithCache, dynamicModels);
306
+ // short retry that recovers a transient empty response (#6620). Shared
307
+ // models.dev snapshots may be empty for one provider and remain authoritative.
308
+ const cacheAuthoritative = hasDynamicFetcher
309
+ ? dynamicFetchSucceeded &&
310
+ dynamicModels.length > 0 &&
311
+ (dynamicModelsAuthoritative || !hasModelsDevFetcher || modelsDevFetchSucceeded)
312
+ : modelsDevFetchSucceeded;
313
+ const mergedWithCache = mergeDynamicModels(staticModels, cacheModels);
314
+ const mergedWithModelsDev = mergeDynamicModels(mergedWithCache, modelsDevModels);
315
+ const mergedModels = mergeDynamicModels(mergedWithModelsDev, dynamicModels);
270
316
  const models = collapseBuiltModelVariants(
271
- dynamicModelsAuthoritative && dynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : mergedModels,
317
+ authoritativeDynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : mergedModels,
272
318
  );
273
- const dynamicAuthoritative = !hasDynamicFetcher || dynamicFetchSucceeded || shouldUseFreshCacheAsAuthoritative;
319
+ const resolutionAuthoritative = !hasRemoteFetcher || remoteResolutionComplete || shouldUseFreshCacheAsAuthoritative;
320
+ const remoteUpdatedAt = anyRemoteFetchSucceeded ? now() : undefined;
274
321
  if (shouldFetchFromNetwork) {
275
- if (dynamicFetchSucceeded) {
276
- const mergedSnapshot = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), dynamicModels);
277
- const snapshotModels = dynamicModelsAuthoritative
278
- ? retainModelIds(mergedSnapshot, dynamicModels)
279
- : mergedSnapshot;
322
+ if (anyRemoteFetchSucceeded) {
280
323
  writeModelCache(
281
324
  cacheProviderId,
282
- now(),
283
- collapseBuiltModelVariants(snapshotModels),
284
- dynamicCacheAuthoritative,
325
+ remoteUpdatedAt!,
326
+ models,
327
+ cacheAuthoritative,
285
328
  staticFingerprint,
286
329
  dbPath,
287
330
  staticModels,
288
331
  restorableHeaderFallback,
289
332
  );
290
333
  } else {
291
- // Dynamic fetch failed — update cache with a non-authoritative snapshot so
334
+ // Remote fetch failed — update cache with a non-authoritative snapshot so
292
335
  // stale state remains visible while retry backoff still applies.
293
336
  const latestCache = readModelCache<TApi>(cacheProviderId, ttlMs, now, dbPath);
294
337
  const latestRestoredCache = restoreCachedModelHeaders(
@@ -302,16 +345,17 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
302
345
  const latestUsableCacheModels = latestRestoredCache.models.filter(
303
346
  model => !latestRestoredCache.unresolvedModelIds.has(model.id),
304
347
  );
348
+ const preparedLatestCacheModels = prepareCacheModelsForStaticMismatch(
349
+ latestUsableCacheModels,
350
+ staticModels,
351
+ cacheFingerprintMatches,
352
+ options.dropCachedModelIdsOnStaticMismatch,
353
+ );
354
+ const latestCacheModels = additiveStaticModelIds
355
+ ? preparedLatestCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
356
+ : preparedLatestCacheModels;
305
357
  const fallbackSnapshotModels = collapseBuiltModelVariants(
306
- mergeDynamicModels(
307
- mergeModelSources(staticModels, modelsDevModels),
308
- prepareCacheModelsForStaticMismatch(
309
- latestUsableCacheModels,
310
- staticModels,
311
- cacheFingerprintMatches,
312
- options.dropCachedModelIdsOnStaticMismatch,
313
- ),
314
- ),
358
+ mergeDynamicModels(mergeDynamicModels(staticModels, latestCacheModels), modelsDevModels),
315
359
  );
316
360
  writeModelCache(
317
361
  cacheProviderId,
@@ -325,9 +369,23 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
325
369
  );
326
370
  }
327
371
  }
372
+ const cacheContributed = cacheModels.length > 0;
373
+ const source: ModelResolutionSource = dynamicFetchSucceeded
374
+ ? "provider"
375
+ : modelsDevFetchSucceeded
376
+ ? "models.dev"
377
+ : cacheContributed
378
+ ? "cache"
379
+ : "bundled";
328
380
  return {
329
381
  models,
330
- stale: !dynamicAuthoritative,
382
+ stale: !resolutionAuthoritative,
383
+ source,
384
+ ...(remoteUpdatedAt !== undefined
385
+ ? { updatedAt: remoteUpdatedAt }
386
+ : cacheContributed && cache
387
+ ? { updatedAt: cache.updatedAt }
388
+ : {}),
331
389
  };
332
390
  }
333
391
 
@@ -409,23 +467,6 @@ function prepareCacheModelsForStaticMismatch<TApi extends Api>(
409
467
  return sanitizedModels;
410
468
  }
411
469
 
412
- function mergeModelSources<TApi extends Api>(...sources: readonly (readonly Model<TApi>[])[]): Model<TApi>[] {
413
- // Strip out empty/missing sources up front. The hot path is `(static, [])`
414
- // (modelsDev disabled / failed) — a single non-empty source means we can
415
- // skip the Map churn entirely and just hand back the array.
416
- const nonEmpty = sources.filter(source => source.length > 0);
417
- if (nonEmpty.length === 0) return [];
418
- if (nonEmpty.length === 1) return [...nonEmpty[0]];
419
- const merged = new Map<string, Model<TApi>>();
420
- for (const source of nonEmpty) {
421
- for (const model of source) {
422
- if (!model?.id) continue;
423
- merged.set(model.id, model);
424
- }
425
- }
426
- return Array.from(merged.values());
427
- }
428
-
429
470
  function mergeDynamicModels<TApi extends Api>(
430
471
  baseModels: readonly Model<TApi>[],
431
472
  dynamicModels: readonly Model<TApi>[],
@@ -459,22 +500,22 @@ function retainModelIds<TApi extends Api>(
459
500
  return models.filter(model => retainedIds.has(model.id));
460
501
  }
461
502
 
462
- /**
463
- * Stable, low-collision fingerprint of a static catalog slice. Cached by
464
- * reference so repeat calls in the same process (e.g. multiple cold-start
465
- * arms calling `resolveProviderModels` with the same `staticModels` array)
466
- * skip the JSON+hash work after the first call.
467
- */
468
503
  const MODEL_CACHE_FINGERPRINT_VERSION = "merge-v3";
469
504
  const kStaticFingerprint = Symbol("model-manager.staticFingerprint");
470
- type ModelArrayWithFingerprint = readonly Model<Api>[] & { [kStaticFingerprint]?: string };
471
- function fingerprintStatic<TApi extends Api>(
472
- models: readonly Model<TApi>[],
505
+ type ModelArrayWithFingerprint = readonly (ModelSpec<Api> | Model<Api>)[] & { [kStaticFingerprint]?: string };
506
+
507
+ /**
508
+ * Return the versioned, low-collision model-cache identity for a static provider
509
+ * slice. Results are cached by array reference so repeat cold-start paths skip
510
+ * the JSON serialization and hash.
511
+ */
512
+ export function fingerprintStaticModels<TApi extends Api>(
513
+ models: readonly (ModelSpec<TApi> | Model<TApi>)[],
473
514
  dynamicModelsAuthoritative = false,
474
515
  ): string {
475
516
  if (models.length === 0) return `${MODEL_CACHE_FINGERPRINT_VERSION}:empty`;
476
517
  if (dynamicModelsAuthoritative)
477
- return `${MODEL_CACHE_FINGERPRINT_VERSION}:authoritative:${fingerprintStatic(models)}`;
518
+ return `${MODEL_CACHE_FINGERPRINT_VERSION}:authoritative:${fingerprintStaticModels(models)}`;
478
519
  const tagged = models as ModelArrayWithFingerprint;
479
520
  const cached = tagged[kStaticFingerprint];
480
521
  if (cached !== undefined) return cached;
@@ -60,7 +60,7 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
60
60
  return "cursor:default-effort-v4";
61
61
  case "litellm": {
62
62
  const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!;
63
- return `litellm:rich-v6:${Bun.hash(baseUrl).toString(36)}`;
63
+ return `litellm:rich-v7:${Bun.hash(baseUrl).toString(36)}`;
64
64
  }
65
65
  case "opencode-go":
66
66
  case "opencode-zen": {
@@ -0,0 +1,38 @@
1
+ import type { Api, ModelSpec } from "../types";
2
+
3
+ const BEDROCK_MANTLE_OPENAI_MODEL_IDS: Readonly<Record<string, true>> = {
4
+ "openai.gpt-5.4": true,
5
+ "openai.gpt-5.5": true,
6
+ "openai.gpt-5.6-luna": true,
7
+ "openai.gpt-5.6-sol": true,
8
+ "openai.gpt-5.6-terra": true,
9
+ };
10
+
11
+ /**
12
+ * Remove models.dev rows that OMP cannot route successfully.
13
+ *
14
+ * Generation and runtime refresh share this policy so a live catalog cannot
15
+ * reintroduce selectors deliberately excluded from the bundled catalog.
16
+ */
17
+ export function filterModelsDevCatalogRows<TApi extends Api>(models: readonly ModelSpec<TApi>[]): ModelSpec<TApi>[] {
18
+ return models.filter(model => {
19
+ if (model.provider === "amazon-bedrock") {
20
+ // AWS does not document the jp. Opus 5 profile, and the openai.gpt-5.x
21
+ // rows are Mantle-only ids that Bedrock rejects or misroutes.
22
+ return model.id !== "jp.anthropic.claude-opus-5" && !BEDROCK_MANTLE_OPENAI_MODEL_IDS[model.id];
23
+ }
24
+ if (model.provider === "zai") {
25
+ // [1m] is a Claude Code selector convention, not an inference id.
26
+ return !model.id.endsWith("[1m]");
27
+ }
28
+ if (model.provider === "fireworks" || model.provider === "firepass") {
29
+ // Control-plane resource ids are not accepted by the inference API.
30
+ return !model.id.startsWith("accounts/fireworks/");
31
+ }
32
+ if (model.provider === "xiaomi" || model.provider.startsWith("xiaomi-token-plan-")) {
33
+ // Text-chat transports cannot serve Xiaomi's audio-only models.
34
+ return !model.id.includes("-tts") && !model.id.includes("-asr");
35
+ }
36
+ return true;
37
+ });
38
+ }
@@ -21,7 +21,7 @@ import {
21
21
  isReasoningGlmModelId,
22
22
  } from "../identity/family";
23
23
  import { resolveModelReference } from "../identity/reference";
24
- import type { ModelManagerOptions } from "../model-manager";
24
+ import type { ModelManagerOptions, ModelsDevFallback } from "../model-manager";
25
25
  import { type GeneratedProvider, getBundledModels } from "../models";
26
26
  import { OPENAI_GPT_56_CYBER_STANDARD_COST, OPENAI_GPT_56_SOL_STANDARD_COST } from "../openai-pricing";
27
27
  import type {
@@ -47,6 +47,7 @@ import {
47
47
  import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references";
48
48
  import { getDefaultModelDiscoveryBaseUrl, resolveModelCacheProviderId } from "./cache-provider-id";
49
49
  import type { ModelManagerConfig } from "./descriptor-types";
50
+ import { filterModelsDevCatalogRows } from "./models-dev-policies";
50
51
 
51
52
  const MODELS_DEV_URL = "https://catalog.stencil.so/models.json.zstd";
52
53
 
@@ -114,17 +115,40 @@ function toInputCapabilities(value: unknown): ("text" | "image")[] {
114
115
  }
115
116
 
116
117
  /**
117
- * Process-wide catalog session: the first call downloads the payload (the one
118
- * request the server logs); later calls revalidate with `If-None-Match` and
119
- * reuse the decoded payload on `304`. Failure after a successful load falls
120
- * back to the session copy.
118
+ * Catalog sessions are scoped to the fetch implementation that owns their
119
+ * network and authentication context. Callers sharing one fetch reuse the same
120
+ * conditional request state; isolated registries cannot observe each other's
121
+ * payloads, ETags, or in-flight requests.
121
122
  */
122
- const catalogSession: {
123
+ interface CatalogSession {
123
124
  inflight: Promise<unknown> | null;
124
125
  payload: unknown;
125
126
  etag: string | null;
126
127
  hasPayload: boolean;
127
- } = { inflight: null, payload: undefined, etag: null, hasPayload: false };
128
+ }
129
+
130
+ const defaultCatalogSession: CatalogSession = { inflight: null, payload: undefined, etag: null, hasPayload: false };
131
+ const catalogSessionsByFetch = new WeakMap<FetchImpl, CatalogSession>();
132
+
133
+ function getCatalogSession(fetchImpl: FetchImpl | undefined): CatalogSession {
134
+ if (!fetchImpl) return defaultCatalogSession;
135
+ const existing = catalogSessionsByFetch.get(fetchImpl);
136
+ if (existing) return existing;
137
+ const created: CatalogSession = { inflight: null, payload: undefined, etag: null, hasPayload: false };
138
+ catalogSessionsByFetch.set(fetchImpl, created);
139
+ return created;
140
+ }
141
+
142
+ function waitForCatalogRequest<T>(request: Promise<T>, signal?: AbortSignal): Promise<T> {
143
+ if (!signal) return request;
144
+ if (signal.aborted) return Promise.reject(signal.reason);
145
+ const aborted = Promise.withResolvers<never>();
146
+ const rejectAborted = () => aborted.reject(signal.reason);
147
+ signal.addEventListener("abort", rejectAborted, { once: true });
148
+ return Promise.race([request, aborted.promise]).finally(() => {
149
+ signal.removeEventListener("abort", rejectAborted);
150
+ });
151
+ }
128
152
 
129
153
  const CATALOG_USER_AGENT = USER_AGENT;
130
154
 
@@ -134,52 +158,66 @@ const CATALOG_USER_AGENT = USER_AGENT;
134
158
  * The frame magic is sniffed rather than trusting content-type so plain-JSON
135
159
  * responses (test stubs, fallback mirrors) parse identically.
136
160
  *
137
- * Fetched fully once per process: concurrent callers share the in-flight
138
- * request, repeat callers send a conditional GET that the server answers
139
- * (and deliberately does not log) with `304`.
161
+ * Fetched fully once per fetch context: concurrent callers sharing a fetch
162
+ * implementation reuse one transport request, while repeat callers send a
163
+ * conditional GET that the server answers with `304`. Each subscriber may stop
164
+ * waiting independently; the shared transport retains its own hard deadline.
165
+ * Transient failures reuse the last in-memory payload for callers that only
166
+ * need best-effort metadata.
140
167
  */
141
168
  export function fetchWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise<unknown> {
142
- if (!catalogSession.inflight) {
143
- catalogSession.inflight = fetchCatalogPayload(fetchImpl ?? discoveryFetch(), signal).finally(() => {
144
- catalogSession.inflight = null;
169
+ const session = getCatalogSession(fetchImpl);
170
+ return fetchRevalidatedWellKnownModels(fetchImpl, signal).catch(error => {
171
+ if (session.hasPayload) return session.payload;
172
+ throw error;
173
+ });
174
+ }
175
+
176
+ function fetchRevalidatedWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise<unknown> {
177
+ const session = getCatalogSession(fetchImpl);
178
+ if (!session.inflight) {
179
+ session.inflight = withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, transportSignal =>
180
+ fetchCatalogPayload(fetchImpl ?? discoveryFetch(), session, transportSignal),
181
+ ).finally(() => {
182
+ session.inflight = null;
145
183
  });
146
184
  }
147
- return catalogSession.inflight;
185
+ return waitForCatalogRequest(session.inflight, signal);
148
186
  }
149
187
 
150
- async function fetchCatalogPayload(fetchImpl: FetchImpl, signal?: AbortSignal): Promise<unknown> {
188
+ function fetchRevalidatedWellKnownModelsWithTimeout(
189
+ fetchImpl?: FetchImpl,
190
+ timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
191
+ ): Promise<unknown> {
192
+ return withCatalogDiscoveryTimeout(timeoutMs, signal => fetchRevalidatedWellKnownModels(fetchImpl, signal));
193
+ }
194
+
195
+ async function fetchCatalogPayload(
196
+ fetchImpl: FetchImpl,
197
+ session: CatalogSession,
198
+ signal?: AbortSignal,
199
+ ): Promise<unknown> {
151
200
  const headers: Record<string, string> = {
152
201
  Accept: "application/zstd, application/json",
153
202
  "User-Agent": CATALOG_USER_AGENT,
154
203
  };
155
- if (catalogSession.hasPayload && catalogSession.etag) {
156
- headers["If-None-Match"] = catalogSession.etag;
157
- }
158
- let response: Response;
159
- try {
160
- response = await fetchImpl(MODELS_DEV_URL, { method: "GET", headers, signal });
161
- } catch (error) {
162
- if (catalogSession.hasPayload) {
163
- return catalogSession.payload;
164
- }
165
- throw error;
204
+ if (session.hasPayload && session.etag) {
205
+ headers["If-None-Match"] = session.etag;
166
206
  }
167
- if (response.status === 304 && catalogSession.hasPayload) {
168
- return catalogSession.payload;
207
+ const response = await fetchImpl(MODELS_DEV_URL, { method: "GET", headers, signal });
208
+ if (response.status === 304 && session.hasPayload) {
209
+ return session.payload;
169
210
  }
170
211
  if (!response.ok) {
171
- if (catalogSession.hasPayload) {
172
- return catalogSession.payload;
173
- }
174
212
  throw new Error(`models catalog fetch failed: ${response.status}`);
175
213
  }
176
214
  const bytes = new Uint8Array(await response.arrayBuffer());
177
215
  const isZstd = bytes.length >= 4 && new DataView(bytes.buffer, bytes.byteOffset).getUint32(0, true) === ZSTD_MAGIC;
178
216
  const text = new TextDecoder().decode(isZstd ? await Bun.zstdDecompress(bytes) : bytes);
179
217
  const payload: unknown = JSON.parse(text);
180
- catalogSession.payload = payload;
181
- catalogSession.etag = response.headers.get("etag");
182
- catalogSession.hasPayload = true;
218
+ session.payload = payload;
219
+ session.etag = response.headers.get("etag");
220
+ session.hasPayload = true;
183
221
  return payload;
184
222
  }
185
223
 
@@ -2852,7 +2890,7 @@ function openCodeModelManagerOptions(
2852
2890
  // by the 2h cache TTL instead.
2853
2891
  dropCachedModelIdsOnStaticMismatch: Object.keys(apiOverrides),
2854
2892
  modelsDev: {
2855
- fetch: () => fetchWellKnownModels(config?.fetch),
2893
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch),
2856
2894
  map: payload => {
2857
2895
  if (!isRecord(payload)) return [];
2858
2896
  return mapModelsDevToModels(payload, OPENCODE_MODELS_DEV_DESCRIPTORS)
@@ -5054,6 +5092,7 @@ type LiteLLMRichEndpointModel<TApi extends Api> = {
5054
5092
  hasToolMetadata: boolean;
5055
5093
  hasSupportedOpenAIParams: boolean;
5056
5094
  hasCost: boolean;
5095
+ reportedCost: Partial<ModelSpec<Api>["cost"]>;
5057
5096
  };
5058
5097
  type LiteLLMRichEndpointFailure = {
5059
5098
  endpoint: string;
@@ -5178,30 +5217,36 @@ function getLiteLLMParams(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry |
5178
5217
  function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unknown {
5179
5218
  return entry[key] ?? getLiteLLMModelInfo(entry)?.[key];
5180
5219
  }
5181
-
5182
- /** Per-million USD cost from a `*_per_token` LiteLLM field, or `undefined` when absent/non-positive. */
5220
+ /** Per-million USD cost from a positive `*_per_token` LiteLLM field. */
5183
5221
  function getLiteLLMPerMillionCost(entry: LiteLLMRichModelEntry, key: string): number | undefined {
5184
5222
  const perToken = toNumber(getLiteLLMMetadataValue(entry, key));
5185
5223
  return perToken !== undefined && perToken > 0 ? perToken * 1_000_000 : undefined;
5186
5224
  }
5187
5225
 
5188
- /**
5189
- * Map LiteLLM's per-token pricing (`input_cost_per_token`, `output_cost_per_token`,
5190
- * cache costs) onto {@link ModelSpec.cost} in $/million tokens. Returns `undefined`
5191
- * when LiteLLM reports neither an input nor an output price so callers keep the
5192
- * bundled reference cost.
5193
- */
5194
- function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec<Api>["cost"] | undefined {
5226
+ /** Map positive LiteLLM per-token prices onto their per-million cost fields. */
5227
+ function getLiteLLMReportedCost(entry: LiteLLMRichModelEntry): Partial<ModelSpec<Api>["cost"]> {
5195
5228
  const input = getLiteLLMPerMillionCost(entry, "input_cost_per_token");
5196
5229
  const output = getLiteLLMPerMillionCost(entry, "output_cost_per_token");
5197
- if (input === undefined && output === undefined) {
5230
+ const cacheRead = getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost");
5231
+ const cacheWrite = getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost");
5232
+ return {
5233
+ ...(input !== undefined ? { input } : {}),
5234
+ ...(output !== undefined ? { output } : {}),
5235
+ ...(cacheRead !== undefined ? { cacheRead } : {}),
5236
+ ...(cacheWrite !== undefined ? { cacheWrite } : {}),
5237
+ };
5238
+ }
5239
+
5240
+ function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec<Api>["cost"] | undefined {
5241
+ const cost = getLiteLLMReportedCost(entry);
5242
+ if (cost.input === undefined && cost.output === undefined) {
5198
5243
  return undefined;
5199
5244
  }
5200
5245
  return {
5201
- input: input ?? 0,
5202
- output: output ?? 0,
5203
- cacheRead: getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost") ?? 0,
5204
- cacheWrite: getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost") ?? 0,
5246
+ input: cost.input ?? 0,
5247
+ output: cost.output ?? 0,
5248
+ cacheRead: cost.cacheRead ?? 0,
5249
+ cacheWrite: cost.cacheWrite ?? 0,
5205
5250
  };
5206
5251
  }
5207
5252
 
@@ -5401,13 +5446,23 @@ function mergeLiteLLMRichEndpointModels<TApi extends Api>(
5401
5446
  maxTokens: next.hasMaxTokens ? next.model.maxTokens : existing.model.maxTokens,
5402
5447
  input: next.supportsVision === true || next.supportsVision === false ? next.model.input : existing.model.input,
5403
5448
  reasoning: typeof next.supportsReasoning === "boolean" ? next.model.reasoning : existing.model.reasoning,
5404
- cost: next.hasCost ? next.model.cost : existing.model.cost,
5449
+ cost: { ...existing.model.cost, ...existing.reportedCost, ...next.reportedCost },
5405
5450
  compat: next.hasSupportedOpenAIParams ? next.model.compat : existing.model.compat,
5406
5451
  };
5407
5452
  if (next.hasToolMetadata) {
5408
5453
  model.supportsTools = next.model.supportsTools;
5409
5454
  }
5410
- return { ...next, apiRoute, model };
5455
+ return {
5456
+ ...next,
5457
+ apiRoute,
5458
+ model,
5459
+ reportedCost: { ...existing.reportedCost, ...next.reportedCost },
5460
+ hasContextWindow: existing.hasContextWindow || next.hasContextWindow,
5461
+ hasMaxTokens: existing.hasMaxTokens || next.hasMaxTokens,
5462
+ hasToolMetadata: existing.hasToolMetadata || next.hasToolMetadata,
5463
+ hasSupportedOpenAIParams: existing.hasSupportedOpenAIParams || next.hasSupportedOpenAIParams,
5464
+ hasCost: existing.hasCost || next.hasCost,
5465
+ };
5411
5466
  }
5412
5467
 
5413
5468
  async function fetchLiteLLMRichEndpoint<TApi extends Api>(
@@ -5469,6 +5524,7 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
5469
5524
  supportedOpenAIParams !== undefined,
5470
5525
  hasSupportedOpenAIParams: supportedOpenAIParams !== undefined,
5471
5526
  hasCost: getLiteLLMCost(entry) !== undefined,
5527
+ reportedCost: getLiteLLMReportedCost(entry),
5472
5528
  };
5473
5529
  const existing = deduped.get(model.id);
5474
5530
  deduped.set(model.id, existing ? mergeLiteLLMRichEndpointModels(existing, next) : next);
@@ -5530,7 +5586,12 @@ async function fetchLiteLLMRichModelsInternal<TApi extends Api>(
5530
5586
  for (const entry of deduped.values()) {
5531
5587
  if (
5532
5588
  (entry.supportsVision !== true && entry.supportsVision !== false) ||
5533
- (options.resolveApi !== undefined && entry.apiRoute === "unknown")
5589
+ (options.resolveApi !== undefined && entry.apiRoute === "unknown") ||
5590
+ (Object.keys(entry.reportedCost).length > 0 &&
5591
+ (entry.reportedCost.input === undefined ||
5592
+ entry.reportedCost.output === undefined ||
5593
+ entry.reportedCost.cacheRead === undefined ||
5594
+ entry.reportedCost.cacheWrite === undefined))
5534
5595
  ) {
5535
5596
  needsMoreMetadata = true;
5536
5597
  break;
@@ -5567,12 +5628,12 @@ export function litellmModelManagerOptions(config?: LiteLLMModelManagerConfig):
5567
5628
  const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("litellm")!;
5568
5629
  return {
5569
5630
  providerId: "litellm",
5570
- // rich-v6 invalidates rows cached before OpenAI models moved to Responses.
5571
- // Earlier versions added bundled reference fallback, continued discovery
5572
- // past incomplete `/model_group/info`, stripped reseller usage suffixes,
5573
- // filtered placeholder-only `all-team-models` rows, and mapped rich pricing.
5574
- // Bump the version whenever the mappers below change, or warm authoritative
5575
- // caches keep serving pre-change rows for the full TTL.
5631
+ // rich-v7 invalidates rows cached before discovery continued past endpoints
5632
+ // that omitted cache pricing. Earlier versions added bundled reference fallback,
5633
+ // moved OpenAI models to Responses, continued past incomplete vision and API
5634
+ // metadata, stripped reseller usage suffixes, filtered placeholder rows, and
5635
+ // mapped rich pricing. Bump the version whenever these mappers change, or warm
5636
+ // authoritative caches keep serving pre-change rows for the full TTL.
5576
5637
  cacheProviderId: resolveModelCacheProviderId("litellm", { baseUrl }),
5577
5638
  // litellm is a local-only proxy and is never bundled in models.json (that
5578
5639
  // would leak the machine's localhost catalog). Prefer the proxy's richer
@@ -6095,7 +6156,7 @@ export function anthropicModelManagerOptions(
6095
6156
  return {
6096
6157
  providerId: "anthropic",
6097
6158
  modelsDev: {
6098
- fetch: () => fetchWellKnownModels(config?.fetch),
6159
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch),
6099
6160
  map: payload => mapAnthropicModelsDev(payload, baseUrl),
6100
6161
  },
6101
6162
  ...(apiKey && {
@@ -6767,3 +6828,42 @@ export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescript
6767
6828
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS,
6768
6829
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED,
6769
6830
  ];
6831
+
6832
+ const MODELS_DEV_DESCRIPTORS_BY_PROVIDER: Record<string, ModelsDevProviderDescriptor[]> = Object.create(null);
6833
+ for (const descriptor of MODELS_DEV_PROVIDER_DESCRIPTORS) {
6834
+ const providerDescriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId];
6835
+ if (providerDescriptors) {
6836
+ providerDescriptors.push(descriptor);
6837
+ } else {
6838
+ MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId] = [descriptor];
6839
+ }
6840
+ }
6841
+
6842
+ /** Providers whose bundled catalog can receive additive models.dev updates at runtime. */
6843
+ export const MODELS_DEV_CATALOG_PROVIDER_IDS: readonly string[] = Object.freeze(
6844
+ Object.keys(MODELS_DEV_DESCRIPTORS_BY_PROVIDER),
6845
+ );
6846
+
6847
+ /**
6848
+ * Build the shared models.dev fallback for one known provider.
6849
+ *
6850
+ * Provider managers sharing one fetch implementation reuse its conditional
6851
+ * catalog session. Each mapped provider slice is persisted independently so
6852
+ * startup can restore it without parsing the full catalog.
6853
+ *
6854
+ * `timeoutMs` bounds the catalog request. It is configurable for callers with a
6855
+ * stricter startup budget and for deterministic timeout tests.
6856
+ */
6857
+ export function modelsDevCatalogFallback(
6858
+ providerId: string,
6859
+ fetchImpl?: FetchImpl,
6860
+ timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
6861
+ ): ModelsDevFallback<Api> | undefined {
6862
+ const descriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId];
6863
+ if (!descriptors) return undefined;
6864
+ return {
6865
+ additiveOnly: true,
6866
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(fetchImpl, timeoutMs),
6867
+ map: payload => (isRecord(payload) ? filterModelsDevCatalogRows(mapModelsDevToModels(payload, descriptors)) : []),
6868
+ };
6869
+ }