feat(model): fuzzy /model matching
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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