feat(model): fuzzy /model matching

This commit is contained in:
Peter Steinberger
2026-01-12 07:57:11 +00:00
parent e79cf5a8b1
commit 60823fd9bd
6 changed files with 270 additions and 23 deletions

View File

@@ -5,6 +5,7 @@ import {
buildAllowedModelSet,
type ModelAliasIndex,
modelKey,
normalizeProviderId,
resolveModelRefFromString,
resolveThinkingDefault,
} from "../../agents/model-selection.js";
@@ -175,32 +176,138 @@ export function resolveModelDirectiveSelection(params: {
}): { selection?: ModelDirectiveSelection; error?: string } {
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
params;
const rawTrimmed = raw.trim();
const rawLower = rawTrimmed.toLowerCase();
const pickAliasForKey = (
provider: string,
model: string,
): string | undefined => aliasIndex.byKey.get(modelKey(provider, model))?.[0];
const buildSelection = (
provider: string,
model: string,
): ModelDirectiveSelection => {
const alias = pickAliasForKey(provider, model);
return {
provider,
model,
isDefault: provider === defaultProvider && model === defaultModel,
...(alias ? { alias } : undefined),
};
};
const resolveFuzzy = (params: {
provider?: string;
fragment: string;
}): { selection?: ModelDirectiveSelection; error?: string } => {
const fragment = params.fragment.trim().toLowerCase();
if (!fragment) return {};
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 });
}
}
// Also allow partial alias matches when the user didn't specify a provider.
if (!params.provider) {
const aliasMatches: Array<{ provider: string; model: string }> = [];
for (const [aliasKey, entry] of aliasIndex.byAlias.entries()) {
if (!aliasKey.includes(fragment)) continue;
aliasMatches.push({
provider: entry.ref.provider,
model: entry.ref.model,
});
}
for (const match of aliasMatches) {
const key = modelKey(match.provider, match.model);
if (!allowedModelKeys.has(key)) continue;
if (
!candidates.some(
(c) => c.provider === match.provider && c.model === match.model,
)
) {
candidates.push(match);
}
}
}
if (candidates.length === 1) {
const match = candidates[0];
if (!match) return {};
return { selection: buildSelection(match.provider, match.model) };
}
if (candidates.length > 1) {
const shown = candidates
.slice(0, 5)
.map((c) => `${c.provider}/${c.model}`)
.join(", ");
const more =
candidates.length > 5 ? ` (+${candidates.length - 5} more)` : "";
return {
error: `Ambiguous model "${rawTrimmed}". Matches: ${shown}${more}. Use /model to list or specify provider/model.`,
};
}
return {};
};
const resolved = resolveModelRefFromString({
raw,
raw: rawTrimmed,
defaultProvider,
aliasIndex,
});
if (!resolved) {
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
if (fuzzy.selection || fuzzy.error) return fuzzy;
return {
error: `Unrecognized model "${raw}". Use /model to list available models.`,
error: `Unrecognized model "${rawTrimmed}". Use /model to list available models.`,
};
}
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
const resolvedKey = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(resolvedKey)) {
return {
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault:
resolved.ref.provider === defaultProvider &&
resolved.ref.model === defaultModel,
alias: resolved.alias,
},
};
}
const isDefault =
resolved.ref.provider === defaultProvider &&
resolved.ref.model === defaultModel;
// If the user specified a provider/model but the exact model isn't allowed,
// attempt a fuzzy match within that provider.
if (rawLower.includes("/")) {
const slash = rawTrimmed.indexOf("/");
const provider = normalizeProviderId(rawTrimmed.slice(0, slash).trim());
const fragment = rawTrimmed.slice(slash + 1).trim();
const fuzzy = resolveFuzzy({ provider, fragment });
if (fuzzy.selection || fuzzy.error) return fuzzy;
}
// Otherwise, try fuzzy matching across allowlisted models.
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
if (fuzzy.selection || fuzzy.error) return fuzzy;
return {
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
alias: resolved.alias,
},
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
};
}