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).
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
(omit to show the full catalog).
`modelAliases` adds short names for `/model` (alias -> provider/model).
```json5
{
@@ -209,6 +210,10 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-1"
],
modelAliases: {
Opus: "anthropic/claude-opus-4-5",
Sonnet: "anthropic/claude-sonnet-4-1"
},
thinkingDefault: "low",
verboseDefault: "off",
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`).
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
deprecation fallback.

View File

@@ -50,4 +50,26 @@ describe("resolveConfiguredModelRef", () => {
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;
};
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) {
return `${provider}/${model}`;
}
@@ -26,6 +35,49 @@ export function parseModelRef(
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: {
cfg: ClawdisConfig;
defaultProvider: string;
@@ -34,10 +86,16 @@ export function resolveConfiguredModelRef(params: {
const rawModel = params.cfg.agent?.model?.trim() || "";
if (rawModel) {
const trimmed = rawModel.trim();
if (trimmed.includes("/")) {
const parsed = parseModelRef(trimmed, params.defaultProvider);
if (parsed) return parsed;
}
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
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.
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 () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");

View File

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

View File

@@ -1 +1 @@
fb0fb0904efd421ea04c63e416231a7e94f2821725d68039e4168cc16f0a5bba
e9e0b910e9ca5c5795b68883312af12c82e153743be19311395ea5a9fc8503cc

View File

@@ -343,6 +343,8 @@ export type ClawdisConfig = {
workspace?: string;
/** Optional allowlist for /model (provider/model or model-only). */
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). */
contextTokens?: number;
/** Default thinking level when no /think directive is present. */
@@ -662,6 +664,7 @@ const ClawdisSchema = z.object({
model: z.string().optional(),
workspace: z.string().optional(),
allowedModels: z.array(z.string()).optional(),
modelAliases: z.record(z.string(), z.string()).optional(),
contextTokens: z.number().int().positive().optional(),
thinkingDefault: z
.union([