feat: add model aliases + minimax shortlist
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -1 +1 @@
|
||||
fb0fb0904efd421ea04c63e416231a7e94f2821725d68039e4168cc16f0a5bba
|
||||
e9e0b910e9ca5c5795b68883312af12c82e153743be19311395ea5a9fc8503cc
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user