Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions __mocks__/react-native-executorch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
29 changes: 28 additions & 1 deletion __tests__/defaultModels.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/');
}
}
});
});
69 changes: 69 additions & 0 deletions __tests__/modelPathRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) => 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',
]);
});
});
37 changes: 36 additions & 1 deletion __tests__/modelRepository.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand Down Expand Up @@ -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();
});
});
13 changes: 6 additions & 7 deletions constants/default-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
];
Expand Down Expand Up @@ -216,15 +218,12 @@ export const DEFAULT_MODELS: Omit<Model, 'id' | 'isDownloaded'>[] = [
{
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,
Expand Down
14 changes: 13 additions & 1 deletion database/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
);
Expand Down Expand Up @@ -183,6 +183,18 @@ const runMigrations = async (db: SQLiteDatabase) => {
model.systemPrompt || null,
model.modelName
);

// 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`,
model.modelPath,
model.tokenizerPath,
model.tokenizerConfigPath,
model.modelSize ?? null,
model.modelName
);
}
};

Expand Down
24 changes: 24 additions & 0 deletions database/modelRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 5 additions & 0 deletions store/modelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -198,6 +199,10 @@ export const useModelStore = create<ModelStore>((set, get) => ({
);
}
await updateModelDownloaded(db, modelId, 0);
// Re-sync stored URLs so an immediate re-download uses current paths.
if (model.source === 'built-in') {
await syncBuiltInModelPaths(db, modelId, model.modelName);
}
await get().loadModels();
set((state) => {
const { [modelId]: _, ...rest } = state.downloadStates;
Expand Down
Loading