@linxiraos/pi-catalog 1.1.4 → 1.1.6

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,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);
186
+ }
187
+
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));
148
193
  }
149
194
 
150
- async function fetchCatalogPayload(fetchImpl: FetchImpl, signal?: AbortSignal): Promise<unknown> {
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
 
@@ -1305,6 +1343,181 @@ export function novitaModelManagerOptions(
1305
1343
  });
1306
1344
  }
1307
1345
 
1346
+ // ---------------------------------------------------------------------------
1347
+ // 5.6 DeepInfra
1348
+ // ---------------------------------------------------------------------------
1349
+
1350
+ export const DEEPINFRA_BASE_URL = "https://api.deepinfra.com/v1/openai";
1351
+ /**
1352
+ * `filter=with_meta` attaches per-model `metadata` (limits, pricing, tags);
1353
+ * `sort_by=omp` asks DeepInfra to return models in omp-priority order
1354
+ * (earlier = better). The mapper does not stamp `priority` yet — see
1355
+ * `mapDeepinfraModel` — but the params are sent so discovery picks the
1356
+ * ordering up as soon as the server honors it.
1357
+ */
1358
+ const DEEPINFRA_MODELS_QUERY = "?filter=with_meta&sort_by=omp";
1359
+ const DEEPINFRA_EFFORTS = [Effort.Low, Effort.Medium, Effort.High] as const;
1360
+
1361
+ /** DeepInfra OpenAI-compatible discovery configuration. */
1362
+ export interface DeepinfraModelManagerConfig {
1363
+ apiKey?: string;
1364
+ baseUrl?: string;
1365
+ fetch?: FetchImpl;
1366
+ }
1367
+
1368
+ interface DeepinfraModelEntry {
1369
+ id?: unknown;
1370
+ metadata?: unknown;
1371
+ }
1372
+
1373
+ function deepinfraTags(metadata: Record<string, unknown>): readonly string[] {
1374
+ const tags = metadata.tags;
1375
+ return Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : [];
1376
+ }
1377
+
1378
+ /**
1379
+ * Map one DeepInfra catalog entry to a chat model spec. Non-`chat` entries
1380
+ * (`tts`, `stt`, `embed`, `image-gen`, `video-gen`) are dropped — those
1381
+ * surfaces are served by dedicated tool backends, not the chat catalog.
1382
+ * DeepInfra reports token prices in USD per 1M tokens — omp's `ModelCost`
1383
+ * unit, used verbatim. A bundled reference (when the generated catalog has
1384
+ * one) is spread first so compat/tooling metadata can contribute, but the
1385
+ * live metadata always wins for limits, pricing, and modalities.
1386
+ */
1387
+ function mapDeepinfraModel(
1388
+ entry: DeepinfraModelEntry,
1389
+ baseUrl: string,
1390
+ reference: ModelSpec<"openai-completions"> | undefined,
1391
+ ): ModelSpec<"openai-completions"> | null {
1392
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
1393
+ if (!id) {
1394
+ return null;
1395
+ }
1396
+ const metadata = isRecord(entry.metadata) ? entry.metadata : {};
1397
+ const tags = deepinfraTags(metadata);
1398
+ if (!tags.includes("chat")) {
1399
+ return null;
1400
+ }
1401
+ const pricing = isRecord(metadata.pricing) ? metadata.pricing : {};
1402
+ // `reasoning_effort` marks models whose effort dial is advertised. The
1403
+ // parameter itself is validated and accepted platform-wide on DeepInfra
1404
+ // (verified: 200 on effort-tagged, reasoning-only, and plain-chat models;
1405
+ // 422 only for out-of-enum values), so a bundled reference's effort ladder
1406
+ // surviving the `...reference` spread after a tag disappears is harmless —
1407
+ // the host ignores the dial rather than rejecting the request.
1408
+ const thinking: ThinkingConfig | undefined = tags.includes("reasoning_effort")
1409
+ ? { mode: "effort", efforts: [...DEEPINFRA_EFFORTS] }
1410
+ : undefined;
1411
+ const contextWindow = toPositiveNumber(metadata.context_length, reference?.contextWindow ?? null);
1412
+ // `metadata.max_tokens` mirrors `context_length` on every live row today —
1413
+ // it is the total token ceiling, not an output cap (the catalog exposes no
1414
+ // output-specific field). Stamping it into `maxTokens` would advertise
1415
+ // `output === context` for every model and default oversized `max_tokens`
1416
+ // onto models with smaller real output limits. Trust it only when it is
1417
+ // strictly below the context window (i.e. the API starts publishing a real
1418
+ // output cap); otherwise keep the bundled reference's cap (stencil.so fill
1419
+ // during generation) or leave the limit unknown so requests defer to the
1420
+ // server-side cap.
1421
+ const liveMaxTokens = toPositiveNumber(metadata.max_tokens, 0);
1422
+ const hasLiveOutputCap = contextWindow !== null && liveMaxTokens > 0 && liveMaxTokens < contextWindow;
1423
+ // A same-id reference carries the canonical deployment's output cap, which
1424
+ // can exceed the (smaller) window DeepInfra serves — e.g. a 500K cap against
1425
+ // a 256K context. An output cap above the context ceiling is meaningless, so
1426
+ // keep the fallback inside the window.
1427
+ const referenceMaxTokens = reference?.maxTokens ?? null;
1428
+ const maxTokens = hasLiveOutputCap
1429
+ ? liveMaxTokens
1430
+ : referenceMaxTokens !== null && contextWindow !== null
1431
+ ? Math.min(referenceMaxTokens, contextWindow)
1432
+ : referenceMaxTokens;
1433
+ return {
1434
+ ...reference,
1435
+ id,
1436
+ name: reference?.name ?? id,
1437
+ api: "openai-completions",
1438
+ provider: "deepinfra",
1439
+ baseUrl,
1440
+ reasoning: tags.includes("reasoning") || tags.includes("reasoning_effort"),
1441
+ ...(thinking ? { thinking } : {}),
1442
+ input: tags.includes("vision") || tags.includes("vlm") ? ["text", "image"] : ["text"],
1443
+ cost: {
1444
+ input: toPositiveNumber(pricing.input_tokens, 0),
1445
+ output: toPositiveNumber(pricing.output_tokens, 0),
1446
+ cacheRead: toPositiveNumber(pricing.cache_read_tokens, 0),
1447
+ cacheWrite: 0,
1448
+ },
1449
+ contextWindow,
1450
+ maxTokens,
1451
+ };
1452
+ }
1453
+
1454
+ /**
1455
+ * Bespoke fetch instead of `fetchOpenAICompatibleModels`: the shared helper
1456
+ * cannot carry the `filter`/`sort_by` query params and re-sorts results by id,
1457
+ * which would destroy DeepInfra's priority ordering once the server honors
1458
+ * `sort_by=omp`. Response order is preserved (dedupe keeps the first, i.e.
1459
+ * highest-priority, occurrence).
1460
+ */
1461
+ async function fetchDeepinfraModels(options: {
1462
+ baseUrl: string;
1463
+ apiKey?: string;
1464
+ fetch?: FetchImpl;
1465
+ references: Map<string, ModelSpec<"openai-completions">>;
1466
+ }): Promise<ModelSpec<"openai-completions">[] | null> {
1467
+ const headers: Record<string, string> = { Accept: "application/json" };
1468
+ if (options.apiKey) {
1469
+ headers.Authorization = `Bearer ${options.apiKey}`;
1470
+ }
1471
+ const fetchImpl = discoveryFetch(options.fetch);
1472
+ let payload: unknown;
1473
+ try {
1474
+ const response = await withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal =>
1475
+ fetchImpl(`${options.baseUrl}/models${DEEPINFRA_MODELS_QUERY}`, { method: "GET", headers, signal }),
1476
+ );
1477
+ if (!response.ok) {
1478
+ return null;
1479
+ }
1480
+ payload = await response.json();
1481
+ } catch {
1482
+ return null;
1483
+ }
1484
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
1485
+ return null;
1486
+ }
1487
+ const models: ModelSpec<"openai-completions">[] = [];
1488
+ const seen = new Set<string>();
1489
+ for (const entry of payload.data) {
1490
+ if (!isRecord(entry)) {
1491
+ continue;
1492
+ }
1493
+ const reference = typeof entry.id === "string" ? options.references.get(entry.id) : undefined;
1494
+ const mapped = mapDeepinfraModel(entry as DeepinfraModelEntry, options.baseUrl, reference);
1495
+ if (mapped && !seen.has(mapped.id)) {
1496
+ seen.add(mapped.id);
1497
+ models.push(mapped);
1498
+ }
1499
+ }
1500
+ return models;
1501
+ }
1502
+
1503
+ /**
1504
+ * Builds DeepInfra's model-discovery manager. The catalog endpoint is public,
1505
+ * so discovery (and keyless `gen:models` generation) works without an API key;
1506
+ * model availability at runtime is still gated on credentials by the registry.
1507
+ */
1508
+ export function deepinfraModelManagerOptions(
1509
+ config?: DeepinfraModelManagerConfig,
1510
+ ): ModelManagerOptions<"openai-completions"> {
1511
+ const apiKey = config?.apiKey;
1512
+ const baseUrl = (config?.baseUrl ?? DEEPINFRA_BASE_URL).replace(/\/$/, "");
1513
+ const references = createBundledReferenceMap<"openai-completions">("deepinfra");
1514
+ return {
1515
+ providerId: "deepinfra",
1516
+ dynamicModelsAuthoritative: true,
1517
+ fetchDynamicModels: () => fetchDeepinfraModels({ baseUrl, apiKey, fetch: config?.fetch, references }),
1518
+ };
1519
+ }
1520
+
1308
1521
  // ---------------------------------------------------------------------------
1309
1522
  // 6. xAI
1310
1523
  // ---------------------------------------------------------------------------
@@ -2610,6 +2823,9 @@ const OPENCODE_GO_API_ID_OVERRIDES: Readonly<Record<string, Api>> = {
2610
2823
  "qwen3.5-plus": "openai-completions",
2611
2824
  "qwen3.6-plus": "openai-completions",
2612
2825
  };
2826
+ // Runtime-discovered rows cached before model-identity corrections retain
2827
+ // stale capability metadata until the authoritative catalog TTL expires.
2828
+ const OPENCODE_CACHE_MIGRATION_MODEL_IDS = ["glm-5.3-flash"] as const;
2613
2829
 
2614
2830
  // Billing-variant suffixes the OpenCode gateways append to a base model id
2615
2831
  // without changing its transport (`deepseek-v4-flash-free`,
@@ -2670,14 +2886,12 @@ function openCodeModelManagerOptions(
2670
2886
  providerId,
2671
2887
  cacheProviderId: resolveModelCacheProviderId(providerId, { apiKey, baseUrl: discoveryBaseUrl }),
2672
2888
  dynamicModelsAuthoritative: true,
2673
- // The per-id API pins are cache identity: without this, rows cached
2674
- // before a pin was added keep the wrong endpoint until TTL expiry
2675
- // (#8957 — 17.3.7 caches held muse-spark-1.2[-contributor] on chat
2676
- // completions after the pin shipped). Sibling-catalog drift is bounded
2677
- // by the 2h cache TTL instead.
2678
- dropCachedModelIdsOnStaticMismatch: Object.keys(apiOverrides),
2889
+ // Per-id route pins and capability migrations are cache identity:
2890
+ // without this, rows cached before a correction keep the stale route or
2891
+ // thinking surface until TTL expiry (#8957, #9960).
2892
+ dropCachedModelIdsOnStaticMismatch: [...Object.keys(apiOverrides), ...OPENCODE_CACHE_MIGRATION_MODEL_IDS],
2679
2893
  modelsDev: {
2680
- fetch: () => fetchWellKnownModels(config?.fetch),
2894
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch),
2681
2895
  map: payload => {
2682
2896
  if (!isRecord(payload)) return [];
2683
2897
  return mapModelsDevToModels(payload, OPENCODE_MODELS_DEV_DESCRIPTORS)
@@ -4585,6 +4799,133 @@ export function aiandModelManagerOptions(config?: AiandModelManagerConfig): Mode
4585
4799
  };
4586
4800
  }
4587
4801
 
4802
+ // ---------------------------------------------------------------------------
4803
+ // 16.7 Yolo-Auto (yolo-auto.com)
4804
+ // ---------------------------------------------------------------------------
4805
+
4806
+ const YOLO_AUTO_BASE_URL = "https://yolo-auto.com/v1";
4807
+
4808
+ /**
4809
+ * Documented Yolo-Auto catalog (yolo-auto.com/docs, 2026-08) bundled so the
4810
+ * provider is usable when generation and first boot have no live key. The
4811
+ * flat-rate `/v1/models` response is authoritative once discovery runs.
4812
+ * The compat block mirrors the provider's documented wire surface: the API
4813
+ * speaks the generic chat template with `reasoning_effort` support and rejects
4814
+ * the `developer` role and `store` param.
4815
+ */
4816
+ export const YOLO_AUTO_STATIC_MODELS: readonly ModelSpec<"openai-completions">[] = [
4817
+ {
4818
+ id: "deepseek-flash-v4",
4819
+ name: "DeepSeek Flash V4",
4820
+ api: "openai-completions",
4821
+ provider: "yolo-auto",
4822
+ baseUrl: YOLO_AUTO_BASE_URL,
4823
+ reasoning: true,
4824
+ input: ["text", "image"],
4825
+ thinking: {
4826
+ mode: "effort",
4827
+ efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh, Effort.Max],
4828
+ effortMap: {
4829
+ [Effort.Minimal]: "low",
4830
+ [Effort.Low]: "low",
4831
+ [Effort.Medium]: "high",
4832
+ [Effort.High]: "high",
4833
+ [Effort.XHigh]: "max",
4834
+ [Effort.Max]: "max",
4835
+ },
4836
+ },
4837
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4838
+ contextWindow: 131_072,
4839
+ maxTokens: null,
4840
+ compat: {
4841
+ supportsDeveloperRole: false,
4842
+ supportsStore: false,
4843
+ supportsReasoningEffort: true,
4844
+ thinkingFormat: "chat-template",
4845
+ },
4846
+ },
4847
+ ];
4848
+
4849
+ export interface YoloAutoModelManagerConfig {
4850
+ apiKey?: string;
4851
+ baseUrl?: string;
4852
+ fetch?: FetchImpl;
4853
+ }
4854
+
4855
+ /**
4856
+ * Map a discovered entry onto its reference, then re-apply Yolo-Auto's
4857
+ * provider-wide wire constraints. A model that only exists in another
4858
+ * provider's bundle (e.g. `gpt-4o` served via the global reference index)
4859
+ * would otherwise inherit that provider's token pricing and `store`-capable
4860
+ * surface, which the flat-rate Yolo endpoint neither charges nor accepts.
4861
+ */
4862
+ function mapYoloAutoModel(
4863
+ entry: OpenAICompatibleModelRecord,
4864
+ defaults: ModelSpec<"openai-completions">,
4865
+ reference: ModelSpec<"openai-completions"> | undefined,
4866
+ ): ModelSpec<"openai-completions"> {
4867
+ const model = mapWithBundledReference(entry, defaults, reference);
4868
+ return {
4869
+ ...model,
4870
+ // Flat-rate and no-store are provider-wide: they must win whether the
4871
+ // reference came from the yolo bundle, the global index, or nowhere.
4872
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4873
+ compat: {
4874
+ ...model.compat,
4875
+ supportsStore: false,
4876
+ supportsDeveloperRole: false,
4877
+ },
4878
+ };
4879
+ }
4880
+ /**
4881
+ * Yolo-Auto model manager: OpenAI-compatible chat completions at the
4882
+ * flat-rate `deepseek-flash-v4` endpoint. Live `/v1/models` discovery replaces the
4883
+ * bundled seed once a key is stored or present in `YOLO_AUTO_API_KEY`. Models
4884
+ * the provider adds later inherit metadata (reasoning, thinking, context)
4885
+ * from the global bundled reference index, so new ids work without a per-id
4886
+ * code change.
4887
+ */
4888
+ export function yoloAutoModelManagerOptions(
4889
+ config?: YoloAutoModelManagerConfig,
4890
+ ): ModelManagerOptions<"openai-completions"> {
4891
+ // Curated static models take precedence over the previously generated
4892
+ // bundle so later corrections to the curated metadata are honored; bundle
4893
+ // rows keep ids that earlier credentialed runs discovered, and the global
4894
+ // index covers everything else.
4895
+ const references = new Map<string, ModelSpec<"openai-completions">>();
4896
+ const bundled = createBundledReferenceMap<"openai-completions">("yolo-auto");
4897
+ for (const model of bundled.values()) {
4898
+ references.set(model.id, model);
4899
+ }
4900
+ for (const model of YOLO_AUTO_STATIC_MODELS) {
4901
+ const previous = references.get(model.id);
4902
+ references.set(model.id, {
4903
+ ...previous,
4904
+ ...model,
4905
+ // Generation-filled fields the curated seed leaves null: keep the
4906
+ // canonical values baked by the previous regen (max-output cap,
4907
+ // derived thinking) instead of dropping them.
4908
+ maxTokens: model.maxTokens ?? previous?.maxTokens ?? null,
4909
+ thinking: model.thinking ?? previous?.thinking,
4910
+ });
4911
+ }
4912
+ const resolveReference = createReferenceResolver(() => references);
4913
+ return {
4914
+ ...createOpenAICompatibleModelManagerOptions({
4915
+ api: "openai-completions",
4916
+ providerId: "yolo-auto",
4917
+ defaultBaseUrl: YOLO_AUTO_BASE_URL,
4918
+ config,
4919
+ requireApiKey: true,
4920
+ mapModel: (entry, defaults, reference) =>
4921
+ mapYoloAutoModel(entry, defaults, resolveReference(defaults.id) ?? reference),
4922
+ dynamicModelsAuthoritative: true,
4923
+ }),
4924
+ };
4925
+ }
4926
+
4927
+ // ---------------------------------------------------------------------------
4928
+
4588
4929
  // ---------------------------------------------------------------------------
4589
4930
  // 17. Qwen Portal
4590
4931
  // ---------------------------------------------------------------------------
@@ -4752,6 +5093,7 @@ type LiteLLMRichEndpointModel<TApi extends Api> = {
4752
5093
  hasToolMetadata: boolean;
4753
5094
  hasSupportedOpenAIParams: boolean;
4754
5095
  hasCost: boolean;
5096
+ reportedCost: Partial<ModelSpec<Api>["cost"]>;
4755
5097
  };
4756
5098
  type LiteLLMRichEndpointFailure = {
4757
5099
  endpoint: string;
@@ -4876,30 +5218,36 @@ function getLiteLLMParams(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry |
4876
5218
  function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unknown {
4877
5219
  return entry[key] ?? getLiteLLMModelInfo(entry)?.[key];
4878
5220
  }
4879
-
4880
- /** Per-million USD cost from a `*_per_token` LiteLLM field, or `undefined` when absent/non-positive. */
5221
+ /** Per-million USD cost from a positive `*_per_token` LiteLLM field. */
4881
5222
  function getLiteLLMPerMillionCost(entry: LiteLLMRichModelEntry, key: string): number | undefined {
4882
5223
  const perToken = toNumber(getLiteLLMMetadataValue(entry, key));
4883
5224
  return perToken !== undefined && perToken > 0 ? perToken * 1_000_000 : undefined;
4884
5225
  }
4885
5226
 
4886
- /**
4887
- * Map LiteLLM's per-token pricing (`input_cost_per_token`, `output_cost_per_token`,
4888
- * cache costs) onto {@link ModelSpec.cost} in $/million tokens. Returns `undefined`
4889
- * when LiteLLM reports neither an input nor an output price so callers keep the
4890
- * bundled reference cost.
4891
- */
4892
- function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec<Api>["cost"] | undefined {
5227
+ /** Map positive LiteLLM per-token prices onto their per-million cost fields. */
5228
+ function getLiteLLMReportedCost(entry: LiteLLMRichModelEntry): Partial<ModelSpec<Api>["cost"]> {
4893
5229
  const input = getLiteLLMPerMillionCost(entry, "input_cost_per_token");
4894
5230
  const output = getLiteLLMPerMillionCost(entry, "output_cost_per_token");
4895
- if (input === undefined && output === undefined) {
5231
+ const cacheRead = getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost");
5232
+ const cacheWrite = getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost");
5233
+ return {
5234
+ ...(input !== undefined ? { input } : {}),
5235
+ ...(output !== undefined ? { output } : {}),
5236
+ ...(cacheRead !== undefined ? { cacheRead } : {}),
5237
+ ...(cacheWrite !== undefined ? { cacheWrite } : {}),
5238
+ };
5239
+ }
5240
+
5241
+ function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec<Api>["cost"] | undefined {
5242
+ const cost = getLiteLLMReportedCost(entry);
5243
+ if (cost.input === undefined && cost.output === undefined) {
4896
5244
  return undefined;
4897
5245
  }
4898
5246
  return {
4899
- input: input ?? 0,
4900
- output: output ?? 0,
4901
- cacheRead: getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost") ?? 0,
4902
- cacheWrite: getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost") ?? 0,
5247
+ input: cost.input ?? 0,
5248
+ output: cost.output ?? 0,
5249
+ cacheRead: cost.cacheRead ?? 0,
5250
+ cacheWrite: cost.cacheWrite ?? 0,
4903
5251
  };
4904
5252
  }
4905
5253
 
@@ -5050,12 +5398,29 @@ function mapLiteLLMRichEntry<TApi extends Api>(
5050
5398
  ["tools", "tool_choice", "functions", "function_call"].includes(param),
5051
5399
  )
5052
5400
  : reference?.supportsTools;
5401
+ // Enrich from the bundled reference with provider-INDEPENDENT reasoning
5402
+ // hints only. The reference is resolved against the global bundled catalog,
5403
+ // so a custom endpoint exposing an alias that collides with a bundled model
5404
+ // (a LiteLLM proxy serving `kimi-k3`, which matches Fireworks' bundled
5405
+ // `kimi-k3`) must not inherit that provider's transport compat. Spreading
5406
+ // the resolved `reference.compat` wholesale leaked `wireModelIdMode`,
5407
+ // `toolSchemaFlavor`, `thinkingFormat`, etc. across the provider boundary —
5408
+ // rewriting the wire id to `accounts/fireworks/models/kimi-k3` for a
5409
+ // non-Fireworks endpoint (issue #9938). `buildModel` re-derives every
5410
+ // transport field from the discovered provider and model id, so only the
5411
+ // effort vocabulary flows through here. Mirrors `discoverOpenAIModelsList`.
5412
+ const referenceCompat = reference?.compat as OpenAICompat | undefined;
5053
5413
  const compat: OpenAICompat = {
5054
- ...(reference?.compat ?? {}),
5055
5414
  supportsStore: false,
5056
5415
  supportsDeveloperRole: false,
5057
5416
  ...(supportedOpenAIParams !== undefined
5058
5417
  ? { supportsReasoningEffort: supportedOpenAIParams.includes("reasoning_effort") }
5418
+ : referenceCompat?.supportsReasoningEffort !== undefined
5419
+ ? { supportsReasoningEffort: referenceCompat.supportsReasoningEffort }
5420
+ : {}),
5421
+ ...(referenceCompat?.reasoningEffortMap ? { reasoningEffortMap: referenceCompat.reasoningEffortMap } : {}),
5422
+ ...(referenceCompat?.omitReasoningEffort !== undefined
5423
+ ? { omitReasoningEffort: referenceCompat.omitReasoningEffort }
5059
5424
  : {}),
5060
5425
  };
5061
5426
  return {
@@ -5099,13 +5464,23 @@ function mergeLiteLLMRichEndpointModels<TApi extends Api>(
5099
5464
  maxTokens: next.hasMaxTokens ? next.model.maxTokens : existing.model.maxTokens,
5100
5465
  input: next.supportsVision === true || next.supportsVision === false ? next.model.input : existing.model.input,
5101
5466
  reasoning: typeof next.supportsReasoning === "boolean" ? next.model.reasoning : existing.model.reasoning,
5102
- cost: next.hasCost ? next.model.cost : existing.model.cost,
5467
+ cost: { ...existing.model.cost, ...existing.reportedCost, ...next.reportedCost },
5103
5468
  compat: next.hasSupportedOpenAIParams ? next.model.compat : existing.model.compat,
5104
5469
  };
5105
5470
  if (next.hasToolMetadata) {
5106
5471
  model.supportsTools = next.model.supportsTools;
5107
5472
  }
5108
- return { ...next, apiRoute, model };
5473
+ return {
5474
+ ...next,
5475
+ apiRoute,
5476
+ model,
5477
+ reportedCost: { ...existing.reportedCost, ...next.reportedCost },
5478
+ hasContextWindow: existing.hasContextWindow || next.hasContextWindow,
5479
+ hasMaxTokens: existing.hasMaxTokens || next.hasMaxTokens,
5480
+ hasToolMetadata: existing.hasToolMetadata || next.hasToolMetadata,
5481
+ hasSupportedOpenAIParams: existing.hasSupportedOpenAIParams || next.hasSupportedOpenAIParams,
5482
+ hasCost: existing.hasCost || next.hasCost,
5483
+ };
5109
5484
  }
5110
5485
 
5111
5486
  async function fetchLiteLLMRichEndpoint<TApi extends Api>(
@@ -5167,6 +5542,7 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
5167
5542
  supportedOpenAIParams !== undefined,
5168
5543
  hasSupportedOpenAIParams: supportedOpenAIParams !== undefined,
5169
5544
  hasCost: getLiteLLMCost(entry) !== undefined,
5545
+ reportedCost: getLiteLLMReportedCost(entry),
5170
5546
  };
5171
5547
  const existing = deduped.get(model.id);
5172
5548
  deduped.set(model.id, existing ? mergeLiteLLMRichEndpointModels(existing, next) : next);
@@ -5228,7 +5604,12 @@ async function fetchLiteLLMRichModelsInternal<TApi extends Api>(
5228
5604
  for (const entry of deduped.values()) {
5229
5605
  if (
5230
5606
  (entry.supportsVision !== true && entry.supportsVision !== false) ||
5231
- (options.resolveApi !== undefined && entry.apiRoute === "unknown")
5607
+ (options.resolveApi !== undefined && entry.apiRoute === "unknown") ||
5608
+ (Object.keys(entry.reportedCost).length > 0 &&
5609
+ (entry.reportedCost.input === undefined ||
5610
+ entry.reportedCost.output === undefined ||
5611
+ entry.reportedCost.cacheRead === undefined ||
5612
+ entry.reportedCost.cacheWrite === undefined))
5232
5613
  ) {
5233
5614
  needsMoreMetadata = true;
5234
5615
  break;
@@ -5265,12 +5646,14 @@ export function litellmModelManagerOptions(config?: LiteLLMModelManagerConfig):
5265
5646
  const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("litellm")!;
5266
5647
  return {
5267
5648
  providerId: "litellm",
5268
- // rich-v6 invalidates rows cached before OpenAI models moved to Responses.
5269
- // Earlier versions added bundled reference fallback, continued discovery
5270
- // past incomplete `/model_group/info`, stripped reseller usage suffixes,
5271
- // filtered placeholder-only `all-team-models` rows, and mapped rich pricing.
5272
- // Bump the version whenever the mappers below change, or warm authoritative
5273
- // caches keep serving pre-change rows for the full TTL.
5649
+ // rich-v8 invalidates rows whose `compatConfig` retained a colliding
5650
+ // bundled model's provider-specific transport (e.g. Fireworks
5651
+ // `wireModelIdMode`) before that leak was fixed. Earlier versions added
5652
+ // bundled reference fallback, moved OpenAI models to Responses, continued
5653
+ // past incomplete vision/API metadata and endpoints omitting cache
5654
+ // pricing, stripped reseller usage suffixes, filtered placeholder rows,
5655
+ // and mapped rich pricing. Bump the version whenever these mappers change,
5656
+ // or warm authoritative caches keep serving pre-change rows for the full TTL.
5274
5657
  cacheProviderId: resolveModelCacheProviderId("litellm", { baseUrl }),
5275
5658
  // litellm is a local-only proxy and is never bundled in models.json (that
5276
5659
  // would leak the machine's localhost catalog). Prefer the proxy's richer
@@ -5793,7 +6176,7 @@ export function anthropicModelManagerOptions(
5793
6176
  return {
5794
6177
  providerId: "anthropic",
5795
6178
  modelsDev: {
5796
- fetch: () => fetchWellKnownModels(config?.fetch),
6179
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch),
5797
6180
  map: payload => mapAnthropicModelsDev(payload, baseUrl),
5798
6181
  },
5799
6182
  ...(apiKey && {
@@ -6465,3 +6848,42 @@ export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescript
6465
6848
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS,
6466
6849
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED,
6467
6850
  ];
6851
+
6852
+ const MODELS_DEV_DESCRIPTORS_BY_PROVIDER: Record<string, ModelsDevProviderDescriptor[]> = Object.create(null);
6853
+ for (const descriptor of MODELS_DEV_PROVIDER_DESCRIPTORS) {
6854
+ const providerDescriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId];
6855
+ if (providerDescriptors) {
6856
+ providerDescriptors.push(descriptor);
6857
+ } else {
6858
+ MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId] = [descriptor];
6859
+ }
6860
+ }
6861
+
6862
+ /** Providers whose bundled catalog can receive additive models.dev updates at runtime. */
6863
+ export const MODELS_DEV_CATALOG_PROVIDER_IDS: readonly string[] = Object.freeze(
6864
+ Object.keys(MODELS_DEV_DESCRIPTORS_BY_PROVIDER),
6865
+ );
6866
+
6867
+ /**
6868
+ * Build the shared models.dev fallback for one known provider.
6869
+ *
6870
+ * Provider managers sharing one fetch implementation reuse its conditional
6871
+ * catalog session. Each mapped provider slice is persisted independently so
6872
+ * startup can restore it without parsing the full catalog.
6873
+ *
6874
+ * `timeoutMs` bounds the catalog request. It is configurable for callers with a
6875
+ * stricter startup budget and for deterministic timeout tests.
6876
+ */
6877
+ export function modelsDevCatalogFallback(
6878
+ providerId: string,
6879
+ fetchImpl?: FetchImpl,
6880
+ timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
6881
+ ): ModelsDevFallback<Api> | undefined {
6882
+ const descriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId];
6883
+ if (!descriptors) return undefined;
6884
+ return {
6885
+ additiveOnly: true,
6886
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(fetchImpl, timeoutMs),
6887
+ map: payload => (isRecord(payload) ? filterModelsDevCatalogRows(mapModelsDevToModels(payload, descriptors)) : []),
6888
+ };
6889
+ }