@@ -4,7 +4,10 @@ import { useToast } from '../contexts/ToastContext';
44
55export 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 ) ;
0 commit comments