fix: pick best fuzzy model match

This commit is contained in:
Peter Steinberger
2026-01-12 22:42:07 +00:00
parent 209380edf8
commit ec5099db89
3 changed files with 188 additions and 12 deletions

View File

@@ -10,6 +10,7 @@
### Fixes
- Tools/Models: MiniMax vision now uses the Coding Plan VLM endpoint (`/v1/coding_plan/vlm`) so the `image` tool works with MiniMax keys (also accepts `@/path/to/file.png`-style inputs).
- Gateway/macOS: reduce noisy loopback WS “closed before connect” logs during tests.
- Auto-reply: resolve ambiguous `/model` fuzzy matches by picking the best candidate instead of erroring.
## 2026.1.12-1

View File

@@ -1944,6 +1944,57 @@ describe("directive behavior", () => {
});
});
it("picks the best fuzzy match when multiple models match", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model minimax", From: "+1222", To: "+1222" },
{},
{
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M2.1" },
workspace: path.join(home, "clawd"),
models: {
"minimax/MiniMax-M2.1": {},
"minimax/MiniMax-M2.1-lightning": {},
"lmstudio/minimax-m2.1-gs32": {},
},
},
},
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "sk-test",
api: "anthropic-messages",
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
},
lmstudio: {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [{ id: "minimax-m2.1-gs32", name: "MiniMax M2.1 GS32" }],
},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to minimax/MiniMax-M2.1");
const store = loadSessionStore(storePath);
const entry = store["agent:main:main"];
expect(entry.modelOverride).toBe("MiniMax-M2.1");
expect(entry.providerOverride).toBe("minimax");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("stores auth profile overrides on /model directive", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@@ -32,6 +32,113 @@ type ModelSelectionState = {
needsModelCatalog: boolean;
};
const FUZZY_VARIANT_TOKENS = [
"lightning",
"preview",
"mini",
"fast",
"turbo",
"lite",
"beta",
"small",
"nano",
];
function scoreFuzzyMatch(params: {
provider: string;
model: string;
fragment: string;
aliasIndex: ModelAliasIndex;
defaultProvider: string;
defaultModel: string;
}): {
score: number;
isDefault: boolean;
variantCount: number;
variantMatchCount: number;
modelLength: number;
key: string;
} {
const provider = normalizeProviderId(params.provider);
const model = params.model;
const fragment = params.fragment.trim().toLowerCase();
const providerLower = provider.toLowerCase();
const modelLower = model.toLowerCase();
const haystack = `${providerLower}/${modelLower}`;
const key = modelKey(provider, model);
const scoreFragment = (
value: string,
weights: { exact: number; starts: number; includes: number },
) => {
if (!fragment) return 0;
let score = 0;
if (value === fragment) score = Math.max(score, weights.exact);
if (value.startsWith(fragment))
score = Math.max(score, weights.starts);
if (value.includes(fragment))
score = Math.max(score, weights.includes);
return score;
};
let score = 0;
score += scoreFragment(haystack, { exact: 220, starts: 140, includes: 110 });
score += scoreFragment(providerLower, {
exact: 180,
starts: 120,
includes: 90,
});
score += scoreFragment(modelLower, {
exact: 160,
starts: 110,
includes: 80,
});
const aliases = params.aliasIndex.byKey.get(key) ?? [];
for (const alias of aliases) {
score += scoreFragment(alias.toLowerCase(), {
exact: 140,
starts: 90,
includes: 60,
});
}
if (modelLower.startsWith(providerLower)) {
score += 30;
}
const fragmentVariants = FUZZY_VARIANT_TOKENS.filter((token) =>
fragment.includes(token),
);
const modelVariants = FUZZY_VARIANT_TOKENS.filter((token) =>
modelLower.includes(token),
);
const variantMatchCount = fragmentVariants.filter((token) =>
modelLower.includes(token),
).length;
const variantCount = modelVariants.length;
if (fragmentVariants.length === 0 && variantCount > 0) {
score -= variantCount * 30;
} else if (fragmentVariants.length > 0) {
if (variantMatchCount > 0) score += variantMatchCount * 40;
if (variantMatchCount === 0) score -= 20;
}
const defaultProvider = normalizeProviderId(params.defaultProvider);
const isDefault =
provider === defaultProvider && model === params.defaultModel;
if (isDefault) score += 20;
return {
score,
isDefault,
variantCount,
variantMatchCount,
modelLength: modelLower.length,
key,
};
}
export async function createModelSelectionState(params: {
cfg: ClawdbotConfig;
agentCfg:
@@ -250,18 +357,35 @@ export function resolveModelDirectiveSelection(params: {
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 {};
if (candidates.length === 0) return {};
const scored = candidates
.map((candidate) => {
const details = scoreFuzzyMatch({
provider: candidate.provider,
model: candidate.model,
fragment,
aliasIndex,
defaultProvider,
defaultModel,
});
return { candidate, ...details };
})
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
if (a.variantMatchCount !== b.variantMatchCount)
return b.variantMatchCount - a.variantMatchCount;
if (a.variantCount !== b.variantCount)
return a.variantCount - b.variantCount;
if (a.modelLength !== b.modelLength)
return a.modelLength - b.modelLength;
return a.key.localeCompare(b.key);
});
const best = scored[0]?.candidate;
if (!best) return {};
return { selection: buildSelection(best.provider, best.model) };
};
const resolved = resolveModelRefFromString({