diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 51a4fec7b..1bed4e55f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed DeepSeek V4 direct API requests with tools to keep documented thinking mode instead of dropping reasoning: lower OMP efforts now map to DeepSeek's supported `high`, `tool_choice` is omitted, `thinking: { type: "enabled" }` and `max_tokens` are sent, and partial user `reasoningEffortMap` overrides merge with DeepSeek defaults. ([#1207](https://github.com/can1357/oh-my-pi/issues/1207)) +- Fixed model cache schema v2 databases so offline refreshes preserve cached provider discoveries after upgrading to schema v3 and subsequent online refreshes can overwrite the cache. ([#1219](https://github.com/can1357/oh-my-pi/issues/1219)) ## [15.1.7] - 2026-05-19 ### Added diff --git a/packages/ai/src/model-cache.ts b/packages/ai/src/model-cache.ts index 0cb3c24ec..07bd8e2cf 100644 --- a/packages/ai/src/model-cache.ts +++ b/packages/ai/src/model-cache.ts @@ -17,6 +17,10 @@ interface CacheRow { models: string; } +interface TableInfoRow { + name: string; +} + interface CacheEntry { models: Model[]; fresh: boolean; @@ -55,11 +59,21 @@ function getDb(dbPath?: string): Database { models TEXT NOT NULL ) `); + migrateCacheSchema(db); + sharedDb = db; sharedDbPath = resolvedPath; return db; } +function migrateCacheSchema(db: Database): void { + const columns = db.prepare("PRAGMA table_info(model_cache)").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 ''"); + } + db.run("UPDATE model_cache SET version = ? WHERE version = 2", [CACHE_SCHEMA_VERSION]); +} + export function readModelCache( providerId: string, ttlMs: number, diff --git a/packages/ai/test/model-cache.test.ts b/packages/ai/test/model-cache.test.ts new file mode 100644 index 000000000..7ebd3a45f --- /dev/null +++ b/packages/ai/test/model-cache.test.ts @@ -0,0 +1,77 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { readModelCache, writeModelCache } from "../src/model-cache"; +import type { Model } from "../src/types"; + +const TTL_MS = 24 * 60 * 60 * 1000; + +function createModel(id: string, name: string): Model<"openai-completions"> { + return { + id, + name, + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4096, + maxTokens: 1024, + }; +} + +describe("model cache migrations", () => { + let tempDir = ""; + let dbPath = ""; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-model-cache-")); + dbPath = path.join(tempDir, "models.db"); + }); + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ""; + dbPath = ""; + } + }); + + it("preserves v2 cached models and lets the next discovery overwrite them", () => { + const legacyModel = createModel("legacy-cloud-model", "Legacy Cloud Model"); + const legacyDb = new Database(dbPath, { create: true }); + legacyDb.run(` + CREATE TABLE model_cache ( + provider_id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + authoritative INTEGER NOT NULL DEFAULT 0, + models TEXT NOT NULL + ) + `); + legacyDb.run( + "INSERT INTO model_cache (provider_id, version, updated_at, authoritative, models) VALUES (?, ?, ?, ?, ?)", + ["ollama-cloud", 2, Date.now(), 1, JSON.stringify([legacyModel])], + ); + legacyDb.close(); + + const migrated = readModelCache<"openai-completions">("ollama-cloud", TTL_MS, Date.now, dbPath); + expect(migrated?.models.map(model => model.id)).toEqual(["legacy-cloud-model"]); + expect(migrated?.staticFingerprint).toBe(""); + + const replacementModel = createModel("fresh-cloud-model", "Fresh Cloud Model"); + writeModelCache("ollama-cloud", Date.now(), [replacementModel], true, "static-v3", dbPath); + + const overwritten = readModelCache<"openai-completions">("ollama-cloud", TTL_MS, Date.now, dbPath); + expect(overwritten?.models.map(model => model.id)).toEqual(["fresh-cloud-model"]); + expect(overwritten?.staticFingerprint).toBe("static-v3"); + }); +});