Files
oh-my-pi/packages/catalog/src/model-cache.ts
T
2026-07-25 02:50:10 +00:00

276 lines
10 KiB
TypeScript

/**
* SQLite-backed model cache for atomic cross-process access.
* Replaces per-provider JSON files with a single cache.db.
*/
import { Database } from "bun:sqlite";
import { getModelDbPath } from "@oh-my-pi/pi-utils";
import type { Api, Model, ModelSpec } from "./types";
// Rows persist ModelSpec JSON (sparse `compat`, never the resolved record);
// the model manager rebuilds via `buildModel` on load. Request headers are
// intentionally omitted: arbitrary provider-defined header names can carry
// credentials. v10 deletes rows that may contain persisted headers and records
// which model ids lost headers and which cannot be rebuilt from static inputs,
// so the manager can restore the safe subset or refetch dynamic-only headers;
// v9 invalidated Kimi Code rows predating live effort and protocol metadata;
// v8 invalidated Codex discovery rows predating provider-native V2 compaction
// metadata; v7 invalidated rows predating the Antigravity Gemini budget-mode
// migration (cached specs still carrying `thinking.mode: "google-level"` and
// the old 3.5-flash effort routing); v6 invalidated rows that may contain the
// retired unknown-limit sentinels (222222/8888); v5 invalidated rows predating
// effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids);
// v4 dropped the pre-efforts ThinkingConfig shape.
const CACHE_SCHEMA_VERSION = 10;
const HEADER_RESTORE_VERSION = 1;
interface CacheRow {
provider_id: string;
version: number;
updated_at: number;
authoritative: number;
static_fingerprint: string;
models: string;
header_omitted_model_ids: string;
unrestorable_header_model_ids: string;
header_restore_version: number;
}
interface TableInfoRow {
name: string;
}
interface CacheEntry<TApi extends Api = Api> {
models: ModelSpec<TApi>[];
fresh: boolean;
authoritative: boolean;
updatedAt: number;
/** Model ids whose live headers were intentionally omitted from disk. */
headerOmittedModelIds: readonly string[];
/** Header-bearing model ids that cannot be rebuilt from the static source. */
unrestorableHeaderModelIds: readonly string[];
/** Whether unrestorable markers predate request-model header matching. */
legacyHeaderRestoreMarkers: boolean;
/**
* Hash of the static catalog slice that was merged into `models` when this
* row was written. `resolveProviderModels` compares against the current
* static fingerprint and bypasses the static+cache re-merge when they
* match — the cache already incorporates the same static state.
*/
staticFingerprint: string;
}
let sharedDb: Database | null = null;
let sharedDbPath: string | null = null;
function openDb(resolvedPath: string): Database {
const db = new Database(resolvedPath, { create: true });
// Install the busy handler BEFORE any lock-taking statement. See
// https://github.com/can1357/oh-my-pi/issues/2421.
db.run("PRAGMA busy_timeout = 3000");
// Schema invalidation can delete rows containing credentials written by old
// versions. Overwrite deleted SQLite cells instead of leaving their bytes in
// free pages where a raw scan of models.db can still recover them (#5780).
db.run("PRAGMA secure_delete = ON");
db.run("PRAGMA journal_mode = WAL");
db.run(`
CREATE TABLE IF NOT EXISTS model_cache (
provider_id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
authoritative INTEGER NOT NULL DEFAULT 0,
static_fingerprint TEXT NOT NULL DEFAULT '',
header_omitted_model_ids TEXT NOT NULL DEFAULT '[]',
unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]',
header_restore_version INTEGER NOT NULL DEFAULT 0,
models TEXT NOT NULL
)
`);
migrateCacheSchema(db);
return db;
}
function getSharedDb(): Database {
const resolvedPath = getModelDbPath();
if (sharedDb && sharedDbPath === resolvedPath) {
return sharedDb;
}
if (sharedDb) {
sharedDb.close();
}
const db = openDb(resolvedPath);
sharedDb = db;
sharedDbPath = resolvedPath;
return db;
}
function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
if (!dbPath) return useDb(getSharedDb());
const db = openDb(dbPath);
try {
return useDb(db);
} finally {
db.close();
}
}
function migrateCacheSchema(db: Database): void {
const stmt = db.prepare("PRAGMA table_info(model_cache)");
try {
const columns = stmt.all() as TableInfoRow[];
if (!columns.some(column => column.name === "static_fingerprint")) {
db.run("ALTER TABLE model_cache ADD COLUMN static_fingerprint TEXT NOT NULL DEFAULT ''");
}
if (!columns.some(column => column.name === "header_omitted_model_ids")) {
db.run("ALTER TABLE model_cache ADD COLUMN header_omitted_model_ids TEXT NOT NULL DEFAULT '[]'");
}
if (!columns.some(column => column.name === "unrestorable_header_model_ids")) {
db.run("ALTER TABLE model_cache ADD COLUMN unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]'");
}
if (!columns.some(column => column.name === "header_restore_version")) {
// Existing v10 rows get 0, distinguishing markers produced by the
// old id-only header matcher from rows written after request-model
// header matching was introduced.
db.run("ALTER TABLE model_cache ADD COLUMN header_restore_version INTEGER NOT NULL DEFAULT 0");
}
} finally {
stmt.finalize();
}
// Delete rows written under any older schema so they cannot be reused. The
// legacy `UPDATE ... WHERE version = 2` migration silently promoted the very
// first cache version to whatever the current one is, defeating every
// subsequent invalidation (see #4146: pre-V2 Codex rows kept the legacy
// compaction path even after CACHE_SCHEMA_VERSION was bumped).
db.run("DELETE FROM model_cache WHERE version <> ?", [CACHE_SCHEMA_VERSION]);
}
export function readModelCache<TApi extends Api>(
providerId: string,
ttlMs: number,
now: () => number,
dbPath?: string,
): CacheEntry<TApi> | null {
try {
return withModelCacheDb(dbPath, db => {
const stmt = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?");
try {
const row = stmt.get(providerId);
if (!row || row.version !== CACHE_SCHEMA_VERSION) {
return null;
}
const models = JSON.parse(row.models) as ModelSpec<TApi>[];
const parsedHeaderModelIds: unknown = JSON.parse(row.header_omitted_model_ids);
const headerOmittedModelIds = Array.isArray(parsedHeaderModelIds)
? parsedHeaderModelIds.filter((id): id is string => typeof id === "string")
: [];
const parsedUnrestorableModelIds: unknown = JSON.parse(row.unrestorable_header_model_ids);
const unrestorableHeaderModelIds = Array.isArray(parsedUnrestorableModelIds)
? parsedUnrestorableModelIds.filter((id): id is string => typeof id === "string")
: [];
const ageMs = now() - row.updated_at;
const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
return {
models,
fresh,
authoritative: row.authoritative === 1,
updatedAt: row.updated_at,
headerOmittedModelIds,
unrestorableHeaderModelIds,
legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION,
staticFingerprint: row.static_fingerprint ?? "",
};
} finally {
stmt.finalize();
}
});
} catch {
return null;
}
}
/** Whether a live model carries at least one request header. */
function hasModelHeaders(model: Model<Api>): boolean {
const headers = model.headers;
if (!headers) return false;
for (const _key in headers) return true;
return false;
}
/**
* Project a live model to cache-safe metadata.
*
* Headers are never persisted: custom/runtime providers may use arbitrary
* credential header names, so no name-based filter can be complete. The
* separately persisted model-id list lets the manager restore matching static
* headers and reject/refetch dynamic-only cached models that need live headers.
*/
function toCachedModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
const { headers: _headers, compatConfig, supportsComputerUseConfig, ...rest } = model;
return { ...rest, supportsComputerUse: supportsComputerUseConfig, compat: compatConfig };
}
/** Whether two in-memory header records are byte-for-byte equivalent. */
function headersEqual(left: Record<string, string> | undefined, right: Record<string, string> | undefined): boolean {
if (!left || !right) return left === right;
for (const key in left) {
if (right[key] !== left[key]) return false;
}
for (const key in right) {
if (!(key in left)) return false;
}
return true;
}
export function writeModelCache<TApi extends Api>(
providerId: string,
updatedAt: number,
models: Model<TApi>[],
authoritative: boolean,
staticFingerprint: string,
dbPath?: string,
staticHeaderSources: readonly Model<TApi>[] = [],
): void {
try {
withModelCacheDb(dbPath, db => {
const headerOmittedModelIds: string[] = [];
const unrestorableHeaderModelIds: string[] = [];
const cachedModels: ModelSpec<TApi>[] = [];
const staticById = new Map(staticHeaderSources.map(model => [model.id, model]));
for (const model of models) {
if (hasModelHeaders(model)) {
headerOmittedModelIds.push(model.id);
// Synthesized variants (e.g. Copilot `-1m`) have no same-id static
// entry; their headers come from the `requestModelId` base. Match
// against that source too, else they are wrongly flagged
// unrestorable and dropped on the next offline read (#6037, #6284).
const staticHeaderSource =
staticById.get(model.id) ?? (model.requestModelId ? staticById.get(model.requestModelId) : undefined);
if (!headersEqual(model.headers, staticHeaderSource?.headers)) {
unrestorableHeaderModelIds.push(model.id);
}
}
cachedModels.push(toCachedModelSpec(model));
}
db.run(
`INSERT OR REPLACE INTO model_cache (
provider_id, version, updated_at, authoritative, static_fingerprint,
header_omitted_model_ids, unrestorable_header_model_ids,
header_restore_version, models
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
providerId,
CACHE_SCHEMA_VERSION,
updatedAt,
authoritative ? 1 : 0,
staticFingerprint,
JSON.stringify(headerOmittedModelIds),
JSON.stringify(unrestorableHeaderModelIds),
HEADER_RESTORE_VERSION,
JSON.stringify(cachedModels),
],
);
});
} catch {
// Cache writes are best-effort; failures should not break model resolution.
}
}