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).
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) ??
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fb0fb0904efd421ea04c63e416231a7e94f2821725d68039e4168cc16f0a5bba
|
e9e0b910e9ca5c5795b68883312af12c82e153743be19311395ea5a9fc8503cc
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user