diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f1c74f5..f9c82a62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ - Agents: skip pre-compaction memory flush when the session workspace is read-only. - Auto-reply: allow inline `/status` for allowlisted senders (stripped before the model); unauthorized senders see it as plain text. - Auto-reply: include config-only allowlisted models in `/model` even when the catalog is partial. +- Auto-reply: allow fuzzy `/model` matches (e.g. `/model kimi` or `/model moonshot/kimi`) when unambiguous. - Auto-reply: ignore inline `/status` directives unless the message is directive-only. - CLI/Configure: enter the selected section immediately, then return to the section picker. - Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo. diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index da6f30c5a..d3ba3aea1 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -62,6 +62,41 @@ describe("buildAllowedModelSet", () => { true, ); }); + + it("allows explicit custom providers from models.providers", () => { + const cfg = { + agents: { + defaults: { + models: { + "moonshot/kimi-k2-0905-preview": { alias: "kimi" }, + }, + }, + }, + models: { + mode: "merge", + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "x", + api: "openai-completions", + models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }], + }, + }, + }, + } as ClawdbotConfig; + + const allowed = buildAllowedModelSet({ + cfg, + catalog: [], + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + }); + + expect(allowed.allowAny).toBe(false); + expect( + allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview")), + ).toBe(true); + }); }); describe("parseModelRef", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 101804ec6..a66ef7dab 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -179,14 +179,23 @@ export function buildAllowedModelSet(params: { } const allowedKeys = new Set(); + const configuredProviders = (params.cfg.models?.providers ?? {}) as Record< + string, + unknown + >; for (const raw of rawAllowlist) { const parsed = parseModelRef(String(raw), params.defaultProvider); if (!parsed) continue; const key = modelKey(parsed.provider, parsed.model); + const providerKey = normalizeProviderId(parsed.provider); if (isCliProvider(parsed.provider, params.cfg)) { allowedKeys.add(key); } else if (catalogKeys.has(key)) { allowedKeys.add(key); + } else if (configuredProviders[providerKey] != null) { + // Explicitly configured providers should be allowlist-able even when + // they don't exist in the curated model catalog. + allowedKeys.add(key); } } diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 7c6502624..b445c18a4 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -1698,6 +1698,94 @@ describe("directive behavior", () => { }); }); + it("supports fuzzy model matches on /model directive", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model kimi", From: "+1222", To: "+1222" }, + {}, + { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "moonshot/kimi-k2-0905-preview": {}, + }, + }, + }, + models: { + mode: "merge", + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2" }], + }, + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview"); + const store = loadSessionStore(storePath); + const entry = store["agent:main:main"]; + expect(entry.modelOverride).toBe("kimi-k2-0905-preview"); + expect(entry.providerOverride).toBe("moonshot"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("supports fuzzy matches within a provider on /model provider/model", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model moonshot/kimi", From: "+1222", To: "+1222" }, + {}, + { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "moonshot/kimi-k2-0905-preview": {}, + }, + }, + }, + models: { + mode: "merge", + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2" }], + }, + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview"); + const store = loadSessionStore(storePath); + const entry = store["agent:main:main"]; + expect(entry.modelOverride).toBe("kimi-k2-0905-preview"); + expect(entry.providerOverride).toBe("moonshot"); + 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/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index d1e4f4e45..715715157 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -960,9 +960,21 @@ export async function handleDirectiveOnly(params: { defaultModel, }); const pickerCatalog: ModelPickerCatalogEntry[] = (() => { - if (allowedModelCatalog.length > 0) return allowedModelCatalog; const keys = new Set(); const out: ModelPickerCatalogEntry[] = []; + + const push = (entry: ModelPickerCatalogEntry) => { + const provider = normalizeProviderId(entry.provider); + const id = String(entry.id ?? "").trim(); + if (!provider || !id) return; + const key = modelKey(provider, id); + if (keys.has(key)) return; + keys.add(key); + out.push({ provider, id, name: entry.name }); + }; + + for (const entry of allowedModelCatalog) push(entry); + for (const rawKey of Object.keys( params.cfg.agents?.defaults?.models ?? {}, )) { @@ -972,19 +984,14 @@ export async function handleDirectiveOnly(params: { aliasIndex, }); if (!resolved) continue; - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (keys.has(key)) continue; - keys.add(key); - out.push({ + push({ provider: resolved.ref.provider, id: resolved.ref.model, name: resolved.ref.model, }); } - if (out.length === 0 && resolvedDefault.model) { - const key = modelKey(resolvedDefault.provider, resolvedDefault.model); - keys.add(key); - out.push({ + if (resolvedDefault.model) { + push({ provider: resolvedDefault.provider, id: resolvedDefault.model, name: resolvedDefault.model, diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index d0733e273..c449809cd 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -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.`, }; }