feat(model): fuzzy /model matching
This commit is contained in:
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user