@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 +10 -0
- package/dist/types/model-manager.d.ts +20 -4
- package/dist/types/provider-models/models-dev-policies.d.ts +8 -0
- package/dist/types/provider-models/openai-compat.d.ts +20 -4
- package/package.json +4 -4
- package/src/model-manager.ts +112 -71
- package/src/provider-models/cache-provider-id.ts +1 -1
- package/src/provider-models/models-dev-policies.ts +38 -0
- package/src/provider-models/openai-compat.ts +159 -59
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
|
|
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
|
|
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 ->
|
|
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
|
|
36
|
-
* request, repeat callers send a
|
|
37
|
-
*
|
|
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.
|
|
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.
|
|
38
|
-
"@oh-my-pi/pi-utils": "18.0.
|
|
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.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.0.7",
|
|
42
42
|
"@types/bun": "^1.3.14"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
package/src/model-manager.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 ->
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
235
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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).
|
|
266
|
-
//
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
317
|
+
authoritativeDynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : mergedModels,
|
|
272
318
|
);
|
|
273
|
-
const
|
|
319
|
+
const resolutionAuthoritative = !hasRemoteFetcher || remoteResolutionComplete || shouldUseFreshCacheAsAuthoritative;
|
|
320
|
+
const remoteUpdatedAt = anyRemoteFetchSucceeded ? now() : undefined;
|
|
274
321
|
if (shouldFetchFromNetwork) {
|
|
275
|
-
if (
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
325
|
+
remoteUpdatedAt!,
|
|
326
|
+
models,
|
|
327
|
+
cacheAuthoritative,
|
|
285
328
|
staticFingerprint,
|
|
286
329
|
dbPath,
|
|
287
330
|
staticModels,
|
|
288
331
|
restorableHeaderFallback,
|
|
289
332
|
);
|
|
290
333
|
} else {
|
|
291
|
-
//
|
|
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: !
|
|
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
|
-
|
|
472
|
-
|
|
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:${
|
|
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-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
|
|
123
|
+
interface CatalogSession {
|
|
123
124
|
inflight: Promise<unknown> | null;
|
|
124
125
|
payload: unknown;
|
|
125
126
|
etag: string | null;
|
|
126
127
|
hasPayload: boolean;
|
|
127
|
-
}
|
|
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
|
|
138
|
-
* request, repeat callers send a
|
|
139
|
-
*
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
185
|
+
return waitForCatalogRequest(session.inflight, signal);
|
|
148
186
|
}
|
|
149
187
|
|
|
150
|
-
|
|
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 (
|
|
156
|
-
headers["If-None-Match"] =
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
5204
|
-
cacheWrite:
|
|
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:
|
|
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 {
|
|
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-
|
|
5571
|
-
// Earlier versions added bundled reference fallback,
|
|
5572
|
-
// past incomplete
|
|
5573
|
-
// filtered placeholder
|
|
5574
|
-
// Bump the version whenever
|
|
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: () =>
|
|
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
|
+
}
|