diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fed92e9f..e082cdb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 46166c2f1..8b726b8e4 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -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(); diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index c449809cd..cefef3c6d 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -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({