Skip to content

Commit 3c82fc1

Browse files
authored
Add fuzzy matching for models import (#367) Adds fuzzy search functionality to the models import feature, allowing users to find and filter models more easily by partial name matches. The hook layer now supports fuzzy matching while maintaining the existing import workflow, and the UI has been updated to leverage this new capability. Also includes minor schema refactoring for meter snapshots and formatting improvements to the release script.
1 parent f265599 commit 3c82fc1

4 files changed

Lines changed: 98 additions & 29 deletions

File tree

packages/backend/drizzle/schema/postgres/meter-snapshots.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
import {
2-
pgTable,
3-
serial,
4-
text,
5-
real,
6-
integer,
7-
bigint,
8-
boolean,
9-
index,
10-
} from 'drizzle-orm/pg-core';
1+
import { pgTable, serial, text, real, integer, bigint, boolean, index } from 'drizzle-orm/pg-core';
112

123
export const meterSnapshots = pgTable(
134
'meter_snapshots',

packages/frontend/src/hooks/useModels.ts

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { useToast } from '../contexts/ToastContext';
44

55
export interface OrphanGroup {
66
modelId: string;
7+
/** The alias this group will merge into or create. When set, imports add targets to this alias. */
78
existingAlias?: Alias;
9+
/** Human-readable reason when an existing alias was matched via fuzzy logic (e.g. "case-insensitive match" or "suffix match"). */
10+
matchReason?: string;
811
candidates: Array<{ provider: Provider; model: Model }>;
912
}
1013

@@ -187,6 +190,51 @@ export const useModels = () => {
187190

188191
const filteredAliases = aliases.filter((a) => a.id.toLowerCase().includes(search.toLowerCase()));
189192

193+
/**
194+
* Find an existing alias that matches a given model ID using fuzzy rules:
195+
* 1. Exact case-insensitive match (e.g. "MiniMax-M2.7" → alias "minimax-m2.7")
196+
* 2. Suffix match: the model ID after stripping a "provider/" prefix matches
197+
* an alias (e.g. "sonar-pro" → alias "perplexity/sonar-pro"), or vice versa
198+
* (e.g. "perplexity/sonar-pro" → alias "sonar-pro").
199+
*/
200+
const findExistingAlias = useCallback(
201+
(modelId: string, aliasList: Alias[]): { alias: Alias; reason?: string } | undefined => {
202+
const lowerModel = modelId.toLowerCase();
203+
204+
// 1. Case-insensitive exact match
205+
const ciMatch = aliasList.find((a) => a.id.toLowerCase() === lowerModel);
206+
if (ciMatch && ciMatch.id !== modelId) {
207+
return { alias: ciMatch, reason: `case-insensitive match: ${ciMatch.id}` };
208+
}
209+
if (ciMatch) {
210+
return { alias: ciMatch };
211+
}
212+
213+
// 2. Suffix match — strip provider-style prefix from either side
214+
const modelSuffix = modelId.includes('/')
215+
? modelId.substring(modelId.lastIndexOf('/') + 1)
216+
: modelId;
217+
const modelSuffixLower = modelSuffix.toLowerCase();
218+
219+
for (const alias of aliasList) {
220+
const aliasSuffix = alias.id.includes('/')
221+
? alias.id.substring(alias.id.lastIndexOf('/') + 1)
222+
: alias.id;
223+
const aliasSuffixLower = aliasSuffix.toLowerCase();
224+
225+
if (aliasSuffixLower === modelSuffixLower && alias.id !== modelId) {
226+
return {
227+
alias,
228+
reason: `suffix match: ${alias.id}`,
229+
};
230+
}
231+
}
232+
233+
return undefined;
234+
},
235+
[]
236+
);
237+
190238
const handleOpenImport = useCallback(() => {
191239
// Build set of covered (provider, model) pairs from all alias targets
192240
const covered = new Set<string>();
@@ -196,25 +244,38 @@ export const useModels = () => {
196244
});
197245
});
198246

199-
// Find orphaned models and group by model.id
247+
// Find orphaned models and group by lowercased model.id for case-insensitive grouping
200248
const orphanMap = new Map<string, Array<{ provider: Provider; model: Model }>>();
249+
const canonicalIds = new Map<string, string>(); // lowercase → first-seen original casing
201250
availableModels.forEach((model) => {
202251
const key = `${model.providerId}|${model.id}`;
203252
if (covered.has(key)) return;
204253

205-
if (!orphanMap.has(model.id)) {
206-
orphanMap.set(model.id, []);
254+
// Group case-insensitively: use lowercase key but preserve first-seen casing
255+
const groupKey = model.id.toLowerCase();
256+
if (!canonicalIds.has(groupKey)) {
257+
canonicalIds.set(groupKey, model.id);
258+
}
259+
260+
if (!orphanMap.has(groupKey)) {
261+
orphanMap.set(groupKey, []);
207262
}
208263
const provider = providers.find((p) => p.id === model.providerId);
209264
if (provider) {
210-
orphanMap.get(model.id)!.push({ provider, model });
265+
orphanMap.get(groupKey)!.push({ provider, model });
211266
}
212267
});
213268

214269
const groups: OrphanGroup[] = [];
215-
orphanMap.forEach((candidates, modelId) => {
216-
const existingAlias = aliases.find((a) => a.id === modelId);
217-
groups.push({ modelId, existingAlias, candidates });
270+
orphanMap.forEach((candidates, groupKey) => {
271+
const modelId = canonicalIds.get(groupKey) || groupKey;
272+
const match = findExistingAlias(modelId, aliases);
273+
groups.push({
274+
modelId,
275+
existingAlias: match?.alias,
276+
matchReason: match?.reason,
277+
candidates,
278+
});
218279
});
219280
groups.sort((a, b) => a.modelId.localeCompare(b.modelId));
220281

@@ -227,7 +288,7 @@ export const useModels = () => {
227288
setOrphanGroups(groups);
228289
setSelectedImports(selections);
229290
setIsImportModalOpen(true);
230-
}, [aliases, availableModels, providers]);
291+
}, [aliases, availableModels, providers, findExistingAlias]);
231292

232293
const handleSaveImports = useCallback(async () => {
233294
setIsImporting(true);

packages/frontend/src/pages/Models.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2555,9 +2555,16 @@ export const Models = () => {
25552555
<td className="px-4 py-3 text-left text-text">
25562556
<div className="font-medium">{group.modelId}</div>
25572557
{group.existingAlias ? (
2558-
<span className="inline-flex rounded border border-border-glass px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-primary">
2559-
Existing Alias
2560-
</span>
2558+
<>
2559+
<span className="inline-flex rounded border border-border-glass px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-primary">
2560+
Existing Alias
2561+
</span>
2562+
{group.matchReason && (
2563+
<div className="text-[11px] text-text-muted mt-0.5">
2564+
{group.matchReason}
2565+
</div>
2566+
)}
2567+
</>
25612568
) : (
25622569
<span className="inline-flex rounded border border-border-glass px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-text-muted">
25632570
New Alias
@@ -2588,7 +2595,14 @@ export const Models = () => {
25882595
setSelectedImports(next);
25892596
}}
25902597
/>
2591-
<span className="text-text text-[13px]">{c.provider.name}</span>
2598+
<span className="text-text text-[13px]">
2599+
{c.provider.name}
2600+
{c.model.id !== group.modelId && (
2601+
<span className="text-text-muted ml-1 text-[11px]">
2602+
({c.model.id})
2603+
</span>
2604+
)}
2605+
</span>
25922606
</label>
25932607
);
25942608
})}

scripts/release.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,9 @@ async function followReleaseWorkflow(owner: string, repo: string, version: strin
223223
execSync(`gh run view ${runId} --repo ${owner}/${repo} --log`, { stdio: 'inherit' });
224224

225225
// Get the final status
226-
const result = execSync(
227-
`gh run view ${runId} --repo ${owner}/${repo} --json status,conclusion`,
228-
{ encoding: 'utf-8' }
229-
);
226+
const result = execSync(`gh run view ${runId} --repo ${owner}/${repo} --json status,conclusion`, {
227+
encoding: 'utf-8',
228+
});
230229
const runInfo = JSON.parse(result);
231230

232231
if (runInfo.conclusion === 'success') {
@@ -243,9 +242,13 @@ function sleep(ms: number): Promise<void> {
243242

244243
async function watchWorkflowRun(owner: string, repo: string, runId: string): Promise<void> {
245244
return new Promise((resolve) => {
246-
const child = spawn('gh', ['run', 'watch', runId, '--repo', `${owner}/${repo}`, '--exit-status'], {
247-
stdio: 'inherit',
248-
});
245+
const child = spawn(
246+
'gh',
247+
['run', 'watch', runId, '--repo', `${owner}/${repo}`, '--exit-status'],
248+
{
249+
stdio: 'inherit',
250+
}
251+
);
249252

250253
child.on('close', (code) => {
251254
resolve();

0 commit comments

Comments
 (0)