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

@@ -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.

View File

@@ -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", () => {

View File

@@ -179,14 +179,23 @@ export function buildAllowedModelSet(params: {
}
const allowedKeys = new Set<string>();
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);
}
}

View File

@@ -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();

View File

@@ -960,9 +960,21 @@ export async function handleDirectiveOnly(params: {
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
if (allowedModelCatalog.length > 0) return allowedModelCatalog;
const keys = new Set<string>();
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,

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.`,
};
}