@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.
- package/CHANGELOG.md +39 -0
- package/dist/types/compat/behavior.d.ts +11 -0
- package/dist/types/compat/cascade.d.ts +10 -0
- package/dist/types/compat/collapse.d.ts +8 -0
- package/dist/types/compat/resolve.d.ts +10 -0
- package/dist/types/compat/types.d.ts +21 -0
- package/dist/types/discovery/antigravity.d.ts +10 -1
- package/dist/types/model-thinking.d.ts +7 -0
- package/dist/types/provider-models/openai-compat.d.ts +2 -0
- package/dist/types/types.d.ts +36 -1
- package/package.json +4 -4
- package/src/build.ts +59 -0
- package/src/compat/axes.ts +15 -0
- package/src/compat/behavior.ts +22 -2
- package/src/compat/cascade.ts +27 -1
- package/src/compat/collapse.ts +100 -8
- package/src/compat/context-window.ts +11 -1
- package/src/compat/resolve.ts +56 -17
- package/src/compat/rules/README.md +3 -1
- package/src/compat/rules/classes/deepseek.kdl +9 -1
- package/src/compat/rules/classes/kimi.kdl +6 -0
- package/src/compat/rules/providers/alibaba-token-plan.kdl +16 -8
- package/src/compat/rules/providers/amazon-bedrock.kdl +30 -0
- package/src/compat/rules/providers/azure.kdl +6 -0
- package/src/compat/rules/providers/cerebras.kdl +10 -0
- package/src/compat/rules/providers/commandcode.kdl +20 -4
- package/src/compat/rules/providers/cursor.kdl +32 -0
- package/src/compat/rules/providers/deepseek.kdl +5 -5
- package/src/compat/rules/providers/devin.kdl +46 -0
- package/src/compat/rules/providers/google-vertex.kdl +15 -0
- package/src/compat/rules/providers/kimi-code.kdl +48 -0
- package/src/compat/rules/providers/meta.kdl +3 -0
- package/src/compat/rules/providers/muse-code.kdl +3 -0
- package/src/compat/rules/providers/openrouter.kdl +6 -0
- package/src/compat/rules/runtime/behavior.kdl +17 -0
- package/src/compat/rules/taxonomy/deepseek.kdl +5 -0
- package/src/compat/rules.json +1 -1
- package/src/compat/types.ts +23 -0
- package/src/discovery/antigravity.ts +80 -43
- package/src/discovery/devin.ts +22 -4
- package/src/identity/bundled.ts +4 -3
- package/src/model-cache.ts +154 -120
- package/src/model-thinking.ts +10 -7
- package/src/models.json +1 -1
- package/src/provider-models/bundled-references.ts +4 -3
- package/src/provider-models/cache-provider-id.ts +14 -8
- package/src/provider-models/ollama.ts +11 -31
- package/src/provider-models/openai-compat.ts +297 -37
- package/src/types.ts +38 -0
package/src/compat/types.ts
CHANGED
|
@@ -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?:
|
|
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 (
|
|
199
|
-
|
|
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;
|
package/src/discovery/devin.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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) {
|
package/src/identity/bundled.ts
CHANGED
|
@@ -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()
|
|
17
|
-
|
|
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
|
|
package/src/model-cache.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
120
|
-
if (sharedDb
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
136
|
-
|
|
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
|
-
|
|
159
|
-
} catch (
|
|
160
|
-
if (!
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
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
|
-
|
|
479
|
+
materializationPolicy(),
|
|
446
480
|
updatedAt,
|
|
447
481
|
authoritative ? 1 : 0,
|
|
448
482
|
staticFingerprint,
|