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
106 changes: 105 additions & 1 deletion src/openrouter/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -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<string, ModelInfo> = {
'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', () => {
Expand Down
72 changes: 71 additions & 1 deletion src/openrouter/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,11 +694,81 @@ export function getAllModels(): Record<string, ModelInfo> {
/**
* 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;
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/telegram/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
);
Expand Down
Loading