feat(commands): add /models and fix /model listing UX

This commit is contained in:
Vignesh Natarajan
2026-01-21 11:53:02 -08:00
parent 9f59ff325b
commit 41d56c06b9
8 changed files with 450 additions and 50 deletions

View File

@@ -46,6 +46,39 @@ const FUZZY_VARIANT_TOKENS = [
"nano",
];
function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null {
if (a === b) return 0;
if (!a || !b) return null;
const aLen = a.length;
const bLen = b.length;
if (Math.abs(aLen - bLen) > maxDistance) return null;
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
const prev = new Array<number>(bLen + 1);
const curr = new Array<number>(bLen + 1);
for (let j = 0; j <= bLen; j++) prev[j] = j;
for (let i = 1; i <= aLen; i++) {
curr[0] = i;
let rowMin = curr[0];
const aChar = a.charCodeAt(i - 1);
for (let j = 1; j <= bLen; j++) {
const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
if (curr[j] < rowMin) rowMin = curr[j];
}
if (rowMin > maxDistance) return null;
for (let j = 0; j <= bLen; j++) prev[j] = curr[j] ?? 0;
}
const dist = prev[bLen] ?? null;
if (dist == null || dist > maxDistance) return null;
return dist;
}
function scoreFuzzyMatch(params: {
provider: string;
model: string;
@@ -94,6 +127,13 @@ function scoreFuzzyMatch(params: {
includes: 80,
});
// Best-effort typo tolerance for common near-misses like "claud" vs "claude".
// Bounded to keep this cheap across large model sets.
const distModel = boundedLevenshteinDistance(fragment, modelLower, 3);
if (distModel != null) {
score += (3 - distModel) * 70;
}
const aliases = params.aliasIndex.byKey.get(key) ?? [];
for (const alias of aliases) {
score += scoreFragment(alias.toLowerCase(), {
@@ -293,17 +333,16 @@ export function resolveModelDirectiveSelection(params: {
const fragment = params.fragment.trim().toLowerCase();
if (!fragment) return {};
const providerFilter = params.provider ? normalizeProviderId(params.provider) : undefined;
const candidates: Array<{ provider: string; model: string }> = [];
for (const key of allowedModelKeys) {
const slash = key.indexOf("/");
if (slash <= 0) continue;
const provider = normalizeProviderId(key.slice(0, slash));
const model = key.slice(slash + 1);
if (params.provider && provider !== normalizeProviderId(params.provider)) continue;
const haystack = `${provider}/${model}`.toLowerCase();
if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) {
candidates.push({ provider, model });
}
if (providerFilter && provider !== providerFilter) continue;
candidates.push({ provider, model });
}
// Also allow partial alias matches when the user didn't specify a provider.
@@ -325,11 +364,6 @@ export function resolveModelDirectiveSelection(params: {
}
}
if (candidates.length === 1) {
const match = candidates[0];
if (!match) return {};
return { selection: buildSelection(match.provider, match.model) };
}
if (candidates.length === 0) return {};
const scored = candidates
@@ -354,8 +388,13 @@ export function resolveModelDirectiveSelection(params: {
return a.key.localeCompare(b.key);
});
const best = scored[0]?.candidate;
if (!best) return {};
const bestScored = scored[0];
const best = bestScored?.candidate;
if (!best || !bestScored) return {};
const minScore = providerFilter ? 90 : 120;
if (bestScored.score < minScore) return {};
return { selection: buildSelection(best.provider, best.model) };
};
@@ -369,7 +408,7 @@ export function resolveModelDirectiveSelection(params: {
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
if (fuzzy.selection || fuzzy.error) return fuzzy;
return {
error: `Unrecognized model "${rawTrimmed}". Use /model to list available models.`,
error: `Unrecognized model "${rawTrimmed}". Use /models to list providers, or /models <provider> to list models.`,
};
}
@@ -400,7 +439,7 @@ export function resolveModelDirectiveSelection(params: {
if (fuzzy.selection || fuzzy.error) return fuzzy;
return {
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /models to list providers, or /models <provider> to list models.`,
};
}