From c9d469caca0a20d203fa9af035346ab17159f267 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 13:27:05 +0000 Subject: [PATCH] fix(models): add fuzzy matching to getModel() for auto-synced aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After /syncall, auto-synced models get hyphenated aliases like "claude-sonnet-46" but users try "sonnet46" or "claudesonnet". getModel() only did exact key lookups, so these all failed. Added fuzzy fallback with 4 passes: 1. Normalized exact (strip hyphens/dots) 2. Suffix match ("sonnet46" → "claude-sonnet-46") 3. Prefix match ("claudesonnet" → "claude-sonnet-46") 4. Model ID match ("gpt4o" → openai/gpt-4o) Also stores canonical alias in /use handler so subsequent lookups are always exact matches. https://claude.ai/code/session_01K2mQTABDGY7DnnposPdDjw --- src/openrouter/models.test.ts | 106 +++++++++++++++++++++++++++++++++- src/openrouter/models.ts | 72 ++++++++++++++++++++++- src/telegram/handler.ts | 7 ++- 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/src/openrouter/models.test.ts b/src/openrouter/models.test.ts index c1671f17f..3afbb7f99 100644 --- a/src/openrouter/models.test.ts +++ b/src/openrouter/models.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect } from 'vitest'; -import { detectToolIntent, getModel, getFreeToolModels, categorizeModel, getOrchestraRecommendations, formatOrchestraModelRecs, resolveTaskModel, detectTaskIntent, type RouterCheckpointMeta } from './models'; +import { detectToolIntent, getModel, getFreeToolModels, categorizeModel, getOrchestraRecommendations, formatOrchestraModelRecs, resolveTaskModel, detectTaskIntent, registerAutoSyncedModels, type RouterCheckpointMeta, type ModelInfo } from './models'; // --- detectToolIntent --- @@ -210,6 +210,110 @@ describe('GLM model tools support', () => { }); }); +// --- getModel fuzzy matching --- + +describe('getModel fuzzy matching', () => { + // Register test auto-synced models for fuzzy tests + const testModels: Record = { + 'claude-sonnet-46': { + id: 'anthropic/claude-sonnet-4.6', + alias: 'claude-sonnet-46', + name: 'Claude Sonnet 4.6', + specialty: 'General (auto-synced)', + score: '200K context', + cost: '$3/$15', + }, + 'deepseek-v32': { + id: 'deepseek/deepseek-v3.2', + alias: 'deepseek-v32', + name: 'DeepSeek V3.2 (synced)', + specialty: 'General (auto-synced)', + score: '128K context', + cost: '$0.25/$0.38', + }, + 'meta-llama-4-scout': { + id: 'meta-llama/llama-4-scout', + alias: 'meta-llama-4-scout', + name: 'Llama 4 Scout', + specialty: 'General (auto-synced)', + score: '512K context', + cost: '$0.15/$0.60', + }, + }; + + // Register before each test group + registerAutoSyncedModels(testModels); + + it('exact match still works for curated models', () => { + const model = getModel('sonnet'); + expect(model).toBeDefined(); + expect(model!.alias).toBe('sonnet'); + }); + + it('exact match works for auto-synced models', () => { + const model = getModel('claude-sonnet-46'); + expect(model).toBeDefined(); + expect(model!.alias).toBe('claude-sonnet-46'); + }); + + it('fuzzy: normalized match strips hyphens (claudesonnet46 → claude-sonnet-46)', () => { + const model = getModel('claudesonnet46'); + expect(model).toBeDefined(); + expect(model!.id).toBe('anthropic/claude-sonnet-4.6'); + }); + + it('fuzzy: suffix match (sonnet46 → claude-sonnet-46)', () => { + const model = getModel('sonnet46'); + expect(model).toBeDefined(); + expect(model!.id).toBe('anthropic/claude-sonnet-4.6'); + }); + + it('fuzzy: prefix match (claudesonnet → claude-sonnet-46)', () => { + const model = getModel('claudesonnet'); + expect(model).toBeDefined(); + expect(model!.id).toBe('anthropic/claude-sonnet-4.6'); + }); + + it('fuzzy: model ID match (gpt4o → curated gpt model)', () => { + const model = getModel('gpt4o'); + expect(model).toBeDefined(); + expect(model!.id).toBe('openai/gpt-4o'); + }); + + it('fuzzy: model ID match for hyphenated (llama4scout → meta-llama-4-scout)', () => { + const model = getModel('llama4scout'); + expect(model).toBeDefined(); + expect(model!.id).toBe('meta-llama/llama-4-scout'); + }); + + it('does not fuzzy match very short queries (< 3 chars)', () => { + const model = getModel('so'); + expect(model).toBeUndefined(); + }); + + it('returns undefined for completely unknown aliases', () => { + const model = getModel('totallyunknownmodel123'); + expect(model).toBeUndefined(); + }); + + it('curated exact match takes priority over fuzzy auto-synced', () => { + // "deep" should exact-match curated model, not fuzzy-match "deepseek-v32" + const model = getModel('deep'); + expect(model).toBeDefined(); + expect(model!.alias).toBe('deep'); + expect(model!.id).toBe('deepseek/deepseek-v3.2'); + }); + + it('case insensitive fuzzy matching', () => { + const model = getModel('Sonnet46'); + expect(model).toBeDefined(); + expect(model!.id).toBe('anthropic/claude-sonnet-4.6'); + }); + + // Clean up + registerAutoSyncedModels({}); +}); + // --- getOrchestraRecommendations --- describe('getOrchestraRecommendations', () => { diff --git a/src/openrouter/models.ts b/src/openrouter/models.ts index fc9251aa4..3dc72adb7 100644 --- a/src/openrouter/models.ts +++ b/src/openrouter/models.ts @@ -694,11 +694,81 @@ export function getAllModels(): Record { /** * Get model by alias. * Priority: blocked → dynamic (/syncmodels) → curated (static) → auto-synced (full catalog) + * Falls back to fuzzy matching when exact match fails (strips hyphens/dots, tries suffix/prefix). */ export function getModel(alias: string): ModelInfo | undefined { const lower = alias.toLowerCase(); if (BLOCKED_ALIASES.has(lower)) return undefined; - return DYNAMIC_MODELS[lower] || MODELS[lower] || AUTO_SYNCED_MODELS[lower]; + + // Exact match (highest priority) + const exact = DYNAMIC_MODELS[lower] || MODELS[lower] || AUTO_SYNCED_MODELS[lower]; + if (exact) return exact; + + // Fuzzy fallback for auto-synced and hyphenated aliases + return fuzzyMatchModel(lower); +} + +/** + * Fuzzy model lookup when exact alias match fails. + * Normalizes query and keys by stripping hyphens/dots, then tries: + * 1. Normalized exact match (e.g. "claudesonnet46" matches key "claude-sonnet-46") + * 2. Normalized key ends with query (e.g. "sonnet46" matches "claude-sonnet-46") + * 3. Normalized key starts with query (e.g. "claudesonnet" matches "claude-sonnet-46") + * 4. Model ID match (strip provider prefix, normalize) + * + * Respects registry priority: DYNAMIC > MODELS > AUTO_SYNCED. + */ +function fuzzyMatchModel(query: string): ModelInfo | undefined { + const norm = query.replace(/[-_.]/g, ''); + if (norm.length < 3) return undefined; + + const registries = [DYNAMIC_MODELS, MODELS, AUTO_SYNCED_MODELS]; + + // Pass 1: Normalized exact match on alias + for (const reg of registries) { + for (const [key, model] of Object.entries(reg)) { + if (BLOCKED_ALIASES.has(key)) continue; + if (key.replace(/[-_.]/g, '') === norm) return model; + } + } + + // Pass 2: Normalized alias ends with query + // e.g. "sonnet46" matches "claude-sonnet-46" → normalized "claudesonnet46" + for (const reg of registries) { + for (const [key, model] of Object.entries(reg)) { + if (BLOCKED_ALIASES.has(key)) continue; + const normKey = key.replace(/[-_.]/g, ''); + if (normKey.endsWith(norm) && norm.length >= 4) return model; + } + } + + // Pass 3: Normalized alias starts with query (handles version-less lookups) + // e.g. "claudesonnet" matches "claude-sonnet-46" → normalized "claudesonnet46" + // Single match only — returns undefined if ambiguous + const startMatches: ModelInfo[] = []; + for (const reg of registries) { + for (const [key, model] of Object.entries(reg)) { + if (BLOCKED_ALIASES.has(key)) continue; + const normKey = key.replace(/[-_.]/g, ''); + if (normKey.startsWith(norm) && norm.length >= 5 && norm.length >= normKey.length * 0.6) { + startMatches.push(model); + } + } + } + if (startMatches.length === 1) return startMatches[0]; + + // Pass 4: Match against model ID (strip provider prefix, normalize) + // e.g. "gpt4o" matches model with ID "openai/gpt-4o" + for (const reg of registries) { + for (const model of Object.values(reg)) { + if (BLOCKED_ALIASES.has(model.alias)) continue; + const idName = model.id.includes('/') ? model.id.split('/').pop()! : model.id; + const normId = idName.replace(/[-_.]/g, '').replace(/:.*$/, '').toLowerCase(); + if (normId === norm) return model; + } + } + + return undefined; } /** diff --git a/src/telegram/handler.ts b/src/telegram/handler.ts index 9e2150646..fe2033307 100644 --- a/src/telegram/handler.ts +++ b/src/telegram/handler.ts @@ -1192,11 +1192,14 @@ export class TelegramHandler { return; } - await this.storage.setUserModel(userId, alias, username); + // Store canonical alias (from model definition), not the user's raw input. + // This ensures exact-match lookups on subsequent requests. + const canonicalAlias = model.alias; + await this.storage.setUserModel(userId, canonicalAlias, username); await this.bot.sendMessage( chatId, `Model set to: ${model.name}\n` + - `Alias: /${alias}\n` + + `Alias: /${canonicalAlias}\n` + `${model.specialty}\n` + `Cost: ${model.cost}` );