@linxiraos/pi-catalog 1.1.27 → 1.2.1

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +4 -17
  2. package/dist/types/compat/auth-ids.d.ts +2 -2
  3. package/dist/types/compat/judgment-questions.d.ts +7 -0
  4. package/dist/types/compat/prompt-cache-lookback.d.ts +7 -0
  5. package/dist/types/compat/provider-ids.d.ts +1 -1
  6. package/dist/types/compat/reasoning-carry.d.ts +21 -0
  7. package/dist/types/compat/request-size.d.ts +7 -0
  8. package/dist/types/compat/taxonomy.d.ts +0 -10
  9. package/dist/types/compat/types.d.ts +4 -0
  10. package/dist/types/discovery/antigravity.d.ts +13 -3
  11. package/dist/types/model-manager.d.ts +16 -0
  12. package/dist/types/provider-models/account-access.d.ts +11 -0
  13. package/dist/types/provider-models/google.d.ts +23 -1
  14. package/dist/types/provider-models/openai-compat.d.ts +19 -1
  15. package/dist/types/provider-models/special.d.ts +2 -0
  16. package/dist/types/types.d.ts +106 -20
  17. package/dist/types/wire/cloudflare-ai-gateway.d.ts +0 -10
  18. package/dist/types/wire/cloudflare.d.ts +10 -0
  19. package/dist/types/wire/factory-droid.d.ts +1 -1
  20. package/package.json +5 -5
  21. package/src/build.ts +39 -35
  22. package/src/compat/auth-ids.ts +4 -0
  23. package/src/compat/axes.ts +37 -3
  24. package/src/compat/cascade.ts +6 -1
  25. package/src/compat/judgment-questions.ts +12 -0
  26. package/src/compat/prompt-cache-lookback.ts +23 -0
  27. package/src/compat/provider-ids.d.ts +1 -1
  28. package/src/compat/provider-ids.ts +2 -0
  29. package/src/compat/reasoning-carry.ts +64 -0
  30. package/src/compat/request-size.ts +25 -0
  31. package/src/compat/resolve.ts +16 -2
  32. package/src/compat/rules/README.md +12 -2
  33. package/src/compat/rules/auth/_order.kdl +2 -2
  34. package/src/compat/rules/auth/cloudflare-workers-ai.kdl +9 -0
  35. package/src/compat/rules/auth/coralbricks.kdl +13 -0
  36. package/src/compat/rules/auth/minimax-code-cn.kdl +1 -1
  37. package/src/compat/rules/auth/minimax-code.kdl +1 -1
  38. package/src/compat/rules/auth/openai-codex.kdl +1 -1
  39. package/src/compat/rules/auth/stencil.kdl +1 -1
  40. package/src/compat/rules/classes/anthropic.kdl +87 -1
  41. package/src/compat/rules/classes/deepseek.kdl +6 -0
  42. package/src/compat/rules/classes/glm.kdl +16 -7
  43. package/src/compat/rules/classes/kimi.kdl +8 -0
  44. package/src/compat/rules/classes/minimax.kdl +31 -1
  45. package/src/compat/rules/classes/openai.kdl +5 -3
  46. package/src/compat/rules/classes/qwen.kdl +2 -1
  47. package/src/compat/rules/providers/abliteration.kdl +7 -0
  48. package/src/compat/rules/providers/anthropic.kdl +13 -0
  49. package/src/compat/rules/providers/apple.kdl +1 -0
  50. package/src/compat/rules/providers/cloudflare-workers-ai.kdl +34 -0
  51. package/src/compat/rules/providers/coralbricks.kdl +62 -0
  52. package/src/compat/rules/providers/cursor.kdl +22 -0
  53. package/src/compat/rules/providers/devin.kdl +2 -2
  54. package/src/compat/rules/providers/factory-droid.kdl +77 -13
  55. package/src/compat/rules/providers/github-copilot.kdl +29 -0
  56. package/src/compat/rules/providers/groq.kdl +21 -0
  57. package/src/compat/rules/providers/judgment-questions.kdl +53 -0
  58. package/src/compat/rules/providers/litellm.kdl +4 -1
  59. package/src/compat/rules/providers/minimax-code-cn.kdl +10 -5
  60. package/src/compat/rules/providers/minimax-code.kdl +10 -5
  61. package/src/compat/rules/providers/mistral.kdl +25 -1
  62. package/src/compat/rules/providers/moonshot.kdl +11 -0
  63. package/src/compat/rules/providers/openai-codex.kdl +17 -13
  64. package/src/compat/rules/providers/openai.kdl +22 -0
  65. package/src/compat/rules/providers/opencode-zen.kdl +20 -0
  66. package/src/compat/rules/providers/openrouter.kdl +6 -0
  67. package/src/compat/rules/providers/zhipu-coding-plan.kdl +10 -0
  68. package/src/compat/rules/runtime/behavior.kdl +7 -4
  69. package/src/compat/rules/taxonomy/_collapse.kdl +18 -0
  70. package/src/compat/rules.json +1 -1
  71. package/src/compat/taxonomy.d.ts +0 -10
  72. package/src/compat/taxonomy.ts +3 -3
  73. package/src/compat/types.d.ts +4 -0
  74. package/src/compat/types.ts +4 -0
  75. package/src/discovery/antigravity.d.ts +13 -3
  76. package/src/discovery/antigravity.ts +33 -17
  77. package/src/discovery/cursor.ts +2 -1
  78. package/src/discovery/devin.ts +20 -2
  79. package/src/model-cache.ts +113 -47
  80. package/src/model-manager.d.ts +16 -0
  81. package/src/model-manager.ts +107 -41
  82. package/src/model-tokenizer.ts +3 -0
  83. package/src/models.json +1 -1
  84. package/src/provider-models/account-access.d.ts +11 -0
  85. package/src/provider-models/account-access.ts +30 -0
  86. package/src/provider-models/cache-provider-id.ts +3 -2
  87. package/src/provider-models/descriptors.ts +4 -0
  88. package/src/provider-models/google.d.ts +23 -1
  89. package/src/provider-models/google.ts +70 -14
  90. package/src/provider-models/openai-compat.d.ts +19 -1
  91. package/src/provider-models/openai-compat.ts +215 -31
  92. package/src/provider-models/special.d.ts +2 -0
  93. package/src/provider-models/special.ts +15 -18
  94. package/src/types.d.ts +106 -20
  95. package/src/types.ts +149 -17
  96. package/src/wire/cloudflare-ai-gateway.d.ts +0 -10
  97. package/src/wire/cloudflare-ai-gateway.ts +0 -36
  98. package/src/wire/cloudflare.ts +40 -0
  99. package/src/wire/factory-droid.d.ts +1 -1
  100. package/src/wire/factory-droid.ts +1 -1
@@ -1,13 +1,3 @@
1
- /**
2
- * Runtime model-identity taxonomy: classifies wire model ids into
3
- * `(class, family, revision)` ranks, applies reviewed identity overrides, and
4
- * exposes the collapse/discovery vocabularies.
5
- *
6
- * Faithful port of the o2 reference (`taxonomy.rs` / `classify.rs`):
7
- * matchers rank `exact(4) > bounded(3) > namespace(2) > prefix(1) > glob(0)`
8
- * with token byte length as tiebreak; equal cross-class or cross-family ranks
9
- * throw unless classification is `lenient` (discovery normalization).
10
- */
11
1
  import type { Effort } from "../effort";
12
2
  import type { ModelIdentity } from "./types";
13
3
  /** Two classes, families, or reviewed override patterns tie for one model id. */
@@ -8,6 +8,7 @@
8
8
  * with token byte length as tiebreak; equal cross-class or cross-family ranks
9
9
  * throw unless classification is `lenient` (discovery normalization).
10
10
  */
11
+ import { LRUCache } from "@linxiraos/pi-utils/lru";
11
12
  import type { Effort } from "../effort";
12
13
  import { globMatch } from "./cascade";
13
14
  import { formatRevision, parseRevisionPrefix } from "./revision";
@@ -433,8 +434,8 @@ export function stripThinkingVariantSuffix(model: string): string | undefined {
433
434
  * @throws AmbiguousIdentityError on equal-rank cross-class, cross-family, or
434
435
  * reviewed-pattern matches unless `opts.lenient`.
435
436
  */
436
- const classifyMemo = new Map<string, ModelIdentity>();
437
- const CLASSIFY_MEMO_MAX = 4096;
437
+ // LRU sized above the bundled catalog so catalog-wide passes stay hot (a full `clear()` thrashed them).
438
+ const classifyMemo = new LRUCache<string, ModelIdentity>({ max: 16384 });
438
439
 
439
440
  function classifyMemoKey(provider: string, modelId: string, lenient: boolean): string {
440
441
  return `${provider.length}:${provider}${modelId.length}:${modelId}${lenient ? 1 : 0}`;
@@ -446,7 +447,6 @@ export function classifyModel(provider: string, modelId: string, opts?: Classify
446
447
  const cached = classifyMemo.get(key);
447
448
  if (cached !== undefined) return { ...cached };
448
449
  const identity = classifyModelUncached(provider, modelId, opts);
449
- if (classifyMemo.size >= CLASSIFY_MEMO_MAX) classifyMemo.clear();
450
450
  classifyMemo.set(key, Object.freeze(identity));
451
451
  return { ...identity };
452
452
  }
@@ -593,6 +593,8 @@ export interface CompiledSeedModel {
593
593
  baseUrl: string;
594
594
  reasoning: boolean;
595
595
  input: ("text" | "image")[];
596
+ /** Wire model id sent upstream when it differs from the row id (`request-model-id`). */
597
+ requestModelId?: string;
596
598
  supportsTools?: boolean;
597
599
  cost: TokenCost;
598
600
  contextWindow: number | null;
@@ -622,6 +624,8 @@ export interface CompiledProvider {
622
624
  id: string;
623
625
  /** Preferred model id when no explicit selection is made. */
624
626
  defaultModel: string;
627
+ /** Whether the provider participates in automatic default selection (defaults to true). */
628
+ automaticDefault?: boolean;
625
629
  /** Env vars consulted, in order, for the runtime API-key fallback. */
626
630
  envVars?: string[];
627
631
  /** The runtime creates a model manager even without a valid API key. */
@@ -625,6 +625,8 @@ export interface CompiledSeedModel {
625
625
  baseUrl: string;
626
626
  reasoning: boolean;
627
627
  input: ("text" | "image")[];
628
+ /** Wire model id sent upstream when it differs from the row id (`request-model-id`). */
629
+ requestModelId?: string;
628
630
  supportsTools?: boolean;
629
631
  cost: TokenCost;
630
632
  contextWindow: number | null;
@@ -656,6 +658,8 @@ export interface CompiledProvider {
656
658
  id: string;
657
659
  /** Preferred model id when no explicit selection is made. */
658
660
  defaultModel: string;
661
+ /** Whether the provider participates in automatic default selection (defaults to true). */
662
+ automaticDefault?: boolean;
659
663
  /** Env vars consulted, in order, for the runtime API-key fallback. */
660
664
  envVars?: string[];
661
665
  /** The runtime creates a model manager even without a valid API key. */
@@ -63,13 +63,23 @@ export interface FetchAntigravityDiscoveryModelsOptions {
63
63
  */
64
64
  collapseTable?: VariantCollapseTable;
65
65
  }
66
+ /**
67
+ * A complete account roster, or a definitive 401/403 credential rejection.
68
+ * `null` from {@link fetchAntigravityDiscoveryModels} instead means a transient
69
+ * failure or invalid response, which must not replace an authoritative catalog.
70
+ */
71
+ export interface AntigravityModelDiscoveryResult {
72
+ models: ModelSpec<"google-gemini-cli">[];
73
+ rejectedStatus?: 401 | 403;
74
+ }
66
75
  /**
67
76
  * Fetches discoverable Antigravity models and normalizes them into canonical model entries.
68
77
  *
69
- * Returns `null` on network/payload/auth failures.
70
- * Returns `[]` only when the endpoint responds successfully with no usable models.
78
+ * Returns `null` on network, server, or payload failures; a 401/403 is reported
79
+ * separately so multi-account discovery can skip a rejected credential.
80
+ * A successful empty roster has `models: []` without `rejectedStatus`.
71
81
  */
72
- export declare function fetchAntigravityDiscoveryModels(options: FetchAntigravityDiscoveryModelsOptions): Promise<ModelSpec<"google-gemini-cli">[] | null>;
82
+ export declare function fetchAntigravityDiscoveryModels(options: FetchAntigravityDiscoveryModelsOptions): Promise<AntigravityModelDiscoveryResult | null>;
73
83
  /** Advertised image model and serving endpoint for one Antigravity account. */
74
84
  export interface AntigravityImageModel {
75
85
  id: string;
@@ -153,19 +153,29 @@ export interface FetchAntigravityDiscoveryModelsOptions {
153
153
  collapseTable?: VariantCollapseTable;
154
154
  }
155
155
 
156
+ /**
157
+ * A complete account roster, or a definitive 401/403 credential rejection.
158
+ * `null` from {@link fetchAntigravityDiscoveryModels} instead means a transient
159
+ * failure or invalid response, which must not replace an authoritative catalog.
160
+ */
161
+ export interface AntigravityModelDiscoveryResult {
162
+ models: ModelSpec<"google-gemini-cli">[];
163
+ rejectedStatus?: 401 | 403;
164
+ }
165
+
156
166
  /**
157
167
  * Fetches discoverable Antigravity models and normalizes them into canonical model entries.
158
168
  *
159
- * Returns `null` on network/payload/auth failures.
160
- * Returns `[]` only when the endpoint responds successfully with no usable models.
169
+ * Returns `null` on network, server, or payload failures; a 401/403 is reported
170
+ * separately so multi-account discovery can skip a rejected credential.
171
+ * A successful empty roster has `models: []` without `rejectedStatus`.
161
172
  */
162
173
  export async function fetchAntigravityDiscoveryModels(
163
174
  options: FetchAntigravityDiscoveryModelsOptions,
164
- ): Promise<ModelSpec<"google-gemini-cli">[] | null> {
175
+ ): Promise<AntigravityModelDiscoveryResult | null> {
165
176
  const discovered = await fetchAntigravityDiscoveryResponse(options);
166
- if (!discovered) {
167
- return null;
168
- }
177
+ if (!discovered) return null;
178
+ if ("rejectedStatus" in discovered) return { models: [], rejectedStatus: discovered.rejectedStatus };
169
179
 
170
180
  const models: ModelSpec<"google-gemini-cli">[] = [];
171
181
  const apiModels = discovered.payload.models;
@@ -208,7 +218,7 @@ export async function fetchAntigravityDiscoveryModels(
208
218
  options.collapseTable === undefined ? undefined : { table: options.collapseTable },
209
219
  );
210
220
  collapsed.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
211
- return collapsed;
221
+ return { models: collapsed };
212
222
  }
213
223
 
214
224
  /** Advertised image model and serving endpoint for one Antigravity account. */
@@ -222,14 +232,14 @@ export async function fetchAntigravityImageModel(
222
232
  options: FetchAntigravityDiscoveryModelsOptions,
223
233
  ): Promise<AntigravityImageModel | null> {
224
234
  const discovered = await fetchAntigravityDiscoveryResponse(options);
225
- const id = discovered?.payload.imageGenerationModelIds?.find(modelId => modelId.length > 0);
226
- return id && discovered ? { id, endpoint: discovered.endpoint } : null;
235
+ if (!discovered || "rejectedStatus" in discovered) return null;
236
+ const id = discovered.payload.imageGenerationModelIds?.find(modelId => modelId.length > 0);
237
+ return id ? { id, endpoint: discovered.endpoint } : null;
227
238
  }
228
239
 
229
- interface AntigravityDiscoveryResponse {
230
- payload: AntigravityDiscoveryApiResponse;
231
- endpoint: string;
232
- }
240
+ type AntigravityDiscoveryResponse =
241
+ | { payload: AntigravityDiscoveryApiResponse; endpoint: string }
242
+ | { rejectedStatus: 401 | 403 };
233
243
 
234
244
  async function fetchAntigravityDiscoveryResponse(
235
245
  options: FetchAntigravityDiscoveryModelsOptions,
@@ -243,6 +253,8 @@ async function fetchAntigravityDiscoveryResponse(
243
253
  ? [trimTrailingSlashes(options.endpoint)]
244
254
  : DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINTS.map(trimTrailingSlashes);
245
255
 
256
+ let rejectedStatus: 401 | 403 | undefined;
257
+ let transientFailure = false;
246
258
  for (const endpoint of endpoints) {
247
259
  let response: Response;
248
260
  try {
@@ -257,10 +269,14 @@ async function fetchAntigravityDiscoveryResponse(
257
269
  signal: options.signal,
258
270
  });
259
271
  } catch {
272
+ transientFailure = true;
260
273
  continue;
261
274
  }
262
275
 
263
276
  if (!response.ok) {
277
+ if (response.status === 401) rejectedStatus = 401;
278
+ else if (response.status === 403) rejectedStatus = 403;
279
+ else transientFailure = true;
264
280
  continue;
265
281
  }
266
282
 
@@ -268,16 +284,16 @@ async function fetchAntigravityDiscoveryResponse(
268
284
  try {
269
285
  payload = await response.json();
270
286
  } catch {
287
+ transientFailure = true;
271
288
  continue;
272
289
  }
273
290
 
274
291
  const parsed = parseAntigravityDiscoveryResponse(payload);
275
- if (parsed) {
276
- return { payload: parsed, endpoint };
277
- }
292
+ if (parsed) return { payload: parsed, endpoint };
293
+ transientFailure = true;
278
294
  }
279
295
 
280
- return null;
296
+ return rejectedStatus !== undefined && !transientFailure ? { rejectedStatus } : null;
281
297
  }
282
298
 
283
299
  function parseAntigravityDiscoveryResponse(value: unknown): AntigravityDiscoveryApiResponse | null {
@@ -882,7 +882,8 @@ function buildRichCursorLane(
882
882
  cursorRequiresDataRetention: details.requiresDataRetention,
883
883
  cursorSupportsAgent: details.supportsAgent,
884
884
  cursorSupportsSandboxing: details.supportsSandboxing,
885
- isProviderDefault,
885
+ // Not marked `isProviderDefault`: Cursor keeps its bundled startup default, so
886
+ // the account default only picks the lane's default wire id and the badge.
886
887
  isRecommended: isProviderDefault || details.defaultOn,
887
888
  description:
888
889
  variant?.tagline ??
@@ -9,6 +9,7 @@ import {
9
9
  type ClientModelConfig,
10
10
  DisplayOption,
11
11
  GetCliModelConfigsRequestSchema,
12
+ type GetCliModelConfigsResponse,
12
13
  GetCliModelConfigsResponseSchema,
13
14
  type Metadata,
14
15
  MetadataSchema,
@@ -346,7 +347,7 @@ export async function fetchDevinModels(
346
347
  GetCliModelConfigsResponseSchema,
347
348
  new Uint8Array(await response.arrayBuffer()),
348
349
  );
349
- return decoded ? normalizeDevinModels(decoded.clientModelConfigs, options.baseUrl) : null;
350
+ return decoded ? normalizeDevinModels(decoded, options.baseUrl) : null;
350
351
  } catch {
351
352
  return null;
352
353
  }
@@ -497,9 +498,12 @@ function routeDevinFusionLead(spec: ModelSpec<"devin-agent">, lead: ModelSpec<"d
497
498
  }
498
499
 
499
500
  function normalizeDevinModels(
500
- configs: readonly ClientModelConfig[],
501
+ response: GetCliModelConfigsResponse,
501
502
  baseUrlOverride: string | undefined,
502
503
  ): ModelSpec<"devin-agent">[] {
504
+ const configs = response.clientModelConfigs;
505
+ // The model the native client starts this account on (plan-dependent).
506
+ const accountDefaultUid = response.defaultOverrideModelConfig?.modelUid.trim();
503
507
  const baseUrl = baseUrlOverride ?? DEVIN_DEFAULT_BASE_URL;
504
508
  const specs: ModelSpec<"devin-agent">[] = [];
505
509
  const seen = new Set<string>();
@@ -539,6 +543,7 @@ function normalizeDevinModels(
539
543
  if (lead === null) continue;
540
544
  const spec = devinModelSpec(config, uid, baseUrl, isAssignModelRouter);
541
545
  if (lead !== undefined) routeDevinFusionLead(spec, devinModelSpec(liveConfigs.get(lead)!, lead, baseUrl, false));
546
+ if (uid === accountDefaultUid) spec.isProviderDefault = true;
542
547
  specs.push(spec);
543
548
  // A router is a server-side dispatcher, not an effort tier: it stays a
544
549
  // standalone model even when upstream files it under a family.
@@ -554,5 +559,18 @@ function normalizeDevinModels(
554
559
  const families = devinDynamicFamilies(lanes.values());
555
560
  const dynamic = families.length > 0 ? collapseVariants(specs, { table: { families } }) : specs;
556
561
  const collapsed = collapseVariants(dynamic);
562
+ // The account default outranks its family's own default lane when an effort
563
+ // routes to it, from server metadata or the static table: the family starts
564
+ // on that wire uid and effort. Other lanes (a no-thinking `off` default) keep
565
+ // the family's own default.
566
+ const accountFamily = collapsed.find(spec => spec.isProviderDefault && spec.thinking?.effortRouting);
567
+ const accountEffort = THINKING_EFFORTS.find(
568
+ effort => accountFamily?.thinking?.effortRouting?.[effort] === accountDefaultUid,
569
+ );
570
+ if (accountFamily?.thinking && accountEffort !== undefined) {
571
+ accountFamily.thinking = { ...accountFamily.thinking, defaultLevel: accountEffort };
572
+ if (accountFamily.id === accountDefaultUid) delete accountFamily.requestModelId;
573
+ else accountFamily.requestModelId = accountDefaultUid;
574
+ }
557
575
  return collapsed.sort((a, b) => a.id.localeCompare(b.id));
558
576
  }
@@ -3,6 +3,7 @@
3
3
  * Replaces per-provider JSON files with a single cache.db.
4
4
  */
5
5
  import type { Database } from "bun:sqlite";
6
+ import * as path from "node:path";
6
7
  import { getModelDbPath, isSqliteCorruptionError, logger, openSqliteDatabaseSync, VERSION } from "@linxiraos/pi-utils";
7
8
  import RULES from "./compat/rules.json" with { type: "json" };
8
9
  import type { Api, Model } from "./types";
@@ -46,14 +47,19 @@ function materializationPolicy(): string {
46
47
  return cachedMaterializationPolicy;
47
48
  }
48
49
 
49
- interface CacheRow {
50
+ interface CacheRowMeta {
50
51
  provider_id: string;
51
52
  version: number;
52
53
  materialization_policy: string;
53
54
  updated_at: number;
54
55
  authoritative: number;
55
56
  static_fingerprint: string;
56
- models: string;
57
+ /**
58
+ * `Bun.hash` of `models`; empty for rows written before the column existed
59
+ * (never equal). An in-place `UPDATE` of `models` that leaves the hash
60
+ * untouched resets it to empty (`model_cache_models_hash_reset` trigger).
61
+ */
62
+ models_hash: string;
57
63
  header_omitted_model_ids: string;
58
64
  unrestorable_header_model_ids: string;
59
65
  header_restore_version: number;
@@ -63,6 +69,10 @@ interface CacheRow {
63
69
  refresh_authoritative: number | null;
64
70
  }
65
71
 
72
+ interface CacheRow extends CacheRowMeta {
73
+ models: string;
74
+ }
75
+
66
76
  /**
67
77
  * Payload rows are multi-MB and SQLite rewrites a whole record (including its
68
78
  * overflow pages) on any column update. Freshness that advances without a
@@ -71,15 +81,23 @@ interface CacheRow {
71
81
  * the payload row's `updated_at`, so a payload rewritten by another binary
72
82
  * silently invalidates it.
73
83
  */
74
- const SELECT_CACHE_ROW = `
75
- SELECT m.*,
76
- r.payload_updated_at AS refresh_payload_updated_at,
77
- r.updated_at AS refresh_updated_at,
78
- r.authoritative AS refresh_authoritative
84
+ const CACHE_ROW_FROM = `
79
85
  FROM model_cache m
80
86
  LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
81
87
  WHERE m.provider_id = ?
82
88
  `;
89
+ const CACHE_ROW_REFRESH_COLUMNS = `
90
+ r.payload_updated_at AS refresh_payload_updated_at,
91
+ r.updated_at AS refresh_updated_at,
92
+ r.authoritative AS refresh_authoritative`;
93
+ const SELECT_CACHE_ROW = `SELECT m.*, ${CACHE_ROW_REFRESH_COLUMNS} ${CACHE_ROW_FROM}`;
94
+ /** Scalar columns plus `models_hash`: validates a memoized parse without reading the multi-MB payload. */
95
+ const SELECT_CACHE_ROW_META = `
96
+ SELECT m.provider_id, m.version, m.materialization_policy, m.updated_at, m.authoritative,
97
+ m.static_fingerprint, m.models_hash, m.header_omitted_model_ids,
98
+ m.unrestorable_header_model_ids, m.header_restore_version, ${CACHE_ROW_REFRESH_COLUMNS}
99
+ ${CACHE_ROW_FROM}
100
+ `;
83
101
 
84
102
  /** Cumulative model-cache write counters for this process. */
85
103
  export interface ModelCacheWriteStats {
@@ -149,8 +167,11 @@ let sharedDbPath: string | null = null;
149
167
  interface ReadRowCacheEntry {
150
168
  /** `PRAGMA data_version` of the shared connection when validated; null for per-call connections. */
151
169
  dataVersion: number | null;
152
- /** Raw row the entry was parsed from; null when the row was absent or rejected. */
153
- row: CacheRow | null;
170
+ /**
171
+ * Scalar columns (plus `models_hash`) of the row the entry was parsed from;
172
+ * null when the row was absent or rejected. Never the multi-MB payload itself.
173
+ */
174
+ meta: CacheRowMeta | null;
154
175
  entry: CacheEntry<Api> | null;
155
176
  }
156
177
 
@@ -182,7 +203,7 @@ function withFreshness<TApi extends Api>(
182
203
  }
183
204
  function invalidateReadRow(providerId: string, dbPath?: string): void {
184
205
  try {
185
- readRowCache.delete(readCacheKey(dbPath ?? getModelDbPath(), providerId));
206
+ readRowCache.delete(readCacheKey(resolveCacheDb(dbPath).resolvedPath, providerId));
186
207
  } catch {
187
208
  // Best-effort only; a missed invalidation just costs one extra parse.
188
209
  }
@@ -213,6 +234,7 @@ function initializeDb(db: Database): void {
213
234
  header_omitted_model_ids TEXT NOT NULL DEFAULT '[]',
214
235
  unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]',
215
236
  header_restore_version INTEGER NOT NULL DEFAULT 0,
237
+ models_hash TEXT NOT NULL DEFAULT '',
216
238
  models TEXT NOT NULL
217
239
  )
218
240
  `);
@@ -225,6 +247,17 @@ function initializeDb(db: Database): void {
225
247
  )
226
248
  `);
227
249
  migrateCacheSchema(db);
250
+ // `models_hash` is the only payload-change detector for the read memo and the
251
+ // unchanged-write skip. Writers here replace whole rows; this keeps an
252
+ // in-place edit of `models` by any other tool or binary from reusing a stale hash.
253
+ db.run(`
254
+ CREATE TRIGGER IF NOT EXISTS model_cache_models_hash_reset
255
+ AFTER UPDATE OF models ON model_cache
256
+ WHEN NEW.models_hash = OLD.models_hash
257
+ BEGIN
258
+ UPDATE model_cache SET models_hash = '' WHERE provider_id = NEW.provider_id;
259
+ END
260
+ `);
228
261
  }
229
262
 
230
263
  function closeSharedDb(): void {
@@ -280,8 +313,22 @@ function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: D
280
313
  }
281
314
 
282
315
  function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
283
- const resolvedPath = dbPath ?? getModelDbPath();
284
- return runModelCacheDb(resolvedPath, dbPath === undefined, useDb);
316
+ const { resolvedPath, shared } = resolveCacheDb(dbPath);
317
+ return runModelCacheDb(resolvedPath, shared, useDb);
318
+ }
319
+
320
+ /**
321
+ * An explicit path naming the default `models.db` (e.g. the SDK passing
322
+ * `getModelDbPath(agentDir)` for the default agent dir) shares the long-lived
323
+ * handle, so it gets the `data_version` fast path instead of opening,
324
+ * initializing and re-reading the row on every call.
325
+ */
326
+ function resolveCacheDb(dbPath: string | undefined): { resolvedPath: string; shared: boolean } {
327
+ const defaultPath = getModelDbPath();
328
+ if (dbPath === undefined || dbPath === defaultPath || path.resolve(dbPath) === path.resolve(defaultPath)) {
329
+ return { resolvedPath: defaultPath, shared: true };
330
+ }
331
+ return { resolvedPath: dbPath, shared: false };
285
332
  }
286
333
 
287
334
  function migrateCacheSchema(db: Database): void {
@@ -306,6 +353,12 @@ function migrateCacheSchema(db: Database): void {
306
353
  // header matching was introduced.
307
354
  db.run("ALTER TABLE model_cache ADD COLUMN header_restore_version INTEGER NOT NULL DEFAULT 0");
308
355
  }
356
+ if (!columns.some(column => column.name === "models_hash")) {
357
+ // Rows from older binaries keep '' (never equal), so they are re-read in
358
+ // full until the next write records their hash; no version bump needed.
359
+ // Older binaries rewriting a row via INSERT OR REPLACE reset it to ''.
360
+ db.run("ALTER TABLE model_cache ADD COLUMN models_hash TEXT NOT NULL DEFAULT ''");
361
+ }
309
362
  } finally {
310
363
  stmt.finalize();
311
364
  }
@@ -419,15 +472,14 @@ export function readModelCache<TApi extends Api>(
419
472
  dbPath?: string,
420
473
  ): CacheEntry<TApi> | null {
421
474
  try {
422
- const resolvedPath = dbPath ?? getModelDbPath();
423
- const key = readCacheKey(resolvedPath, providerId);
424
475
  // Monotonic change signal: same-shaped WAL overwrites after checkpoint
425
476
  // can leave every size:mtime pair identical, so file metadata alone
426
477
  // cannot invalidate. PRAGMA data_version increments on each committed
427
478
  // write transaction visible to a new reader. It is only comparable
428
479
  // across calls on the same connection, so it gates the no-query fast
429
480
  // path for the shared handle only.
430
- const shared = dbPath === undefined;
481
+ const { resolvedPath, shared } = resolveCacheDb(dbPath);
482
+ const key = readCacheKey(resolvedPath, providerId);
431
483
  return runModelCacheDb(resolvedPath, shared, db => {
432
484
  const dataVersion = shared ? dbDataVersion(db) : null;
433
485
  const cached = readRowCache.get(key);
@@ -436,34 +488,32 @@ export function readModelCache<TApi extends Api>(
436
488
  // cached row's updatedAt so a long-lived process goes stale.
437
489
  return withFreshness(cached.entry as CacheEntry<TApi> | null, ttlMs, now);
438
490
  }
439
- // Any commit (to any provider's row) bumps data_version. Re-read this
440
- // row, but reuse the parsed entry when its bytes are unchanged.
441
- const stmt = db.query<CacheRow, [string]>(SELECT_CACHE_ROW);
442
- let row: CacheRow | null;
443
- try {
444
- row = stmt.get(providerId);
445
- } finally {
446
- stmt.finalize();
491
+ // Any commit (to any provider's row) bumps data_version. Validate the
492
+ // memoized parse against the row's scalar columns and payload hash
493
+ // first; only a changed payload pays for the multi-MB `models` read.
494
+ let entry: CacheEntry<TApi> | null = null;
495
+ let meta: CacheRowMeta | null = null;
496
+ if (cached?.entry && cached.meta) {
497
+ meta = queryRow<CacheRowMeta>(db, SELECT_CACHE_ROW_META, providerId);
498
+ if (meta && meta.materialization_policy === materializationPolicy() && cacheRowsEqual(cached.meta, meta)) {
499
+ // Payload unchanged; only side-table freshness may have moved.
500
+ entry = withFreshness(withRowState(cached.entry as CacheEntry<TApi>, meta), ttlMs, now);
501
+ } else {
502
+ meta = null;
503
+ }
447
504
  }
448
- let entry: CacheEntry<TApi> | null;
449
- if (
450
- cached?.entry &&
451
- cached.row &&
452
- row &&
453
- row.materialization_policy === materializationPolicy() &&
454
- cacheRowsEqual(cached.row, row)
455
- ) {
456
- // Payload bytes unchanged; only side-table freshness may have moved.
457
- entry = withFreshness(withRowState(cached.entry as CacheEntry<TApi>, row), ttlMs, now);
458
- } else {
505
+ if (entry === null) {
506
+ // One statement reads payload and scalars from the same snapshot.
507
+ const row = queryRow<CacheRow>(db, SELECT_CACHE_ROW, providerId);
459
508
  entry = parseCacheRow<TApi>(db, providerId, row, ttlMs, now);
509
+ if (entry !== null && row !== null) {
510
+ // Drop the payload so the memo never pins the multi-MB `models` string.
511
+ const { models: _models, ...rowScalars } = row;
512
+ meta = rowScalars;
513
+ }
460
514
  }
461
515
  if (readRowCache.size >= READ_ROW_CACHE_MAX) readRowCache.clear();
462
- readRowCache.set(key, {
463
- dataVersion,
464
- row: entry === null ? null : row,
465
- entry: entry as CacheEntry<Api> | null,
466
- });
516
+ readRowCache.set(key, { dataVersion, meta, entry: entry as CacheEntry<Api> | null });
467
517
  return entry;
468
518
  });
469
519
  } catch {
@@ -471,8 +521,20 @@ export function readModelCache<TApi extends Api>(
471
521
  }
472
522
  }
473
523
 
474
- /** Whether two rows carry the same payload record (refresh side-row columns excluded). */
475
- function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
524
+ function queryRow<T>(db: Database, sql: string, providerId: string): T | null {
525
+ const stmt = db.query<T, [string]>(sql);
526
+ try {
527
+ return stmt.get(providerId);
528
+ } finally {
529
+ stmt.finalize();
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Whether two rows carry the same payload record (refresh side-row columns
535
+ * excluded). Payloads compare by `models_hash`; rows without one never match.
536
+ */
537
+ function cacheRowsEqual(left: CacheRowMeta, right: CacheRowMeta): boolean {
476
538
  return (
477
539
  left.version === right.version &&
478
540
  left.materialization_policy === right.materialization_policy &&
@@ -482,7 +544,8 @@ function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
482
544
  left.header_omitted_model_ids === right.header_omitted_model_ids &&
483
545
  left.unrestorable_header_model_ids === right.unrestorable_header_model_ids &&
484
546
  left.header_restore_version === right.header_restore_version &&
485
- left.models === right.models
547
+ left.models_hash !== "" &&
548
+ left.models_hash === right.models_hash
486
549
  );
487
550
  }
488
551
 
@@ -498,7 +561,7 @@ function rowState(row: StoredRowState): { updatedAt: number; authoritative: bool
498
561
  return { updatedAt: row.updated_at, authoritative: row.authoritative === 1 };
499
562
  }
500
563
 
501
- function withRowState<TApi extends Api>(entry: CacheEntry<TApi>, row: CacheRow): CacheEntry<TApi> {
564
+ function withRowState<TApi extends Api>(entry: CacheEntry<TApi>, row: StoredRowState): CacheEntry<TApi> {
502
565
  const { updatedAt, authoritative } = rowState(row);
503
566
  return entry.updatedAt === updatedAt && entry.authoritative === authoritative
504
567
  ? entry
@@ -648,6 +711,8 @@ export function writeModelCache<TApi extends Api>(
648
711
  const serializedHeaderOmitted = JSON.stringify(headerOmittedModelIds);
649
712
  const serializedUnrestorable = JSON.stringify(unrestorableHeaderModelIds);
650
713
  const serializedModels = JSON.stringify(cachedModels);
714
+ // Length prefix: a collision would also need an equal payload size.
715
+ const modelsHash = `${serializedModels.length.toString(36)}:${Bun.hash(serializedModels).toString(36)}`;
651
716
  const outcome = db
652
717
  .transaction((): "skipped" | "refreshed" | "written" => {
653
718
  const matchStmt = db.prepare<
@@ -662,7 +727,7 @@ export function writeModelCache<TApi extends Api>(
662
727
  LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
663
728
  WHERE m.provider_id = ? AND m.version = ? AND m.materialization_policy = ?
664
729
  AND m.static_fingerprint = ? AND m.header_omitted_model_ids = ?
665
- AND m.unrestorable_header_model_ids = ? AND m.header_restore_version = ? AND m.models = ?`,
730
+ AND m.unrestorable_header_model_ids = ? AND m.header_restore_version = ? AND m.models_hash = ?`,
666
731
  );
667
732
  let stored: StoredRowState | null;
668
733
  try {
@@ -674,7 +739,7 @@ export function writeModelCache<TApi extends Api>(
674
739
  serializedHeaderOmitted,
675
740
  serializedUnrestorable,
676
741
  HEADER_RESTORE_VERSION,
677
- serializedModels,
742
+ modelsHash,
678
743
  );
679
744
  } finally {
680
745
  matchStmt.finalize();
@@ -697,8 +762,8 @@ export function writeModelCache<TApi extends Api>(
697
762
  `INSERT OR REPLACE INTO model_cache (
698
763
  provider_id, version, materialization_policy, updated_at, authoritative, static_fingerprint,
699
764
  header_omitted_model_ids, unrestorable_header_model_ids,
700
- header_restore_version, models
701
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
765
+ header_restore_version, models_hash, models
766
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
702
767
  [
703
768
  providerId,
704
769
  CACHE_SCHEMA_VERSION,
@@ -709,6 +774,7 @@ export function writeModelCache<TApi extends Api>(
709
774
  serializedHeaderOmitted,
710
775
  serializedUnrestorable,
711
776
  HEADER_RESTORE_VERSION,
777
+ modelsHash,
712
778
  serializedModels,
713
779
  ],
714
780
  );
@@ -35,6 +35,22 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
35
35
  cacheTtlMs?: number;
36
36
  /** When true, a successful dynamic fetch is the complete provider catalog and prunes static-only models. */
37
37
  dynamicModelsAuthoritative?: boolean;
38
+ /**
39
+ * When true, a provider's dynamic rows are the deployment's whole truth
40
+ * for input modality: a live text-only row strips the bundled reference's
41
+ * image support instead of OR-merging it back. Declared by providers whose
42
+ * capability flags are authoritative and whose endpoints reject
43
+ * unsupported content (`github-copilot`, `deepinfra`, `coralbricks`).
44
+ */
45
+ dynamicInputAuthoritative?: boolean;
46
+ /** When true, live reasoning capability replaces the fallback, including an explicit false. */
47
+ dynamicReasoningAuthoritative?: boolean;
48
+ /**
49
+ * When true, a live per-million price replaces the fallback even when it is
50
+ * an explicit `0` (a free model or a dropped cache-write charge). Without it,
51
+ * a zero discovery price is treated as unknown and the bundled rate is kept.
52
+ */
53
+ dynamicCostAuthoritative?: boolean;
38
54
  /**
39
55
  * When true, a fresh cache never satisfies an online-eligible refresh: the
40
56
  * dynamic fetch always runs (an explicit `"offline"` strategy is still