From 3b450cba62797f2d17c3967676fad7cdc6ddd77e Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Wed, 29 Jul 2026 12:26:37 +0200 Subject: [PATCH 1/2] fix(models): repair Bielik download after upstream HF repo reorg The Bielik entry was the only default model with hand-written download URLs, pinned to resolve/main of the HF repo. The repo moved its files (bielik-v3.0-1.5B/quantized/... -> xnnpack/...), so the stored model URL started returning 404. - source Bielik paths from the BIELIK_V3_0_1_5B_QUANTIZED constant of react-native-executorch, which is pinned to a version tag - refresh stale built-in download paths on migration for undownloaded rows (seeding uses INSERT OR IGNORE, so existing installs kept the dead URL forever) - re-sync built-in paths when a downloaded model's files are removed, so an immediate re-download uses current URLs - correct Bielik modelSize to the actual file size (0.86 GiB) Co-Authored-By: Claude Fable 5 --- __mocks__/react-native-executorch.ts | 3 ++ __tests__/defaultModels.test.ts | 29 +++++++++++- __tests__/modelPathRefresh.test.ts | 69 ++++++++++++++++++++++++++++ __tests__/modelRepository.test.ts | 37 ++++++++++++++- constants/default-models.ts | 13 +++--- database/db.ts | 16 ++++++- database/modelRepository.ts | 24 ++++++++++ store/modelStore.ts | 7 +++ 8 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 __tests__/modelPathRefresh.test.ts diff --git a/__mocks__/react-native-executorch.ts b/__mocks__/react-native-executorch.ts index 0dfa9696..f25c865e 100644 --- a/__mocks__/react-native-executorch.ts +++ b/__mocks__/react-native-executorch.ts @@ -62,6 +62,9 @@ export const LFM2_5_VL_1_6B_QUANTIZED = makeModelConstants( export const LFM2_5_VL_450M_QUANTIZED = makeModelConstants( 'lfm2.5-vl-450m-quantized' ); +export const BIELIK_V3_0_1_5B_QUANTIZED = makeModelConstants( + 'bielik-v3.0-1.5b-quantized' +); export const GEMMA4_E2B = makeModelConstants('gemma4-e2b'); export const GEMMA4_E2B_MM = makeModelConstants('gemma4-e2b-mm'); export const WHISPER_TINY_EN = 'whisper-tiny-en'; diff --git a/__tests__/defaultModels.test.ts b/__tests__/defaultModels.test.ts index e5568b82..2898c414 100644 --- a/__tests__/defaultModels.test.ts +++ b/__tests__/defaultModels.test.ts @@ -1,4 +1,5 @@ -import { getStartingModels } from '../constants/default-models'; +import { BIELIK_V3_0_1_5B_QUANTIZED } from 'react-native-executorch'; +import { DEFAULT_MODELS, getStartingModels } from '../constants/default-models'; describe('getStartingModels', () => { it('returns low-end model suggestions below 4 GB RAM', () => { @@ -36,3 +37,29 @@ describe('getStartingModels', () => { expect(getStartingModels(-1)).toEqual(lowEnd); }); }); + +describe('DEFAULT_MODELS paths', () => { + it('sources Bielik paths from the react-native-executorch constant', () => { + const bielik = DEFAULT_MODELS.find((m) => m.modelName === 'Bielik - v3.0'); + expect(bielik).toBeDefined(); + expect(bielik!.modelPath).toBe(BIELIK_V3_0_1_5B_QUANTIZED.modelSource); + expect(bielik!.tokenizerPath).toBe( + BIELIK_V3_0_1_5B_QUANTIZED.tokenizerSource + ); + expect(bielik!.tokenizerConfigPath).toBe( + BIELIK_V3_0_1_5B_QUANTIZED.tokenizerConfigSource + ); + }); + + it('never points a default model at the mutable HF main branch', () => { + for (const model of DEFAULT_MODELS) { + for (const path of [ + model.modelPath, + model.tokenizerPath, + model.tokenizerConfigPath, + ]) { + expect(path).not.toContain('/resolve/main/'); + } + } + }); +}); diff --git a/__tests__/modelPathRefresh.test.ts b/__tests__/modelPathRefresh.test.ts new file mode 100644 index 00000000..04bdb95d --- /dev/null +++ b/__tests__/modelPathRefresh.test.ts @@ -0,0 +1,69 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +jest.mock('../store/chatStore', () => ({ + useChatStore: { getState: () => ({}) }, +})); +jest.mock('../store/llmStore', () => ({ + useLLMStore: { getState: () => ({}) }, +})); +jest.mock('../store/modelStore', () => ({ + useModelStore: { getState: () => ({}) }, +})); +jest.mock('../store/sourceStore', () => ({ + useSourceStore: { getState: () => ({}) }, +})); +jest.mock('../database/modelRepository', () => ({ addModel: jest.fn() })); +jest.mock('../constants/default-models', () => ({ + DEFAULT_MODELS: [ + { + modelName: 'Bielik - v3.0', + family: 'Bielik', + modelPath: 'https://hf.example/resolve/v0.9.0/xnnpack/bielik.pte', + tokenizerPath: 'https://hf.example/resolve/v0.9.0/tokenizer.json', + tokenizerConfigPath: + 'https://hf.example/resolve/v0.9.0/tokenizer_config.json', + source: 'remote', + modelSize: 0.86, + featured: true, + }, + ], +})); + +import { runMigrations } from '../database/db'; + +type Call = { sql: string; params: unknown[] }; + +const makeFakeDb = () => { + const calls: Call[] = []; + const db = { + getAllAsync: async () => [], + execAsync: async () => {}, + getFirstAsync: async () => null, + runAsync: async (sql: string, ...params: unknown[]) => { + calls.push({ sql, params: params.flat() }); + return {}; + }, + withTransactionAsync: async (fn: () => Promise) => fn(), + }; + return { db: db as unknown as SQLiteDatabase, calls }; +}; + +describe('runMigrations built-in model path refresh', () => { + it('rewrites stale download paths for undownloaded built-in models', async () => { + const { db, calls } = makeFakeDb(); + + await runMigrations(db); + + const refresh = calls.find((c) => c.sql.includes('SET modelPath')); + expect(refresh).toBeDefined(); + expect(refresh!.sql).toContain(`source = 'built-in'`); + expect(refresh!.sql).toContain('isDownloaded = 0'); + expect(refresh!.params).toEqual([ + 'https://hf.example/resolve/v0.9.0/xnnpack/bielik.pte', + 'https://hf.example/resolve/v0.9.0/tokenizer.json', + 'https://hf.example/resolve/v0.9.0/tokenizer_config.json', + 0.86, + 'Bielik - v3.0', + ]); + }); +}); diff --git a/__tests__/modelRepository.test.ts b/__tests__/modelRepository.test.ts index 78d847c6..5f555fa8 100644 --- a/__tests__/modelRepository.test.ts +++ b/__tests__/modelRepository.test.ts @@ -1,6 +1,11 @@ // __tests__/modelRepository.test.ts import { type SQLiteDatabase } from 'expo-sqlite'; -import { getAllModels, getModelsByNames } from '../database/modelRepository'; +import { + getAllModels, + getModelsByNames, + syncBuiltInModelPaths, +} from '../database/modelRepository'; +import { DEFAULT_MODELS } from '../constants/default-models'; jest.mock('expo-sqlite', () => { const stableDb = {}; @@ -167,3 +172,33 @@ describe('getModelsByNames', () => { ]); }); }); + +describe('syncBuiltInModelPaths', () => { + it('rewrites a built-in row with paths from DEFAULT_MODELS', async () => { + const bielik = DEFAULT_MODELS.find((m) => m.modelName === 'Bielik - v3.0')!; + const runAsync = jest.fn().mockResolvedValue({}); + const mockDb = { runAsync } as unknown as SQLiteDatabase; + + await syncBuiltInModelPaths(mockDb, 7, 'Bielik - v3.0'); + + expect(runAsync).toHaveBeenCalledTimes(1); + const [sql, params] = runAsync.mock.calls[0]; + expect(sql).toContain(`source = 'built-in'`); + expect(params).toEqual([ + bielik.modelPath, + bielik.tokenizerPath, + bielik.tokenizerConfigPath, + bielik.modelSize, + 7, + ]); + }); + + it('does nothing for a model name outside DEFAULT_MODELS', async () => { + const runAsync = jest.fn(); + const mockDb = { runAsync } as unknown as SQLiteDatabase; + + await syncBuiltInModelPaths(mockDb, 3, 'My Local Model'); + + expect(runAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/constants/default-models.ts b/constants/default-models.ts index 85fa526a..8d56984e 100644 --- a/constants/default-models.ts +++ b/constants/default-models.ts @@ -13,6 +13,7 @@ import { LFM2_5_1_2B_INSTRUCT_QUANTIZED, LFM2_5_VL_1_6B_QUANTIZED, LFM2_5_VL_450M_QUANTIZED, + BIELIK_V3_0_1_5B_QUANTIZED, GEMMA4_E2B, GEMMA4_E2B_MM, } from 'react-native-executorch'; @@ -42,6 +43,7 @@ const RNE_MODELS = [ LFM2_5_1_2B_INSTRUCT_QUANTIZED, LFM2_5_VL_1_6B_QUANTIZED, LFM2_5_VL_450M_QUANTIZED, + BIELIK_V3_0_1_5B_QUANTIZED, GEMMA4_E2B, GEMMA4_E2B_MM, ]; @@ -216,15 +218,12 @@ export const DEFAULT_MODELS: Omit[] = [ { modelName: 'Bielik - v3.0', family: 'Bielik', - tokenizerPath: - 'https://huggingface.co/software-mansion/react-native-executorch-bielik-v3.0/resolve/main/tokenizer.json', - modelPath: - 'https://huggingface.co/software-mansion/react-native-executorch-bielik-v3.0/resolve/main/bielik-v3.0-1.5B/quantized/bielik_1_5b_v3_0_instruct_xnnpack_8da4w.pte', - tokenizerConfigPath: - 'https://huggingface.co/software-mansion/react-native-executorch-bielik-v3.0/resolve/main/tokenizer_config.json', + tokenizerPath: BIELIK_V3_0_1_5B_QUANTIZED.tokenizerSource, + modelPath: BIELIK_V3_0_1_5B_QUANTIZED.modelSource, + tokenizerConfigPath: BIELIK_V3_0_1_5B_QUANTIZED.tokenizerConfigSource, source: 'remote', parameters: 1.5, - modelSize: 1.65, + modelSize: 0.86, featured: true, experimental: true, thinking: false, diff --git a/database/db.ts b/database/db.ts index cb83a434..83d0d21c 100644 --- a/database/db.ts +++ b/database/db.ts @@ -7,7 +7,7 @@ import { addModel } from './modelRepository'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useSourceStore } from '../store/sourceStore'; -const runMigrations = async (db: SQLiteDatabase) => { +export const runMigrations = async (db: SQLiteDatabase) => { const modelsTableInfo = await db.getAllAsync<{ name: string }>( `PRAGMA table_info(models)` ); @@ -183,6 +183,20 @@ const runMigrations = async (db: SQLiteDatabase) => { model.systemPrompt || null, model.modelName ); + + // Rows are seeded with INSERT OR IGNORE, so download URLs frozen in an + // existing install go stale when the upstream HF repo moves files. Only + // undownloaded rows are refreshed: for a downloaded model the stored URL + // is the resource fetcher's key to its local files. + await db.runAsync( + `UPDATE models SET modelPath = ?, tokenizerPath = ?, tokenizerConfigPath = ?, modelSize = ? + WHERE modelName = ? AND source = 'built-in' AND isDownloaded = 0`, + model.modelPath, + model.tokenizerPath, + model.tokenizerConfigPath, + model.modelSize ?? null, + model.modelName + ); } }; diff --git a/database/modelRepository.ts b/database/modelRepository.ts index 185cfcff..18de6c43 100644 --- a/database/modelRepository.ts +++ b/database/modelRepository.ts @@ -85,6 +85,30 @@ export const removeModelFiles = async (db: SQLiteDatabase, id: number) => { await db.runAsync(`DELETE FROM models WHERE id = ?`, [id]); }; +export const syncBuiltInModelPaths = async ( + db: SQLiteDatabase, + id: number, + modelName: string +) => { + const defaults = DEFAULT_MODELS.find((m) => m.modelName === modelName); + if (!defaults) return; + + await db.runAsync( + ` + UPDATE models + SET modelPath = ?, tokenizerPath = ?, tokenizerConfigPath = ?, modelSize = ? + WHERE id = ? AND source = 'built-in' + `, + [ + defaults.modelPath, + defaults.tokenizerPath, + defaults.tokenizerConfigPath, + defaults.modelSize ?? null, + id, + ] + ); +}; + type RawModel = Omit< Model, | 'isDownloaded' diff --git a/store/modelStore.ts b/store/modelStore.ts index 34626003..d7412862 100644 --- a/store/modelStore.ts +++ b/store/modelStore.ts @@ -7,6 +7,7 @@ import { updateModelDownloaded, removeModelFiles, updateModel, + syncBuiltInModelPaths, } from '../database/modelRepository'; import Toast from 'react-native-toast-message'; import { ResourceFetcher } from 'react-native-executorch'; @@ -198,6 +199,12 @@ export const useModelStore = create((set, get) => ({ ); } await updateModelDownloaded(db, modelId, 0); + // A downloaded row keeps the URLs it was installed with (they key the + // fetcher's local files); once the files are gone, re-sync the row so an + // immediate re-download uses the current upstream paths. + if (model.source === 'built-in') { + await syncBuiltInModelPaths(db, modelId, model.modelName); + } await get().loadModels(); set((state) => { const { [modelId]: _, ...rest } = state.downloadStates; From 5be1034264a217f767289e3e59de00277b8853e4 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Wed, 29 Jul 2026 12:58:14 +0200 Subject: [PATCH 2/2] style: tighten the built-in path refresh comments Co-Authored-By: Claude Fable 5 --- database/db.ts | 6 ++---- store/modelStore.ts | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/database/db.ts b/database/db.ts index 83d0d21c..a7f9c791 100644 --- a/database/db.ts +++ b/database/db.ts @@ -184,10 +184,8 @@ export const runMigrations = async (db: SQLiteDatabase) => { model.modelName ); - // Rows are seeded with INSERT OR IGNORE, so download URLs frozen in an - // existing install go stale when the upstream HF repo moves files. Only - // undownloaded rows are refreshed: for a downloaded model the stored URL - // is the resource fetcher's key to its local files. + // Refresh stale download URLs. Downloaded rows keep theirs — the stored + // URL is the resource fetcher's key to the local files. await db.runAsync( `UPDATE models SET modelPath = ?, tokenizerPath = ?, tokenizerConfigPath = ?, modelSize = ? WHERE modelName = ? AND source = 'built-in' AND isDownloaded = 0`, diff --git a/store/modelStore.ts b/store/modelStore.ts index d7412862..90c66324 100644 --- a/store/modelStore.ts +++ b/store/modelStore.ts @@ -199,9 +199,7 @@ export const useModelStore = create((set, get) => ({ ); } await updateModelDownloaded(db, modelId, 0); - // A downloaded row keeps the URLs it was installed with (they key the - // fetcher's local files); once the files are gone, re-sync the row so an - // immediate re-download uses the current upstream paths. + // Re-sync stored URLs so an immediate re-download uses current paths. if (model.source === 'built-in') { await syncBuiltInModelPaths(db, modelId, model.modelName); }