@oh-my-pi/pi-catalog 18.2.0 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/types/compat/behavior.d.ts +11 -0
  3. package/dist/types/compat/cascade.d.ts +10 -0
  4. package/dist/types/compat/collapse.d.ts +8 -0
  5. package/dist/types/compat/resolve.d.ts +10 -0
  6. package/dist/types/compat/types.d.ts +21 -0
  7. package/dist/types/discovery/antigravity.d.ts +10 -1
  8. package/dist/types/model-thinking.d.ts +7 -0
  9. package/dist/types/provider-models/openai-compat.d.ts +2 -0
  10. package/dist/types/types.d.ts +36 -1
  11. package/package.json +4 -4
  12. package/src/build.ts +59 -0
  13. package/src/compat/axes.ts +15 -0
  14. package/src/compat/behavior.ts +22 -2
  15. package/src/compat/cascade.ts +27 -1
  16. package/src/compat/collapse.ts +100 -8
  17. package/src/compat/context-window.ts +11 -1
  18. package/src/compat/resolve.ts +56 -17
  19. package/src/compat/rules/README.md +3 -1
  20. package/src/compat/rules/classes/deepseek.kdl +9 -1
  21. package/src/compat/rules/classes/kimi.kdl +6 -0
  22. package/src/compat/rules/providers/alibaba-token-plan.kdl +16 -8
  23. package/src/compat/rules/providers/amazon-bedrock.kdl +30 -0
  24. package/src/compat/rules/providers/azure.kdl +6 -0
  25. package/src/compat/rules/providers/cerebras.kdl +10 -0
  26. package/src/compat/rules/providers/commandcode.kdl +20 -4
  27. package/src/compat/rules/providers/cursor.kdl +32 -0
  28. package/src/compat/rules/providers/deepseek.kdl +5 -5
  29. package/src/compat/rules/providers/devin.kdl +46 -0
  30. package/src/compat/rules/providers/google-vertex.kdl +15 -0
  31. package/src/compat/rules/providers/kimi-code.kdl +48 -0
  32. package/src/compat/rules/providers/meta.kdl +3 -0
  33. package/src/compat/rules/providers/muse-code.kdl +3 -0
  34. package/src/compat/rules/providers/openrouter.kdl +6 -0
  35. package/src/compat/rules/runtime/behavior.kdl +17 -0
  36. package/src/compat/rules/taxonomy/deepseek.kdl +5 -0
  37. package/src/compat/rules.json +1 -1
  38. package/src/compat/types.ts +23 -0
  39. package/src/discovery/antigravity.ts +80 -43
  40. package/src/discovery/devin.ts +22 -4
  41. package/src/identity/bundled.ts +4 -3
  42. package/src/model-cache.ts +154 -120
  43. package/src/model-thinking.ts +10 -7
  44. package/src/models.json +1 -1
  45. package/src/provider-models/bundled-references.ts +4 -3
  46. package/src/provider-models/cache-provider-id.ts +14 -8
  47. package/src/provider-models/ollama.ts +11 -31
  48. package/src/provider-models/openai-compat.ts +297 -37
  49. package/src/types.ts +38 -0
@@ -293,6 +293,12 @@ export interface CompiledExcludeModels {
293
293
  match: CompiledMatchList;
294
294
  }
295
295
 
296
+ /** Exact upstream discovery modes excluded from one provider's coding-model roster. */
297
+ export interface CompiledExcludeDiscoveryModes {
298
+ provider: string;
299
+ modes: string[];
300
+ }
301
+
296
302
  /** Provider plan-requirement tiers keyed by matcher token lists. */
297
303
  export interface CompiledPlanRequirement {
298
304
  provider: string;
@@ -306,6 +312,12 @@ export interface CompiledPricingPeer {
306
312
  aliases: { model: string; peerId: string }[];
307
313
  }
308
314
 
315
+ /** Provider timezone assumption for offset-less absolute retry-reset timestamps. */
316
+ export interface CompiledRetryResetTimezone {
317
+ provider: string;
318
+ offset: string;
319
+ }
320
+
309
321
  /** Compiled runtime behavior vocabulary (`runtime/behavior.kdl`). */
310
322
  export interface CompiledBehavior {
311
323
  openaiResponsesHeuristic?: CompiledResponsesHeuristic;
@@ -316,10 +328,13 @@ export interface CompiledBehavior {
316
328
  hostedDefaults: CompiledHostedDefault[];
317
329
  apiRoutes: CompiledApiRoutes[];
318
330
  modelLimits: CompiledModelLimits[];
331
+ excludeDiscoveryModes: CompiledExcludeDiscoveryModes[];
319
332
  excludeModels: CompiledExcludeModels[];
320
333
  planRequirements: CompiledPlanRequirement[];
321
334
  pricingPeers: CompiledPricingPeer[];
335
+ retryResetTimezones: CompiledRetryResetTimezone[];
322
336
  retiredProviders: string[];
337
+ referenceIsolatedProviders: string[];
323
338
  }
324
339
 
325
340
  /**
@@ -672,4 +687,12 @@ export interface ResolvedAxes {
672
687
  wire: Record<string, unknown>;
673
688
  thinking: Record<string, unknown>;
674
689
  catalog: Record<string, unknown>;
690
+ /**
691
+ * Reasoning capability after the exact-model effort upgrade: `true` when the
692
+ * target reported reasoning or an exact rule declares a ladder for it (the
693
+ * reviewed correction to metadata-less discovery rows). Compat resolvers
694
+ * read this instead of the raw spec flag, or one id resolves two different
695
+ * wire contracts depending on whether it came from discovery or the bake.
696
+ */
697
+ reasoning: boolean;
675
698
  }
@@ -1,4 +1,5 @@
1
1
  import { type } from "@oh-my-pi/omptype";
2
+ import type { FetchImpl } from "@oh-my-pi/pi-utils";
2
3
  import { collapseVariants, type VariantCollapseTable } from "../compat/collapse";
3
4
  import type { ModelSpec } from "../types";
4
5
  import { discoveryFetch, toPositiveNumber } from "../utils";
@@ -51,6 +52,7 @@ export interface AntigravityDiscoveryAgentModelSort {
51
52
  export interface AntigravityDiscoveryApiResponse {
52
53
  models?: Record<string, AntigravityDiscoveryApiModel>;
53
54
  agentModelSorts?: AntigravityDiscoveryAgentModelSort[];
55
+ imageGenerationModelIds?: string[];
54
56
  }
55
57
  const AntigravityDiscoveryApiModelSchema = type({
56
58
  "displayName?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
@@ -123,6 +125,9 @@ const AntigravityDiscoveryApiResponseSchema = type({
123
125
  }
124
126
  return result;
125
127
  }),
128
+ "imageGenerationModelIds?": type("unknown").pipe(value =>
129
+ Array.isArray(value) ? value.filter((modelId): modelId is string => typeof modelId === "string") : undefined,
130
+ ),
126
131
  });
127
132
  /**
128
133
  * Options for fetching Antigravity discovery models.
@@ -139,7 +144,7 @@ export interface FetchAntigravityDiscoveryModelsOptions {
139
144
  /** Optional abort signal for request cancellation. */
140
145
  signal?: AbortSignal;
141
146
  /** Optional fetch implementation override for tests. */
142
- fetcher?: typeof fetch;
147
+ fetcher?: FetchImpl;
143
148
  /**
144
149
  * Hand collapse table to apply to the discovered list. Defaults to the
145
150
  * Antigravity (budget-transport) table; `googleGeminiCli` passes the
@@ -157,6 +162,78 @@ export interface FetchAntigravityDiscoveryModelsOptions {
157
162
  export async function fetchAntigravityDiscoveryModels(
158
163
  options: FetchAntigravityDiscoveryModelsOptions,
159
164
  ): Promise<ModelSpec<"google-gemini-cli">[] | null> {
165
+ const discovered = await fetchAntigravityDiscoveryResponse(options);
166
+ if (!discovered) {
167
+ return null;
168
+ }
169
+
170
+ const models: ModelSpec<"google-gemini-cli">[] = [];
171
+ const apiModels = discovered.payload.models;
172
+ if (apiModels) {
173
+ for (const modelId in apiModels) {
174
+ const model = apiModels[modelId];
175
+ if (ANTIGRAVITY_DISCOVERY_DENYLIST.has(modelId)) {
176
+ continue;
177
+ }
178
+ if (model.isInternal === true) {
179
+ continue;
180
+ }
181
+
182
+ const supportsImages = model.supportsImages === true;
183
+ models.push({
184
+ id: modelId,
185
+ name: model.displayName || modelId,
186
+ api: "google-gemini-cli",
187
+ provider: "google-antigravity",
188
+ baseUrl: discovered.endpoint,
189
+ reasoning: model.supportsThinking === true,
190
+ input: supportsImages ? ["text", "image"] : ["text"],
191
+ cost: {
192
+ input: 0,
193
+ output: 0,
194
+ cacheRead: 0,
195
+ cacheWrite: 0,
196
+ },
197
+ contextWindow: toPositiveNumber(model.maxTokens, DEFAULT_CONTEXT_WINDOW),
198
+ maxTokens: toPositiveNumber(model.maxOutputTokens, DEFAULT_MAX_TOKENS),
199
+ });
200
+ }
201
+ }
202
+
203
+ // Collapse effort-tier variants at the source so runtime discovery,
204
+ // the gemini-cli re-provision, and the catalog generator all see
205
+ // logical ids only.
206
+ const collapsed = collapseVariants(
207
+ models,
208
+ options.collapseTable === undefined ? undefined : { table: options.collapseTable },
209
+ );
210
+ collapsed.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
211
+ return collapsed;
212
+ }
213
+
214
+ /** Advertised image model and serving endpoint for one Antigravity account. */
215
+ export interface AntigravityImageModel {
216
+ id: string;
217
+ endpoint: string;
218
+ }
219
+
220
+ /** Resolves the first image-generation model advertised by an Antigravity account. */
221
+ export async function fetchAntigravityImageModel(
222
+ options: FetchAntigravityDiscoveryModelsOptions,
223
+ ): Promise<AntigravityImageModel | null> {
224
+ 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;
227
+ }
228
+
229
+ interface AntigravityDiscoveryResponse {
230
+ payload: AntigravityDiscoveryApiResponse;
231
+ endpoint: string;
232
+ }
233
+
234
+ async function fetchAntigravityDiscoveryResponse(
235
+ options: FetchAntigravityDiscoveryModelsOptions,
236
+ ): Promise<AntigravityDiscoveryResponse | null> {
160
237
  if (options.userAgent === undefined) {
161
238
  await ensureAntigravityVersion(options.fetcher ?? fetch, options.signal);
162
239
  }
@@ -195,49 +272,9 @@ export async function fetchAntigravityDiscoveryModels(
195
272
  }
196
273
 
197
274
  const parsed = parseAntigravityDiscoveryResponse(payload);
198
- if (!parsed) {
199
- continue;
275
+ if (parsed) {
276
+ return { payload: parsed, endpoint };
200
277
  }
201
-
202
- const models: ModelSpec<"google-gemini-cli">[] = [];
203
-
204
- for (const [modelId, model] of Object.entries(parsed.models ?? {})) {
205
- if (ANTIGRAVITY_DISCOVERY_DENYLIST.has(modelId)) {
206
- continue;
207
- }
208
- if (model.isInternal === true) {
209
- continue;
210
- }
211
-
212
- const supportsImages = model.supportsImages === true;
213
- models.push({
214
- id: modelId,
215
- name: model.displayName || modelId,
216
- api: "google-gemini-cli",
217
- provider: "google-antigravity",
218
- baseUrl: endpoint,
219
- reasoning: model.supportsThinking === true,
220
- input: supportsImages ? ["text", "image"] : ["text"],
221
- cost: {
222
- input: 0,
223
- output: 0,
224
- cacheRead: 0,
225
- cacheWrite: 0,
226
- },
227
- contextWindow: toPositiveNumber(model.maxTokens, DEFAULT_CONTEXT_WINDOW),
228
- maxTokens: toPositiveNumber(model.maxOutputTokens, DEFAULT_MAX_TOKENS),
229
- });
230
- }
231
-
232
- // Collapse effort-tier variants at the source so runtime discovery,
233
- // the gemini-cli re-provision, and the catalog generator all see
234
- // logical ids only.
235
- const collapsed = collapseVariants(
236
- models,
237
- options.collapseTable === undefined ? undefined : { table: options.collapseTable },
238
- );
239
- collapsed.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
240
- return collapsed;
241
278
  }
242
279
 
243
280
  return null;
@@ -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) {
@@ -6,6 +6,7 @@
6
6
  * non-bundled reference data use the pure builder directly
7
7
  * ({@link buildModelReferenceIndex}).
8
8
  */
9
+ import { isBareIdReferenceProvider } from "../compat/behavior";
9
10
  import { getBundledModels, getBundledProviders } from "../models";
10
11
  import type { Api, Model } from "../types";
11
12
  import { buildModelReferenceIndex, type ModelReferenceIndex } from "./reference";
@@ -13,9 +14,9 @@ import { buildModelReferenceIndex, type ModelReferenceIndex } from "./reference"
13
14
  let bundledModels: readonly Model<Api>[] | undefined;
14
15
 
15
16
  function getBundledModelList(): readonly Model<Api>[] {
16
- bundledModels ??= getBundledProviders().flatMap(
17
- provider => getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[],
18
- );
17
+ bundledModels ??= getBundledProviders()
18
+ .filter(isBareIdReferenceProvider)
19
+ .flatMap(provider => getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[]);
19
20
  return bundledModels;
20
21
  }
21
22
 
@@ -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
 
@@ -31,12 +30,19 @@ const HEADER_RESTORE_VERSION = 1;
31
30
  * Explicit compatibility gate for materialized rows. Bump whenever buildModel
32
31
  * semantics change without an app-version change. The compiled-rules content
33
32
  * hash catches every KDL policy edit even when the package version is unchanged;
34
- * it is computed once per process rather than once per provider or model.
33
+ * computed lazily on first cache access and memoized, so processes that never
34
+ * touch the model cache skip the stringify entirely.
35
35
  */
36
36
  const MODEL_MATERIALIZATION_VERSION = 1;
37
- const MATERIALIZATION_POLICY =
38
- `app-${VERSION}:builder-${MODEL_MATERIALIZATION_VERSION}:rules-${RULES.version}-` +
39
- Bun.hash(JSON.stringify(RULES)).toString(36);
37
+ let cachedMaterializationPolicy: string | undefined;
38
+ function materializationPolicy(): string {
39
+ if (cachedMaterializationPolicy === undefined) {
40
+ cachedMaterializationPolicy =
41
+ `app-${VERSION}:builder-${MODEL_MATERIALIZATION_VERSION}:rules-${RULES.version}-` +
42
+ Bun.hash(JSON.stringify(RULES)).toString(36);
43
+ }
44
+ return cachedMaterializationPolicy;
45
+ }
40
46
 
41
47
  interface CacheRow {
42
48
  provider_id: string;
@@ -88,14 +94,50 @@ export interface CacheEntry<TApi extends Api = Api> {
88
94
  let sharedDb: Database | null = null;
89
95
  let sharedDbPath: string | null = null;
90
96
 
91
- function openDb(resolvedPath: string): Database {
92
- const db = new Database(resolvedPath, { create: true });
93
- // Install the busy handler BEFORE any lock-taking statement. See
94
- // https://github.com/can1357/oh-my-pi/issues/2421.
95
- db.run("PRAGMA busy_timeout = 3000");
96
- // Schema invalidation can delete rows containing credentials written by old
97
- // versions. Overwrite deleted SQLite cells instead of leaving their bytes in
98
- // free pages where a raw scan of models.db can still recover them (#5780).
97
+ const readRowCache = new Map<string, { dataVersion: number; entry: CacheEntry<Api> | null }>();
98
+ const READ_ROW_CACHE_MAX = 64;
99
+
100
+ function readCacheKey(resolvedPath: string, providerId: string): string {
101
+ return `${resolvedPath} ${providerId}`;
102
+ }
103
+
104
+ function dbDataVersion(db: Database): number | null {
105
+ try {
106
+ const row = db.query<{ data_version: number }, []>("PRAGMA data_version").get();
107
+ return typeof row?.data_version === "number" ? row.data_version : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function withFreshness<TApi extends Api>(
114
+ entry: CacheEntry<TApi> | null,
115
+ ttlMs: number,
116
+ now: () => number,
117
+ ): CacheEntry<TApi> | null {
118
+ if (entry === null) return null;
119
+ const ageMs = now() - entry.updatedAt;
120
+ const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
121
+ return fresh === entry.fresh ? entry : { ...entry, fresh };
122
+ }
123
+ function invalidateReadRow(providerId: string, dbPath?: string): void {
124
+ try {
125
+ readRowCache.delete(readCacheKey(dbPath ?? getModelDbPath(), providerId));
126
+ } catch {
127
+ // Best-effort only; a missed invalidation just costs one extra parse.
128
+ }
129
+ }
130
+
131
+ function invalidateReadPath(resolvedPath: string): void {
132
+ for (const key of readRowCache.keys()) {
133
+ if (key.startsWith(`${resolvedPath} `)) readRowCache.delete(key);
134
+ }
135
+ }
136
+
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).
99
141
  db.run("PRAGMA secure_delete = ON");
100
142
  db.run("PRAGMA journal_mode = WAL");
101
143
  db.run(`
@@ -113,91 +155,53 @@ function openDb(resolvedPath: string): Database {
113
155
  )
114
156
  `);
115
157
  migrateCacheSchema(db);
116
- return db;
117
158
  }
118
159
 
119
- function getSharedDb(resolvedPath: string): Database {
120
- if (sharedDb && sharedDbPath === resolvedPath) {
121
- return sharedDb;
122
- }
123
- if (sharedDb) {
124
- sharedDb.close();
125
- sharedDb = null;
126
- sharedDbPath = null;
127
- }
128
- const db = openDb(resolvedPath);
129
- sharedDb = db;
130
- sharedDbPath = resolvedPath;
131
- return db;
160
+ function closeSharedDb(): void {
161
+ if (!sharedDb) return;
162
+ sharedDb.close();
163
+ sharedDb = null;
164
+ sharedDbPath = null;
132
165
  }
133
166
 
134
167
  function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: Database) => T): T {
135
- if (shared) return useDb(getSharedDb(resolvedPath));
136
- const db = openDb(resolvedPath);
137
- try {
138
- return useDb(db);
139
- } finally {
140
- db.close();
141
- }
142
- }
143
-
144
- // Paths already reported corrupt this process: the first unrecoverable failure
145
- // is logged at `error`, later heals at `debug`, so a dying disk cannot spam.
146
- const reportedCorruptPaths = new Set<string>();
147
-
148
- /**
149
- * Move a physically corrupt `models.db` (plus its `-wal`/`-shm` sidecars) aside
150
- * so {@link openDb} can recreate a fresh cache at the original path. Renames are
151
- * best-effort: a vanished sidecar (already healed by a peer process) is fine,
152
- * and any other rename failure is left for {@link openDb} to surface.
153
- */
154
- function quarantineCorruptModelCache(resolvedPath: string): void {
155
- const stamp = Date.now();
156
- for (const suffix of ["", "-wal", "-shm"]) {
168
+ if (shared && sharedDb && sharedDbPath !== resolvedPath) closeSharedDb();
169
+ if (shared && sharedDb) {
157
170
  try {
158
- renameSync(`${resolvedPath}${suffix}`, `${resolvedPath}.corrupt-${stamp}${suffix}`);
159
- } catch (err) {
160
- if (!isEnoent(err)) {
161
- logger.debug("model cache: could not quarantine corrupt file", { path: `${resolvedPath}${suffix}` });
162
- }
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);
163
179
  }
164
180
  }
165
- }
166
181
 
167
- /**
168
- * Recover from unrecoverable `models.db` corruption: drop the cached handle,
169
- * quarantine the broken files, and let the next open recreate the cache. A
170
- * corrupt cache would otherwise be re-queried on every read/write forever,
171
- * permanently masking a successful live catalog (issue #8867). Only
172
- * {@link isSqliteCorruptionError} codes reach here; BUSY/permission errors keep
173
- * their existing best-effort paths.
174
- */
175
- function healCorruptModelCache(resolvedPath: string, shared: boolean, err: unknown): void {
176
- if (shared && sharedDb) {
177
- sharedDb.close();
178
- sharedDb = null;
179
- sharedDbPath = null;
180
- }
181
- quarantineCorruptModelCache(resolvedPath);
182
- const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
183
- if (reportedCorruptPaths.has(resolvedPath)) {
184
- logger.debug("model cache: re-healed corrupt database", { path: resolvedPath, code });
185
- } else {
186
- reportedCorruptPaths.add(resolvedPath);
187
- logger.error("model cache corrupt; quarantined and recreated a fresh cache", { path: resolvedPath, code });
188
- }
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
+ );
189
200
  }
190
201
 
191
202
  function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
192
203
  const resolvedPath = dbPath ?? getModelDbPath();
193
- const shared = dbPath === undefined;
194
- try {
195
- return runModelCacheDb(resolvedPath, shared, useDb);
196
- } catch (err) {
197
- if (!isSqliteCorruptionError(err)) throw err;
198
- healCorruptModelCache(resolvedPath, shared, err);
199
- return runModelCacheDb(resolvedPath, shared, useDb);
200
- }
204
+ return runModelCacheDb(resolvedPath, dbPath === undefined, useDb);
201
205
  }
202
206
 
203
207
  function migrateCacheSchema(db: Database): void {
@@ -232,7 +236,7 @@ function migrateCacheSchema(db: Database): void {
232
236
  // compaction path even after CACHE_SCHEMA_VERSION was bumped).
233
237
  db.run("DELETE FROM model_cache WHERE version <> ? OR materialization_policy <> ?", [
234
238
  CACHE_SCHEMA_VERSION,
235
- MATERIALIZATION_POLICY,
239
+ materializationPolicy(),
236
240
  ]);
237
241
  }
238
242
 
@@ -313,7 +317,6 @@ function parseModelIds(serialized: string): string[] | null {
313
317
  return null;
314
318
  }
315
319
  }
316
-
317
320
  export function readModelCache<TApi extends Api>(
318
321
  providerId: string,
319
322
  ttlMs: number,
@@ -321,44 +324,74 @@ export function readModelCache<TApi extends Api>(
321
324
  dbPath?: string,
322
325
  ): CacheEntry<TApi> | null {
323
326
  try {
324
- return withModelCacheDb(dbPath, db => {
325
- const stmt = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?");
326
- try {
327
- const row = stmt.get(providerId);
328
- if (!row || row.version !== CACHE_SCHEMA_VERSION || row.materialization_policy !== MATERIALIZATION_POLICY) {
329
- return null;
330
- }
331
- const models = parseMaterializedModels<TApi>(row.models);
332
- const headerOmittedModelIds = parseModelIds(row.header_omitted_model_ids);
333
- const unrestorableHeaderModelIds = parseModelIds(row.unrestorable_header_model_ids);
334
- if (models === null || headerOmittedModelIds === null || unrestorableHeaderModelIds === null) {
335
- // Fail closed on corrupt header provenance: treating malformed
336
- // markers as empty could return a model with required credentials
337
- // silently absent. secure_delete scrubs the rejected payload.
338
- db.run("DELETE FROM model_cache WHERE provider_id = ?", [providerId]);
339
- return null;
327
+ const resolvedPath = dbPath ?? getModelDbPath();
328
+ const key = readCacheKey(resolvedPath, providerId);
329
+ // Monotonic change signal: same-shaped WAL overwrites after checkpoint
330
+ // can leave every size:mtime pair identical, so file metadata alone
331
+ // cannot invalidate. PRAGMA data_version increments on each committed
332
+ // write transaction visible to a new reader.
333
+ const entry = withModelCacheDb(dbPath, db => {
334
+ const dataVersion = dbDataVersion(db);
335
+ if (dataVersion !== null) {
336
+ const cached = readRowCache.get(key);
337
+ if (cached !== undefined && cached.dataVersion === dataVersion) {
338
+ // Freshness is time-relative: recompute per call from the
339
+ // cached row's updatedAt so a long-lived process goes stale.
340
+ return { hit: true as const, entry: withFreshness(cached.entry as CacheEntry<TApi> | null, ttlMs, now) };
340
341
  }
341
- const ageMs = now() - row.updated_at;
342
- const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
343
- return {
344
- models,
345
- fresh,
346
- authoritative: row.authoritative === 1,
347
- updatedAt: row.updated_at,
348
- headerOmittedModelIds,
349
- unrestorableHeaderModelIds,
350
- legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION,
351
- staticFingerprint: row.static_fingerprint ?? "",
352
- };
353
- } finally {
354
- stmt.finalize();
355
342
  }
343
+ const fresh = readRowUncached<TApi>(db, providerId, ttlMs, now);
344
+ if (dataVersion !== null) {
345
+ if (readRowCache.size >= READ_ROW_CACHE_MAX) readRowCache.clear();
346
+ readRowCache.set(key, { dataVersion, entry: fresh as CacheEntry<Api> | null });
347
+ }
348
+ return { hit: false as const, entry: fresh };
356
349
  });
350
+ return entry.entry;
357
351
  } catch {
358
352
  return null;
359
353
  }
360
354
  }
361
355
 
356
+ function readRowUncached<TApi extends Api>(
357
+ db: Database,
358
+ providerId: string,
359
+ ttlMs: number,
360
+ now: () => number,
361
+ ): CacheEntry<TApi> | null {
362
+ const stmt = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?");
363
+ try {
364
+ const row = stmt.get(providerId);
365
+ if (!row || row.version !== CACHE_SCHEMA_VERSION || row.materialization_policy !== materializationPolicy()) {
366
+ return null;
367
+ }
368
+ const models = parseMaterializedModels<TApi>(row.models);
369
+ const headerOmittedModelIds = parseModelIds(row.header_omitted_model_ids);
370
+ const unrestorableHeaderModelIds = parseModelIds(row.unrestorable_header_model_ids);
371
+ if (models === null || headerOmittedModelIds === null || unrestorableHeaderModelIds === null) {
372
+ // Fail closed on corrupt header provenance: treating malformed
373
+ // markers as empty could return a model with required credentials
374
+ // silently absent. secure_delete scrubs the rejected payload.
375
+ db.run("DELETE FROM model_cache WHERE provider_id = ?", [providerId]);
376
+ return null;
377
+ }
378
+ const ageMs = now() - row.updated_at;
379
+ const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
380
+ return {
381
+ models,
382
+ fresh,
383
+ authoritative: row.authoritative === 1,
384
+ updatedAt: row.updated_at,
385
+ headerOmittedModelIds,
386
+ unrestorableHeaderModelIds,
387
+ legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION,
388
+ staticFingerprint: row.static_fingerprint ?? "",
389
+ };
390
+ } finally {
391
+ stmt.finalize();
392
+ }
393
+ }
394
+
362
395
  /** Whether a live model carries at least one request header. */
363
396
  function hasModelHeaders(model: Model<Api>): boolean {
364
397
  const headers = model.headers;
@@ -406,6 +439,7 @@ export function writeModelCache<TApi extends Api>(
406
439
  restorableHeaderFallback?: Record<string, string>,
407
440
  ): void {
408
441
  try {
442
+ invalidateReadRow(providerId, dbPath);
409
443
  withModelCacheDb(dbPath, db => {
410
444
  const headerOmittedModelIds: string[] = [];
411
445
  const unrestorableHeaderModelIds: string[] = [];
@@ -442,7 +476,7 @@ export function writeModelCache<TApi extends Api>(
442
476
  [
443
477
  providerId,
444
478
  CACHE_SCHEMA_VERSION,
445
- MATERIALIZATION_POLICY,
479
+ materializationPolicy(),
446
480
  updatedAt,
447
481
  authoritative ? 1 : 0,
448
482
  staticFingerprint,