feat(commands): add /models and fix /model listing UX
This commit is contained in:
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user