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.
|
- 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: 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: 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.
|
- 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.
|
- 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.
|
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
|
||||||
|
|||||||
@@ -62,6 +62,41 @@ describe("buildAllowedModelSet", () => {
|
|||||||
true,
|
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", () => {
|
describe("parseModelRef", () => {
|
||||||
|
|||||||
@@ -179,14 +179,23 @@ export function buildAllowedModelSet(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allowedKeys = new Set<string>();
|
const allowedKeys = new Set<string>();
|
||||||
|
const configuredProviders = (params.cfg.models?.providers ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
for (const raw of rawAllowlist) {
|
for (const raw of rawAllowlist) {
|
||||||
const parsed = parseModelRef(String(raw), params.defaultProvider);
|
const parsed = parseModelRef(String(raw), params.defaultProvider);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
const key = modelKey(parsed.provider, parsed.model);
|
const key = modelKey(parsed.provider, parsed.model);
|
||||||
|
const providerKey = normalizeProviderId(parsed.provider);
|
||||||
if (isCliProvider(parsed.provider, params.cfg)) {
|
if (isCliProvider(parsed.provider, params.cfg)) {
|
||||||
allowedKeys.add(key);
|
allowedKeys.add(key);
|
||||||
} else if (catalogKeys.has(key)) {
|
} else if (catalogKeys.has(key)) {
|
||||||
allowedKeys.add(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 () => {
|
it("stores auth profile overrides on /model directive", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
|||||||
@@ -960,9 +960,21 @@ export async function handleDirectiveOnly(params: {
|
|||||||
defaultModel,
|
defaultModel,
|
||||||
});
|
});
|
||||||
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
|
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
|
||||||
if (allowedModelCatalog.length > 0) return allowedModelCatalog;
|
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
const out: ModelPickerCatalogEntry[] = [];
|
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(
|
for (const rawKey of Object.keys(
|
||||||
params.cfg.agents?.defaults?.models ?? {},
|
params.cfg.agents?.defaults?.models ?? {},
|
||||||
)) {
|
)) {
|
||||||
@@ -972,19 +984,14 @@ export async function handleDirectiveOnly(params: {
|
|||||||
aliasIndex,
|
aliasIndex,
|
||||||
});
|
});
|
||||||
if (!resolved) continue;
|
if (!resolved) continue;
|
||||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
push({
|
||||||
if (keys.has(key)) continue;
|
|
||||||
keys.add(key);
|
|
||||||
out.push({
|
|
||||||
provider: resolved.ref.provider,
|
provider: resolved.ref.provider,
|
||||||
id: resolved.ref.model,
|
id: resolved.ref.model,
|
||||||
name: resolved.ref.model,
|
name: resolved.ref.model,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (out.length === 0 && resolvedDefault.model) {
|
if (resolvedDefault.model) {
|
||||||
const key = modelKey(resolvedDefault.provider, resolvedDefault.model);
|
push({
|
||||||
keys.add(key);
|
|
||||||
out.push({
|
|
||||||
provider: resolvedDefault.provider,
|
provider: resolvedDefault.provider,
|
||||||
id: resolvedDefault.model,
|
id: resolvedDefault.model,
|
||||||
name: resolvedDefault.model,
|
name: resolvedDefault.model,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildAllowedModelSet,
|
buildAllowedModelSet,
|
||||||
type ModelAliasIndex,
|
type ModelAliasIndex,
|
||||||
modelKey,
|
modelKey,
|
||||||
|
normalizeProviderId,
|
||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
resolveThinkingDefault,
|
resolveThinkingDefault,
|
||||||
} from "../../agents/model-selection.js";
|
} from "../../agents/model-selection.js";
|
||||||
@@ -175,32 +176,138 @@ export function resolveModelDirectiveSelection(params: {
|
|||||||
}): { selection?: ModelDirectiveSelection; error?: string } {
|
}): { selection?: ModelDirectiveSelection; error?: string } {
|
||||||
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
|
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
|
||||||
params;
|
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({
|
const resolved = resolveModelRefFromString({
|
||||||
raw,
|
raw: rawTrimmed,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
aliasIndex,
|
aliasIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
|
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
|
||||||
|
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
||||||
return {
|
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 {
|
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 &&
|
// If the user specified a provider/model but the exact model isn't allowed,
|
||||||
resolved.ref.model === defaultModel;
|
// 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 {
|
return {
|
||||||
selection: {
|
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
|
||||||
provider: resolved.ref.provider,
|
|
||||||
model: resolved.ref.model,
|
|
||||||
isDefault,
|
|
||||||
alias: resolved.alias,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user