@linxiraos/pi-catalog 1.1.5 → 1.1.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.
@@ -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;
@@ -429,11 +429,14 @@ function getModelDefinedEfforts<TApi extends Api>(
429
429
  if (isDeepseekV4FlashModelId(spec.id)) {
430
430
  return LOW_HIGH_MAX_REASONING_EFFORTS;
431
431
  }
432
- if (bareModelId(spec.id).toLowerCase().includes("deepseek-v4")) {
432
+ const bareId = bareModelId(spec.id).toLowerCase();
433
+ if (bareId.includes("deepseek-v4")) {
433
434
  if (!isOpenRouterThinkingFormat(compat)) {
434
435
  return LOW_HIGH_MAX_REASONING_EFFORTS;
435
436
  }
436
- return bareModelId(spec.id).toLowerCase() === "deepseek-v4-pro-0813"
437
+ // Route suffixes are OpenRouter-specific and not part of the model identity.
438
+ const openRouterBaseId = bareId.replace(/:[^:]+$/, "");
439
+ return openRouterBaseId === "deepseek-v4-pro-0813"
437
440
  ? LOW_HIGH_MAX_REASONING_EFFORTS
438
441
  : HIGH_ONLY_REASONING_EFFORTS;
439
442
  }
@@ -777,6 +780,14 @@ function inferThinkingControlMode<TApi extends Api>(
777
780
  return "anthropic-budget-effort";
778
781
  }
779
782
  }
783
+ // Bedrock serves the GPT-5.x models through OpenAI's own request
784
+ // schema, which rejects Anthropic's budget block outright:
785
+ // `unknown_parameter: 'thinking'`. It takes `reasoning.effort`
786
+ // instead. gpt-oss parses as `unknown` (no `gpt-<digits>`), so it
787
+ // keeps the budget path it ships with today.
788
+ if (parsedModel.family === "openai") {
789
+ return "effort";
790
+ }
780
791
  return "budget";
781
792
 
782
793
  default: