@oh-my-pi/pi-catalog 18.2.1 → 18.2.2

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.
@@ -76,6 +76,8 @@ function supportsDevinThinking(config: ClientModelConfig): boolean {
76
76
  const DEVIN_COST_LABEL_INPUT = "input";
77
77
  const DEVIN_COST_LABEL_CACHE_READ = "cached input";
78
78
  const DEVIN_COST_LABEL_OUTPUT = "output";
79
+ /** Normalized label of the marker dimension separating composite rate cards. */
80
+ const DEVIN_SIDEKICK_LABEL = "sidekick";
79
81
 
80
82
  /** Leading token count of a cost denominator ("1M tokens", "1K tokens"). */
81
83
  const DEVIN_COST_DENOMINATOR_PATTERN = /(\d+(?:\.\d+)?)\s*([kmb])?/i;
@@ -104,10 +106,20 @@ function devinCostDenominatorTokens(denominator: string): number {
104
106
  * an estimated rate, not a different unit, so both kinds are read. `cacheWrite`
105
107
  * has no Cascade dimension — Devin bills cache writes at the input rate — and
106
108
  * stays 0.
109
+ *
110
+ * Composite configs (`fusion`) flatten their own rate card plus every
111
+ * dispatched component's card into one `modelDimensions` list. A `Sidekick`
112
+ * marker dimension separates the composite's own card from the component
113
+ * cards, so reading stops there: a headline card may omit dimensions a
114
+ * component includes, which makes repeated-label detection unreliable.
107
115
  */
108
116
  function devinModelCost(config: ClientModelConfig): ModelCost {
109
117
  const cost: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
110
118
  for (const dimension of config.modelDimensions) {
119
+ const label = dimension.label.trim().toLowerCase();
120
+ if (label === DEVIN_SIDEKICK_LABEL) {
121
+ break;
122
+ }
111
123
  if (dimension.kind !== ModelDimensionKind.COST && dimension.kind !== ModelDimensionKind.COST_FUZZY) {
112
124
  continue;
113
125
  }
@@ -115,7 +127,7 @@ function devinModelCost(config: ClientModelConfig): ModelCost {
115
127
  // (0.1 decodes as 0.10000000149011612) at sub-cent precision.
116
128
  const perMillion =
117
129
  Math.round(((dimension.value * 1_000_000) / devinCostDenominatorTokens(dimension.denominator)) * 1e6) / 1e6;
118
- switch (dimension.label.trim().toLowerCase()) {
130
+ switch (label) {
119
131
  case DEVIN_COST_LABEL_INPUT:
120
132
  cost.input = perMillion;
121
133
  break;
@@ -379,14 +391,14 @@ function devinModelSpec(
379
391
  config: ClientModelConfig,
380
392
  uid: string,
381
393
  baseUrl: string,
382
- isRouter: boolean,
394
+ isAssignModelRouter: boolean,
383
395
  ): ModelSpec<"devin-agent"> {
384
396
  const features = config.modelInfo?.modelFeatures;
385
397
  const supportsImages =
386
398
  (features !== undefined ? features.supportsImages : config.supportsImages) && !DEVIN_IMAGE_BLIND_UIDS.has(uid);
387
399
  const input: ("text" | "image")[] = supportsImages ? ["text", "image"] : ["text"];
388
400
  const compat: DevinCompat = {};
389
- if (isRouter) compat.modelRouter = true;
401
+ if (isAssignModelRouter) compat.modelRouter = true;
390
402
  if (features?.supportsParallelToolCalls === true) compat.supportsParallelToolCalls = true;
391
403
  const maxOutputTokens = config.modelInfo?.maxOutputTokens ?? 0;
392
404
  const spec: ModelSpec<"devin-agent"> = {
@@ -439,7 +451,13 @@ function normalizeDevinModels(
439
451
  }
440
452
  seen.add(uid);
441
453
  const isRouter = displayOption === DisplayOption.MODEL_ROUTER || config.modelInfo?.isModelRouter === true;
442
- specs.push(devinModelSpec(config, uid, baseUrl, isRouter));
454
+ // `isModelRouter` marks two different things: harness-less routing slots
455
+ // (`adaptive`, `subagent-default`) that `AssignModel` resolves into a
456
+ // concrete model, and harness-backed composites (`fusion`,
457
+ // `fusion-sidekick-*`) that are themselves valid chat uids. Only the
458
+ // former take the `AssignModel` path — sending a composite uid there 404s.
459
+ const isAssignModelRouter = isRouter && (config.modelInfo?.harnessUids.length ?? 0) === 0;
460
+ specs.push(devinModelSpec(config, uid, baseUrl, isAssignModelRouter));
443
461
  // A router is a server-side dispatcher, not an effort tier: it stays a
444
462
  // standalone model even when upstream files it under a family.
445
463
  if (!isRouter) {
@@ -2,9 +2,8 @@
2
2
  * SQLite-backed model cache for atomic cross-process access.
3
3
  * Replaces per-provider JSON files with a single cache.db.
4
4
  */
5
- import { Database } from "bun:sqlite";
6
- import { renameSync } from "node:fs";
7
- import { getModelDbPath, isEnoent, isSqliteCorruptionError, logger, VERSION } from "@oh-my-pi/pi-utils";
5
+ import type { Database } from "bun:sqlite";
6
+ import { getModelDbPath, isSqliteCorruptionError, openSqliteDatabaseSync, VERSION } from "@oh-my-pi/pi-utils";
8
7
  import RULES from "./compat/rules.json" with { type: "json" };
9
8
  import type { Api, Model } from "./types";
10
9
 
@@ -135,14 +134,10 @@ function invalidateReadPath(resolvedPath: string): void {
135
134
  }
136
135
  }
137
136
 
138
- function openDb(resolvedPath: string): Database {
139
- const db = new Database(resolvedPath, { create: true });
140
- // Install the busy handler BEFORE any lock-taking statement. See
141
- // https://github.com/can1357/oh-my-pi/issues/2421.
142
- db.run("PRAGMA busy_timeout = 3000");
143
- // Schema invalidation can delete rows containing credentials written by old
144
- // versions. Overwrite deleted SQLite cells instead of leaving their bytes in
145
- // free pages where a raw scan of models.db can still recover them (#5780).
137
+ function initializeDb(db: Database): void {
138
+ // The shared opener installs the busy handler before any lock-taking
139
+ // statement. Schema invalidation can delete rows containing credentials
140
+ // written by old versions, so scrub deleted cells (#5780).
146
141
  db.run("PRAGMA secure_delete = ON");
147
142
  db.run("PRAGMA journal_mode = WAL");
148
143
  db.run(`
@@ -160,92 +155,53 @@ function openDb(resolvedPath: string): Database {
160
155
  )
161
156
  `);
162
157
  migrateCacheSchema(db);
163
- return db;
164
158
  }
165
159
 
166
- function getSharedDb(resolvedPath: string): Database {
167
- if (sharedDb && sharedDbPath === resolvedPath) {
168
- return sharedDb;
169
- }
170
- if (sharedDb) {
171
- sharedDb.close();
172
- sharedDb = null;
173
- sharedDbPath = null;
174
- }
175
- const db = openDb(resolvedPath);
176
- sharedDb = db;
177
- sharedDbPath = resolvedPath;
178
- return db;
160
+ function closeSharedDb(): void {
161
+ if (!sharedDb) return;
162
+ sharedDb.close();
163
+ sharedDb = null;
164
+ sharedDbPath = null;
179
165
  }
180
166
 
181
167
  function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: Database) => T): T {
182
- if (shared) return useDb(getSharedDb(resolvedPath));
183
- const db = openDb(resolvedPath);
184
- try {
185
- return useDb(db);
186
- } finally {
187
- db.close();
188
- }
189
- }
190
-
191
- // Paths already reported corrupt this process: the first unrecoverable failure
192
- // is logged at `error`, later heals at `debug`, so a dying disk cannot spam.
193
- const reportedCorruptPaths = new Set<string>();
194
-
195
- /**
196
- * Move a physically corrupt `models.db` (plus its `-wal`/`-shm` sidecars) aside
197
- * so {@link openDb} can recreate a fresh cache at the original path. Renames are
198
- * best-effort: a vanished sidecar (already healed by a peer process) is fine,
199
- * and any other rename failure is left for {@link openDb} to surface.
200
- */
201
- function quarantineCorruptModelCache(resolvedPath: string): void {
202
- const stamp = Date.now();
203
- for (const suffix of ["", "-wal", "-shm"]) {
168
+ if (shared && sharedDb && sharedDbPath !== resolvedPath) closeSharedDb();
169
+ if (shared && sharedDb) {
204
170
  try {
205
- renameSync(`${resolvedPath}${suffix}`, `${resolvedPath}.corrupt-${stamp}${suffix}`);
206
- } catch (err) {
207
- if (!isEnoent(err)) {
208
- logger.debug("model cache: could not quarantine corrupt file", { path: `${resolvedPath}${suffix}` });
209
- }
171
+ return useDb(sharedDb);
172
+ } catch (error) {
173
+ if (!isSqliteCorruptionError(error)) throw error;
174
+ // The opener owns recovery for new handles. Drop this stale handle
175
+ // first so its WAL cannot remain attached to the replacement.
176
+ closeSharedDb();
177
+ invalidateReadPath(resolvedPath);
178
+ return runModelCacheDb(resolvedPath, shared, useDb);
210
179
  }
211
180
  }
212
- }
213
181
 
214
- /**
215
- * Recover from unrecoverable `models.db` corruption: drop the cached handle,
216
- * quarantine the broken files, and let the next open recreate the cache. A
217
- * corrupt cache would otherwise be re-queried on every read/write forever,
218
- * permanently masking a successful live catalog (issue #8867). Only
219
- * {@link isSqliteCorruptionError} codes reach here; BUSY/permission errors keep
220
- * their existing best-effort paths.
221
- */
222
- function healCorruptModelCache(resolvedPath: string, shared: boolean, err: unknown): void {
223
- if (shared && sharedDb) {
224
- sharedDb.close();
225
- sharedDb = null;
226
- sharedDbPath = null;
227
- }
228
- invalidateReadPath(resolvedPath);
229
- quarantineCorruptModelCache(resolvedPath);
230
- const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
231
- if (reportedCorruptPaths.has(resolvedPath)) {
232
- logger.debug("model cache: re-healed corrupt database", { path: resolvedPath, code });
233
- } else {
234
- reportedCorruptPaths.add(resolvedPath);
235
- logger.error("model cache corrupt; quarantined and recreated a fresh cache", { path: resolvedPath, code });
236
- }
182
+ return openSqliteDatabaseSync(
183
+ resolvedPath,
184
+ db => {
185
+ initializeDb(db);
186
+ const result = useDb(db);
187
+ if (shared) {
188
+ sharedDb = db;
189
+ sharedDbPath = resolvedPath;
190
+ } else {
191
+ db.close();
192
+ }
193
+ return result;
194
+ },
195
+ {
196
+ recoverCorruption: true,
197
+ onCorruptionPreserved: () => invalidateReadPath(resolvedPath),
198
+ },
199
+ );
237
200
  }
238
201
 
239
202
  function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
240
203
  const resolvedPath = dbPath ?? getModelDbPath();
241
- const shared = dbPath === undefined;
242
- try {
243
- return runModelCacheDb(resolvedPath, shared, useDb);
244
- } catch (err) {
245
- if (!isSqliteCorruptionError(err)) throw err;
246
- healCorruptModelCache(resolvedPath, shared, err);
247
- return runModelCacheDb(resolvedPath, shared, useDb);
248
- }
204
+ return runModelCacheDb(resolvedPath, dbPath === undefined, useDb);
249
205
  }
250
206
 
251
207
  function migrateCacheSchema(db: Database): void {
@@ -12,7 +12,7 @@ import {
12
12
  pricingPeerFor,
13
13
  } from "../compat/behavior";
14
14
  import { xaiResponsesReasoningEffortMap } from "../compat/openai";
15
- import { resolveModelPolicy } from "../compat/resolve";
15
+ import { hasModelScopedEffortLadder, resolveModelPolicy } from "../compat/resolve";
16
16
  import { compareRevision, parseRevision } from "../compat/revision";
17
17
  import { seedModels } from "../compat/providers";
18
18
  import { billingVariantPlain, classifyModel, discoveryVocabulary } from "../compat/taxonomy";
@@ -241,6 +241,192 @@ async function fetchCatalogPayload(
241
241
  return payload;
242
242
  }
243
243
 
244
+ /**
245
+ * The wire effort tiers the catalog publishes for a model, in canonical order.
246
+ * Undefined when the row has no effort-addressed thinking, or names no tier
247
+ * omp knows.
248
+ */
249
+ function publishedEffortLadder(model: ModelsDevModel): Effort[] | undefined {
250
+ const values = model.reasoning_options?.find(option => option?.type === "effort")?.values;
251
+ if (!Array.isArray(values)) return undefined;
252
+ const ladder = THINKING_EFFORTS.filter(effort => values.includes(effort));
253
+ return ladder.length > 0 ? ladder : undefined;
254
+ }
255
+
256
+ /**
257
+ * Published ladders, addressable both by the host that published them and by
258
+ * bare id.
259
+ *
260
+ * `byHost` (keyed `provider\0id`) is authoritative: a ladder is only valid for
261
+ * the deployment that published it, so the endpoint's own catalog identity is
262
+ * consulted first. `byId` answers when the host is unknown — a gateway id with
263
+ * no catalog provider of its own — and only while every host publishing that
264
+ * id agrees that it takes an effort dial and on which tiers; an id whose hosts
265
+ * disagree, or that any host publishes with no dial at all, is dropped from
266
+ * it, so the ladder stays unknown instead of borrowing an arbitrary host's.
267
+ *
268
+ * `withoutLadder` carries the same `provider\0id` key for a row the catalog
269
+ * does publish but with no effort dial on it. The host serving an id outranks
270
+ * every other host on the question of what that deployment accepts, so its
271
+ * silence blocks the bare-id fallback for that id rather than letting a
272
+ * foreign ladder answer in its place. When the serving host is unknown that
273
+ * per-host veto cannot fire, which is why a dialless row also disqualifies the
274
+ * bare id outright.
275
+ */
276
+ interface PublishedEffortLadders {
277
+ byHost: ReadonlyMap<string, readonly Effort[]>;
278
+ byId: ReadonlyMap<string, readonly Effort[]>;
279
+ withoutLadder: ReadonlySet<string>;
280
+ }
281
+
282
+ const EMPTY_PUBLISHED_EFFORT_LADDERS: PublishedEffortLadders = {
283
+ byHost: new Map(),
284
+ byId: new Map(),
285
+ withoutLadder: new Set(),
286
+ };
287
+
288
+ function indexPublishedEffortLadders(payload: unknown): PublishedEffortLadders {
289
+ const byHost = new Map<string, readonly Effort[]>();
290
+ const byId = new Map<string, readonly Effort[]>();
291
+ const withoutLadder = new Set<string>();
292
+ const index: PublishedEffortLadders = { byHost, byId, withoutLadder };
293
+ const unshareable = new Set<string>();
294
+ if (!isRecord(payload)) return index;
295
+ for (const [providerKey, provider] of Object.entries(payload)) {
296
+ if (!isRecord(provider) || !isRecord(provider.models)) continue;
297
+ for (const [modelId, rawModel] of Object.entries(provider.models)) {
298
+ if (!isRecord(rawModel)) continue;
299
+ const key = `${providerKey}\u0000${modelId}`;
300
+ const ladder = publishedEffortLadder(rawModel as ModelsDevModel);
301
+ if (!ladder) {
302
+ withoutLadder.add(key);
303
+ // Some deployment of this id rejects an effort dial; without
304
+ // knowing which host serves a bare id, none may claim one.
305
+ byId.delete(modelId);
306
+ unshareable.add(modelId);
307
+ continue;
308
+ }
309
+ byHost.set(key, ladder);
310
+ if (unshareable.has(modelId)) continue;
311
+ const shared = byId.get(modelId);
312
+ if (shared === undefined) {
313
+ byId.set(modelId, ladder);
314
+ } else if (shared.length !== ladder.length || shared.some((effort, index) => effort !== ladder[index])) {
315
+ byId.delete(modelId);
316
+ unshareable.add(modelId);
317
+ }
318
+ }
319
+ }
320
+ return index;
321
+ }
322
+
323
+ /**
324
+ * The index is tagged onto the catalog payload it was built from, so each
325
+ * catalog version is indexed once. {@link fetchWellKnownModels} already scopes
326
+ * payloads by fetch context, coalesces concurrent requests, answers a `304`
327
+ * with the same object, and hands back the last good payload when a refresh
328
+ * fails, so the tag inherits all of that lifetime behaviour for free.
329
+ */
330
+ const kPublishedEffortLadders = Symbol("catalog.publishedEffortLadders");
331
+
332
+ interface IndexedCatalogPayload {
333
+ [kPublishedEffortLadders]?: PublishedEffortLadders;
334
+ }
335
+
336
+ async function loadPublishedEffortLadders(fetchImpl?: FetchImpl): Promise<PublishedEffortLadders> {
337
+ const payload = await fetchWellKnownModels(fetchImpl);
338
+ if (!isRecord(payload)) return EMPTY_PUBLISHED_EFFORT_LADDERS;
339
+ const tagged = payload as IndexedCatalogPayload;
340
+ return (tagged[kPublishedEffortLadders] ??= indexPublishedEffortLadders(payload));
341
+ }
342
+
343
+ /**
344
+ * The catalog provider keys this endpoint publishes under. A provider whose
345
+ * catalog identity differs from its omp id (`moonshot` → `moonshotai`) is
346
+ * resolved through its descriptors; the omp id stays as a candidate for
347
+ * providers the descriptors do not cover.
348
+ */
349
+ function catalogProviderKeys(providerId: string): readonly string[] {
350
+ const keys = new Set<string>();
351
+ for (const descriptor of MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId] ?? []) keys.add(descriptor.modelsDevKey);
352
+ keys.add(providerId);
353
+ return [...keys];
354
+ }
355
+
356
+ /**
357
+ * The ladder published for a discovered id, preferring the host that serves it.
358
+ *
359
+ * Gateway prefixes are peeled (`deepseek/deepseek-v4` → `deepseek-v4`), and
360
+ * each peeled segment joins the host candidates ahead of the endpoint's own
361
+ * keys: on an aggregator the prefix names the real upstream. All host-scoped
362
+ * candidates are checked before accepting any shared-id fallback.
363
+ * A bare id is only accepted from {@link PublishedEffortLadders.byId}, which
364
+ * holds it only while every publishing host agrees that it takes an effort
365
+ * dial and on which tiers. A host serving the id that published it without a
366
+ * dial is this deployment's own answer and outranks any other host's ladder,
367
+ * so it vetoes the candidate here; a dialless host omp cannot recognize as the
368
+ * server already kept the id out of `byId` when the index was built.
369
+ */
370
+ function lookupPublishedEffortLadder(
371
+ ladders: PublishedEffortLadders,
372
+ providerKeys: readonly string[],
373
+ modelId: string,
374
+ ): readonly Effort[] | undefined {
375
+ const hosts = [...providerKeys];
376
+ let shared: readonly Effort[] | undefined;
377
+ for (let candidate = modelId; ;) {
378
+ let dialless = false;
379
+ for (const host of hosts) {
380
+ const key = `${host}\u0000${candidate}`;
381
+ const scoped = ladders.byHost.get(key);
382
+ if (scoped) return scoped;
383
+ dialless ||= ladders.withoutLadder.has(key);
384
+ }
385
+ if (dialless) return undefined;
386
+ shared ??= ladders.byId.get(candidate);
387
+ const slash = candidate.indexOf("/");
388
+ if (slash < 0) return shared;
389
+ hosts.unshift(candidate.slice(0, slash));
390
+ candidate = candidate.slice(slash + 1);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Fill the effort ladder of discovered reasoning models whose tiers omp would
396
+ * otherwise guess from the neutral wire or provider-wide unknown-class default.
397
+ *
398
+ * Source precedence is unchanged: a provider that reports its own thinking
399
+ * surface, and any model whose ladder reviewed rules declare, are left exactly
400
+ * as they are. Only the guess is corrected, and only for ids the catalog
401
+ * actually publishes for this endpoint (or publishes unambiguously), so no
402
+ * request is made when every discovered model is already covered.
403
+ */
404
+ async function applyPublishedEffortLadders<TApi extends Api>(
405
+ models: readonly ModelSpec<TApi>[] | null,
406
+ providerId: string,
407
+ fetchImpl?: FetchImpl,
408
+ ): Promise<readonly ModelSpec<TApi>[] | null> {
409
+ if (models === null) return null;
410
+ const guessed = new Set(
411
+ models
412
+ .filter(
413
+ model => model.reasoning === true && model.thinking === undefined && !hasModelScopedEffortLadder(model),
414
+ )
415
+ .map(model => model.id),
416
+ );
417
+ if (guessed.size === 0) return models;
418
+ // An unreachable catalog with no prior payload is not a discovery failure:
419
+ // the endpoint's own listing stands and the guess stays in place.
420
+ const ladders = await loadPublishedEffortLadders(fetchImpl).catch(() => EMPTY_PUBLISHED_EFFORT_LADDERS);
421
+ if (ladders.byHost.size === 0) return models;
422
+ const providerKeys = catalogProviderKeys(providerId);
423
+ return models.map(model => {
424
+ if (!guessed.has(model.id)) return model;
425
+ const efforts = lookupPublishedEffortLadder(ladders, providerKeys, model.id);
426
+ return efforts ? { ...model, thinking: { mode: "effort" as const, efforts } } : model;
427
+ });
428
+ }
429
+
244
430
  function mapAnthropicModelsDev(payload: unknown, baseUrl: string): ModelSpec<"anthropic-messages">[] {
245
431
  if (!isRecord(payload)) {
246
432
  return [];
@@ -609,19 +795,23 @@ function createOpenAICompatibleModelManagerOptions<TApi extends Api>(
609
795
  dropCachedModelIdsOnStaticMismatch: options.dropCachedModelIdsOnStaticMismatch,
610
796
  }),
611
797
  ...((!options.requireApiKey || apiKey) && {
612
- fetchDynamicModels: () =>
613
- fetchOpenAICompatibleModels({
614
- api: options.api,
615
- provider: options.providerId,
616
- baseUrl,
617
- apiKey,
618
- ...(options.headers && { headers: resolveSimpleProviderHeaders(options.headers) }),
619
- ...(filterModel && {
620
- filterModel: (entry, model) => filterModel(entry, model, references),
798
+ fetchDynamicModels: async () =>
799
+ applyPublishedEffortLadders(
800
+ await fetchOpenAICompatibleModels({
801
+ api: options.api,
802
+ provider: options.providerId,
803
+ baseUrl,
804
+ apiKey,
805
+ ...(options.headers && { headers: resolveSimpleProviderHeaders(options.headers) }),
806
+ ...(filterModel && {
807
+ filterModel: (entry, model) => filterModel(entry, model, references),
808
+ }),
809
+ mapModel: (entry, defaults) => options.mapModel(entry, defaults, references.get(defaults.id)),
810
+ fetch: options.config?.fetch,
621
811
  }),
622
- mapModel: (entry, defaults) => options.mapModel(entry, defaults, references.get(defaults.id)),
623
- fetch: options.config?.fetch,
624
- }),
812
+ options.providerId,
813
+ options.config?.fetch,
814
+ ),
625
815
  }),
626
816
  };
627
817
  }
package/src/types.ts CHANGED
@@ -1196,6 +1196,16 @@ export interface Model<TApi extends Api = Api> {
1196
1196
  gitlabDuoWorkflowRootNamespaceId?: string;
1197
1197
  /** Cursor `max_mode` request flag returned by `GetUsableModels` for premium models that require max mode. */
1198
1198
  cursorMaxMode?: boolean;
1199
+ /**
1200
+ * Per-wire-id `max_mode` markers for the members a collapsed Cursor row
1201
+ * routes to, recorded by `collapseVariants` from live `GetUsableModels`
1202
+ * rows. {@link cursorMaxMode} on a collapsed row is an OR across members,
1203
+ * so it cannot tell a `-low` route that needs no max mode from an Opus
1204
+ * `-fast` route that does; transports look the routed wire id up here
1205
+ * first. Absent on raw rows (their own `cursorMaxMode` already describes
1206
+ * their single wire id) and on bundled snapshots that predate discovery.
1207
+ */
1208
+ cursorMaxModeRoutes?: Readonly<Record<string, boolean>>;
1199
1209
  cost: ModelCost;
1200
1210
  /** Premium Copilot requests charged per user-initiated request (defaults to 1). */
1201
1211
  premiumMultiplier?: number;