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

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.8] - 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the thinking control mode for OpenAI models served over Bedrock Converse (`global.openai.gpt-5.6-luna`, `-sol`, `-terra`), which are now classified as `effort` rather than `budget` so requests use OpenAI's reasoning schema.
10
+ - Fixed LiteLLM discovery leaking a colliding bundled model's provider-specific transport onto custom endpoints: a discovered alias (e.g. `kimi-k3`) matching a bundled Fireworks model no longer inherits that model's wire-id transform, which had caused requests to POST a model id the endpoint never advertised and return HTTP 400 ([#9938](https://github.com/can1357/oh-my-pi/issues/9938)).
11
+
12
+ ## [18.0.7] - 2026-08-26
13
+
14
+ ### Added
15
+
16
+ - 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.
17
+
18
+ ### Fixed
19
+
20
+ - Fixed LiteLLM model discovery so model pricing is correctly populated when pricing information is provided by a later metadata endpoint.
21
+
5
22
  ## [18.0.5] - 2026-08-25
6
23
 
7
24
  ### 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.8",
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.8",
38
+ "@oh-my-pi/pi-utils": "18.0.8"
39
39
  },
40
40
  "devDependencies": {
41
- "@oh-my-pi/pi-ai": "18.0.6",
41
+ "@oh-my-pi/pi-ai": "18.0.8",
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;
@@ -777,6 +777,14 @@ function inferThinkingControlMode<TApi extends Api>(
777
777
  return "anthropic-budget-effort";
778
778
  }
779
779
  }
780
+ // Bedrock serves the GPT-5.x models through OpenAI's own request
781
+ // schema, which rejects Anthropic's budget block outright:
782
+ // `unknown_parameter: 'thinking'`. It takes `reasoning.effort`
783
+ // instead. gpt-oss parses as `unknown` (no `gpt-<digits>`), so it
784
+ // keeps the budget path it ships with today.
785
+ if (parsedModel.family === "openai") {
786
+ return "effort";
787
+ }
780
788
  return "budget";
781
789
 
782
790
  default:
package/src/models.json CHANGED
@@ -28688,7 +28688,7 @@
28688
28688
  "contextWindow": 1050000,
28689
28689
  "maxTokens": 128000,
28690
28690
  "thinking": {
28691
- "mode": "budget",
28691
+ "mode": "effort",
28692
28692
  "efforts": [
28693
28693
  "low",
28694
28694
  "medium",
@@ -28727,7 +28727,7 @@
28727
28727
  "contextWindow": 1050000,
28728
28728
  "maxTokens": 128000,
28729
28729
  "thinking": {
28730
- "mode": "budget",
28730
+ "mode": "effort",
28731
28731
  "efforts": [
28732
28732
  "low",
28733
28733
  "medium",
@@ -28766,7 +28766,7 @@
28766
28766
  "contextWindow": 1050000,
28767
28767
  "maxTokens": 128000,
28768
28768
  "thinking": {
28769
- "mode": "budget",
28769
+ "mode": "effort",
28770
28770
  "efforts": [
28771
28771
  "low",
28772
28772
  "medium",
@@ -60,7 +60,10 @@ 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
+ // rich-v8 invalidates rows whose `compatConfig` retained a colliding
64
+ // bundled model's provider-specific transport (e.g. Fireworks
65
+ // `wireModelIdMode`) before that leak was fixed (issue #9938).
66
+ return `litellm:rich-v8:${Bun.hash(baseUrl).toString(36)}`;
64
67
  }
65
68
  case "opencode-go":
66
69
  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
 
@@ -5352,12 +5397,29 @@ function mapLiteLLMRichEntry<TApi extends Api>(
5352
5397
  ["tools", "tool_choice", "functions", "function_call"].includes(param),
5353
5398
  )
5354
5399
  : reference?.supportsTools;
5400
+ // Enrich from the bundled reference with provider-INDEPENDENT reasoning
5401
+ // hints only. The reference is resolved against the global bundled catalog,
5402
+ // so a custom endpoint exposing an alias that collides with a bundled model
5403
+ // (a LiteLLM proxy serving `kimi-k3`, which matches Fireworks' bundled
5404
+ // `kimi-k3`) must not inherit that provider's transport compat. Spreading
5405
+ // the resolved `reference.compat` wholesale leaked `wireModelIdMode`,
5406
+ // `toolSchemaFlavor`, `thinkingFormat`, etc. across the provider boundary —
5407
+ // rewriting the wire id to `accounts/fireworks/models/kimi-k3` for a
5408
+ // non-Fireworks endpoint (issue #9938). `buildModel` re-derives every
5409
+ // transport field from the discovered provider and model id, so only the
5410
+ // effort vocabulary flows through here. Mirrors `discoverOpenAIModelsList`.
5411
+ const referenceCompat = reference?.compat as OpenAICompat | undefined;
5355
5412
  const compat: OpenAICompat = {
5356
- ...(reference?.compat ?? {}),
5357
5413
  supportsStore: false,
5358
5414
  supportsDeveloperRole: false,
5359
5415
  ...(supportedOpenAIParams !== undefined
5360
5416
  ? { supportsReasoningEffort: supportedOpenAIParams.includes("reasoning_effort") }
5417
+ : referenceCompat?.supportsReasoningEffort !== undefined
5418
+ ? { supportsReasoningEffort: referenceCompat.supportsReasoningEffort }
5419
+ : {}),
5420
+ ...(referenceCompat?.reasoningEffortMap ? { reasoningEffortMap: referenceCompat.reasoningEffortMap } : {}),
5421
+ ...(referenceCompat?.omitReasoningEffort !== undefined
5422
+ ? { omitReasoningEffort: referenceCompat.omitReasoningEffort }
5361
5423
  : {}),
5362
5424
  };
5363
5425
  return {
@@ -5401,13 +5463,23 @@ function mergeLiteLLMRichEndpointModels<TApi extends Api>(
5401
5463
  maxTokens: next.hasMaxTokens ? next.model.maxTokens : existing.model.maxTokens,
5402
5464
  input: next.supportsVision === true || next.supportsVision === false ? next.model.input : existing.model.input,
5403
5465
  reasoning: typeof next.supportsReasoning === "boolean" ? next.model.reasoning : existing.model.reasoning,
5404
- cost: next.hasCost ? next.model.cost : existing.model.cost,
5466
+ cost: { ...existing.model.cost, ...existing.reportedCost, ...next.reportedCost },
5405
5467
  compat: next.hasSupportedOpenAIParams ? next.model.compat : existing.model.compat,
5406
5468
  };
5407
5469
  if (next.hasToolMetadata) {
5408
5470
  model.supportsTools = next.model.supportsTools;
5409
5471
  }
5410
- return { ...next, apiRoute, model };
5472
+ return {
5473
+ ...next,
5474
+ apiRoute,
5475
+ model,
5476
+ reportedCost: { ...existing.reportedCost, ...next.reportedCost },
5477
+ hasContextWindow: existing.hasContextWindow || next.hasContextWindow,
5478
+ hasMaxTokens: existing.hasMaxTokens || next.hasMaxTokens,
5479
+ hasToolMetadata: existing.hasToolMetadata || next.hasToolMetadata,
5480
+ hasSupportedOpenAIParams: existing.hasSupportedOpenAIParams || next.hasSupportedOpenAIParams,
5481
+ hasCost: existing.hasCost || next.hasCost,
5482
+ };
5411
5483
  }
5412
5484
 
5413
5485
  async function fetchLiteLLMRichEndpoint<TApi extends Api>(
@@ -5469,6 +5541,7 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
5469
5541
  supportedOpenAIParams !== undefined,
5470
5542
  hasSupportedOpenAIParams: supportedOpenAIParams !== undefined,
5471
5543
  hasCost: getLiteLLMCost(entry) !== undefined,
5544
+ reportedCost: getLiteLLMReportedCost(entry),
5472
5545
  };
5473
5546
  const existing = deduped.get(model.id);
5474
5547
  deduped.set(model.id, existing ? mergeLiteLLMRichEndpointModels(existing, next) : next);
@@ -5530,7 +5603,12 @@ async function fetchLiteLLMRichModelsInternal<TApi extends Api>(
5530
5603
  for (const entry of deduped.values()) {
5531
5604
  if (
5532
5605
  (entry.supportsVision !== true && entry.supportsVision !== false) ||
5533
- (options.resolveApi !== undefined && entry.apiRoute === "unknown")
5606
+ (options.resolveApi !== undefined && entry.apiRoute === "unknown") ||
5607
+ (Object.keys(entry.reportedCost).length > 0 &&
5608
+ (entry.reportedCost.input === undefined ||
5609
+ entry.reportedCost.output === undefined ||
5610
+ entry.reportedCost.cacheRead === undefined ||
5611
+ entry.reportedCost.cacheWrite === undefined))
5534
5612
  ) {
5535
5613
  needsMoreMetadata = true;
5536
5614
  break;
@@ -5567,12 +5645,14 @@ export function litellmModelManagerOptions(config?: LiteLLMModelManagerConfig):
5567
5645
  const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("litellm")!;
5568
5646
  return {
5569
5647
  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.
5648
+ // rich-v8 invalidates rows whose `compatConfig` retained a colliding
5649
+ // bundled model's provider-specific transport (e.g. Fireworks
5650
+ // `wireModelIdMode`) before that leak was fixed. Earlier versions added
5651
+ // bundled reference fallback, moved OpenAI models to Responses, continued
5652
+ // past incomplete vision/API metadata and endpoints omitting cache
5653
+ // pricing, stripped reseller usage suffixes, filtered placeholder rows,
5654
+ // and mapped rich pricing. Bump the version whenever these mappers change,
5655
+ // or warm authoritative caches keep serving pre-change rows for the full TTL.
5576
5656
  cacheProviderId: resolveModelCacheProviderId("litellm", { baseUrl }),
5577
5657
  // litellm is a local-only proxy and is never bundled in models.json (that
5578
5658
  // would leak the machine's localhost catalog). Prefer the proxy's richer
@@ -6095,7 +6175,7 @@ export function anthropicModelManagerOptions(
6095
6175
  return {
6096
6176
  providerId: "anthropic",
6097
6177
  modelsDev: {
6098
- fetch: () => fetchWellKnownModels(config?.fetch),
6178
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch),
6099
6179
  map: payload => mapAnthropicModelsDev(payload, baseUrl),
6100
6180
  },
6101
6181
  ...(apiKey && {
@@ -6767,3 +6847,42 @@ export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescript
6767
6847
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS,
6768
6848
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED,
6769
6849
  ];
6850
+
6851
+ const MODELS_DEV_DESCRIPTORS_BY_PROVIDER: Record<string, ModelsDevProviderDescriptor[]> = Object.create(null);
6852
+ for (const descriptor of MODELS_DEV_PROVIDER_DESCRIPTORS) {
6853
+ const providerDescriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId];
6854
+ if (providerDescriptors) {
6855
+ providerDescriptors.push(descriptor);
6856
+ } else {
6857
+ MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId] = [descriptor];
6858
+ }
6859
+ }
6860
+
6861
+ /** Providers whose bundled catalog can receive additive models.dev updates at runtime. */
6862
+ export const MODELS_DEV_CATALOG_PROVIDER_IDS: readonly string[] = Object.freeze(
6863
+ Object.keys(MODELS_DEV_DESCRIPTORS_BY_PROVIDER),
6864
+ );
6865
+
6866
+ /**
6867
+ * Build the shared models.dev fallback for one known provider.
6868
+ *
6869
+ * Provider managers sharing one fetch implementation reuse its conditional
6870
+ * catalog session. Each mapped provider slice is persisted independently so
6871
+ * startup can restore it without parsing the full catalog.
6872
+ *
6873
+ * `timeoutMs` bounds the catalog request. It is configurable for callers with a
6874
+ * stricter startup budget and for deterministic timeout tests.
6875
+ */
6876
+ export function modelsDevCatalogFallback(
6877
+ providerId: string,
6878
+ fetchImpl?: FetchImpl,
6879
+ timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
6880
+ ): ModelsDevFallback<Api> | undefined {
6881
+ const descriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId];
6882
+ if (!descriptors) return undefined;
6883
+ return {
6884
+ additiveOnly: true,
6885
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(fetchImpl, timeoutMs),
6886
+ map: payload => (isRecord(payload) ? filterModelsDevCatalogRows(mapModelsDevToModels(payload, descriptors)) : []),
6887
+ };
6888
+ }