diff --git a/docs/configuration.md b/docs/configuration.md index 61c2a4418..84306d25d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 23d3eaf04..096730ad5 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -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", + }); + }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index bd66c6656..9a187361d 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -6,6 +6,15 @@ export type ModelRef = { model: string; }; +export type ModelAliasIndex = { + byAlias: Map; + byKey: Map; +}; + +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(); + const byKey = new Map(); + + 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 }; } diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index ae9e5ca9c..02dde76f0 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -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"); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 66717ea80..c69e27a4a 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -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) ?? diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 3c65ad422..c2c9974d5 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -fb0fb0904efd421ea04c63e416231a7e94f2821725d68039e4168cc16f0a5bba +e9e0b910e9ca5c5795b68883312af12c82e153743be19311395ea5a9fc8503cc diff --git a/src/config/config.ts b/src/config/config.ts index 0ffc41858..b2a79a0d5 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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; /** 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([