feat: add model aliases + minimax shortlist

This commit is contained in:
Peter Steinberger
2025-12-26 23:26:14 +00:00
parent ae9a8ce34c
commit 5c8e1b6eef
7 changed files with 178 additions and 28 deletions

View File

@@ -200,6 +200,7 @@ Controls inbound/outbound prefixes and timestamps.
Controls the embedded agent runtime (model/thinking/verbose/timeouts). Controls the embedded agent runtime (model/thinking/verbose/timeouts).
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist `allowedModels` lets `/model` list/filter and enforce a per-session allowlist
(omit to show the full catalog). (omit to show the full catalog).
`modelAliases` adds short names for `/model` (alias -> provider/model).
```json5 ```json5
{ {
@@ -209,6 +210,10 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
"anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-1" "anthropic/claude-sonnet-4-1"
], ],
modelAliases: {
Opus: "anthropic/claude-opus-4-5",
Sonnet: "anthropic/claude-sonnet-4-1"
},
thinkingDefault: "low", thinkingDefault: "low",
verboseDefault: "off", verboseDefault: "off",
timeoutSeconds: 600, timeoutSeconds: 600,
@@ -229,6 +234,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
``` ```
`agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). `agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
If `modelAliases` is configured, you may also use the alias key (e.g. `Opus`).
If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary
deprecation fallback. deprecation fallback.

View File

@@ -50,4 +50,26 @@ describe("resolveConfiguredModelRef", () => {
model: DEFAULT_MODEL, model: DEFAULT_MODEL,
}); });
}); });
it("resolves agent.model aliases when configured", () => {
const cfg = {
agent: {
model: "Opus",
modelAliases: {
Opus: "anthropic/claude-opus-4-5",
},
},
} satisfies ClawdisConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
});
});
}); });

View File

@@ -6,6 +6,15 @@ export type ModelRef = {
model: string; model: string;
}; };
export type ModelAliasIndex = {
byAlias: Map<string, { alias: string; ref: ModelRef }>;
byKey: Map<string, string[]>;
};
function normalizeAliasKey(value: string): string {
return value.trim().toLowerCase();
}
export function modelKey(provider: string, model: string) { export function modelKey(provider: string, model: string) {
return `${provider}/${model}`; return `${provider}/${model}`;
} }
@@ -26,6 +35,49 @@ export function parseModelRef(
return { provider, model }; return { provider, model };
} }
export function buildModelAliasIndex(params: {
cfg: ClawdisConfig;
defaultProvider: string;
}): ModelAliasIndex {
const rawAliases = params.cfg.agent?.modelAliases ?? {};
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
for (const [aliasRaw, targetRaw] of Object.entries(rawAliases)) {
const alias = aliasRaw.trim();
if (!alias) continue;
const parsed = parseModelRef(String(targetRaw ?? ""), params.defaultProvider);
if (!parsed) continue;
const aliasKey = normalizeAliasKey(alias);
byAlias.set(aliasKey, { alias, ref: parsed });
const key = modelKey(parsed.provider, parsed.model);
const existing = byKey.get(key) ?? [];
existing.push(alias);
byKey.set(key, existing);
}
return { byAlias, byKey };
}
export function resolveModelRefFromString(params: {
raw: string;
defaultProvider: string;
aliasIndex?: ModelAliasIndex;
}): { ref: ModelRef; alias?: string } | null {
const trimmed = params.raw.trim();
if (!trimmed) return null;
if (!trimmed.includes("/")) {
const aliasKey = normalizeAliasKey(trimmed);
const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey);
if (aliasMatch) {
return { ref: aliasMatch.ref, alias: aliasMatch.alias };
}
}
const parsed = parseModelRef(trimmed, params.defaultProvider);
if (!parsed) return null;
return { ref: parsed };
}
export function resolveConfiguredModelRef(params: { export function resolveConfiguredModelRef(params: {
cfg: ClawdisConfig; cfg: ClawdisConfig;
defaultProvider: string; defaultProvider: string;
@@ -34,10 +86,16 @@ export function resolveConfiguredModelRef(params: {
const rawModel = params.cfg.agent?.model?.trim() || ""; const rawModel = params.cfg.agent?.model?.trim() || "";
if (rawModel) { if (rawModel) {
const trimmed = rawModel.trim(); const trimmed = rawModel.trim();
if (trimmed.includes("/")) { const aliasIndex = buildModelAliasIndex({
const parsed = parseModelRef(trimmed, params.defaultProvider); cfg: params.cfg,
if (parsed) return parsed; defaultProvider: params.defaultProvider,
} });
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: params.defaultProvider,
aliasIndex,
});
if (resolved) return resolved.ref;
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated. // TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated.
return { provider: "anthropic", model: trimmed }; return { provider: "anthropic", model: trimmed };
} }

View File

@@ -393,6 +393,41 @@ describe("directive parsing", () => {
}); });
}); });
it("supports model aliases 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 Opus", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "openai/gpt-4.1-mini",
workspace: path.join(home, "clawd"),
allowedModels: [
"openai/gpt-4.1-mini",
"anthropic/claude-opus-4-5",
],
modelAliases: {
Opus: "anthropic/claude-opus-4-5",
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to Opus");
expect(text).toContain("anthropic/claude-opus-4-5");
const store = loadSessionStore(storePath);
const entry = store.main;
expect(entry.modelOverride).toBe("claude-opus-4-5");
expect(entry.providerOverride).toBe("anthropic");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("uses model override for inline /model", async () => { it("uses model override for inline /model", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");

View File

@@ -9,8 +9,9 @@ import {
import { loadModelCatalog } from "../agents/model-catalog.js"; import { loadModelCatalog } from "../agents/model-catalog.js";
import { import {
buildAllowedModelSet, buildAllowedModelSet,
buildModelAliasIndex,
modelKey, modelKey,
parseModelRef, resolveModelRefFromString,
resolveConfiguredModelRef, resolveConfiguredModelRef,
} from "../agents/model-selection.js"; } from "../agents/model-selection.js";
import { import {
@@ -252,16 +253,21 @@ export async function getReplyFromConfig(
}); });
const defaultProvider = mainModel.provider; const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model; const defaultModel = mainModel.model;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider });
let provider = defaultProvider; let provider = defaultProvider;
let model = defaultModel; let model = defaultModel;
if (opts?.isHeartbeat) { if (opts?.isHeartbeat) {
const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? ""; const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
const heartbeatRef = heartbeatRaw const heartbeatRef = heartbeatRaw
? parseModelRef(heartbeatRaw, defaultProvider) ? resolveModelRefFromString({
raw: heartbeatRaw,
defaultProvider,
aliasIndex,
})
: null; : null;
if (heartbeatRef) { if (heartbeatRef) {
provider = heartbeatRef.provider; provider = heartbeatRef.ref.provider;
model = heartbeatRef.model; model = heartbeatRef.ref.model;
} }
} }
let contextTokens = let contextTokens =
@@ -571,9 +577,12 @@ export async function getReplyFromConfig(
} }
for (const entry of allowedModelCatalog) { for (const entry of allowedModelCatalog) {
const label = `${entry.provider}/${entry.id}`; const label = `${entry.provider}/${entry.id}`;
const aliases = aliasIndex.byKey.get(label);
const aliasSuffix =
aliases && aliases.length > 0 ? ` (alias: ${aliases.join(", ")})` : "";
const suffix = const suffix =
entry.name && entry.name !== entry.id ? `${entry.name}` : ""; entry.name && entry.name !== entry.id ? `${entry.name}` : "";
lines.push(`- ${label}${suffix}`); lines.push(`- ${label}${aliasSuffix}${suffix}`);
} }
cleanupTyping(); cleanupTyping();
return { text: lines.join("\n") }; return { text: lines.join("\n") };
@@ -598,26 +607,36 @@ export async function getReplyFromConfig(
} }
let modelSelection: let modelSelection:
| { provider: string; model: string; isDefault: boolean } | { provider: string; model: string; isDefault: boolean; alias?: string }
| undefined; | undefined;
if (hasModelDirective && rawModelDirective) { if (hasModelDirective && rawModelDirective) {
const parsed = parseModelRef(rawModelDirective, defaultProvider); const resolved = resolveModelRefFromString({
if (!parsed) { raw: rawModelDirective,
defaultProvider,
aliasIndex,
});
if (!resolved) {
cleanupTyping(); cleanupTyping();
return { return {
text: `Unrecognized model "${rawModelDirective}". Use /model to list available models.`, text: `Unrecognized model "${rawModelDirective}". Use /model to list available models.`,
}; };
} }
const key = modelKey(parsed.provider, parsed.model); const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
cleanupTyping(); cleanupTyping();
return { return {
text: `Model "${parsed.provider}/${parsed.model}" is not allowed. Use /model to list available models.`, text: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
}; };
} }
const isDefault = const isDefault =
parsed.provider === defaultProvider && parsed.model === defaultModel; resolved.ref.provider === defaultProvider &&
modelSelection = { ...parsed, isDefault }; resolved.ref.model === defaultModel;
modelSelection = {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
alias: resolved.alias,
};
} }
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {
@@ -665,10 +684,13 @@ export async function getReplyFromConfig(
} }
if (modelSelection) { if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`; const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias
? `${modelSelection.alias} (${label})`
: label;
parts.push( parts.push(
modelSelection.isDefault modelSelection.isDefault
? `Model reset to default (${label}).` ? `Model reset to default (${labelWithAlias}).`
: `Model set to ${label}.`, : `Model set to ${labelWithAlias}.`,
); );
} }
if (hasQueueDirective && inlineQueueMode) { if (hasQueueDirective && inlineQueueMode) {
@@ -701,22 +723,26 @@ export async function getReplyFromConfig(
updated = true; updated = true;
} }
if (hasModelDirective && rawModelDirective) { if (hasModelDirective && rawModelDirective) {
const parsed = parseModelRef(rawModelDirective, defaultProvider); const resolved = resolveModelRefFromString({
if (parsed) { raw: rawModelDirective,
const key = modelKey(parsed.provider, parsed.model); defaultProvider,
aliasIndex,
});
if (resolved) {
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
const isDefault = const isDefault =
parsed.provider === defaultProvider && resolved.ref.provider === defaultProvider &&
parsed.model === defaultModel; resolved.ref.model === defaultModel;
if (isDefault) { if (isDefault) {
delete sessionEntry.providerOverride; delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride; delete sessionEntry.modelOverride;
} else { } else {
sessionEntry.providerOverride = parsed.provider; sessionEntry.providerOverride = resolved.ref.provider;
sessionEntry.modelOverride = parsed.model; sessionEntry.modelOverride = resolved.ref.model;
} }
provider = parsed.provider; provider = resolved.ref.provider;
model = parsed.model; model = resolved.ref.model;
contextTokens = contextTokens =
agentCfg?.contextTokens ?? agentCfg?.contextTokens ??
lookupContextTokens(model) ?? lookupContextTokens(model) ??

View File

@@ -1 +1 @@
fb0fb0904efd421ea04c63e416231a7e94f2821725d68039e4168cc16f0a5bba e9e0b910e9ca5c5795b68883312af12c82e153743be19311395ea5a9fc8503cc

View File

@@ -343,6 +343,8 @@ export type ClawdisConfig = {
workspace?: string; workspace?: string;
/** Optional allowlist for /model (provider/model or model-only). */ /** Optional allowlist for /model (provider/model or model-only). */
allowedModels?: string[]; allowedModels?: string[];
/** Optional model aliases for /model (alias -> provider/model). */
modelAliases?: Record<string, string>;
/** Optional display-only context window override (used for % in status UIs). */ /** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number; contextTokens?: number;
/** Default thinking level when no /think directive is present. */ /** Default thinking level when no /think directive is present. */
@@ -662,6 +664,7 @@ const ClawdisSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
workspace: z.string().optional(), workspace: z.string().optional(),
allowedModels: z.array(z.string()).optional(), allowedModels: z.array(z.string()).optional(),
modelAliases: z.record(z.string(), z.string()).optional(),
contextTokens: z.number().int().positive().optional(), contextTokens: z.number().int().positive().optional(),
thinkingDefault: z thinkingDefault: z
.union([ .union([