feat(discord): add channel-level model overrides

- Add `model?: string` field to DiscordGuildChannelConfig and DiscordGuildEntry
- Add corresponding Zod schema validation for the new fields
- Update allow-list resolved types to include model field
- Pass channelModelOverride through the reply dispatch chain
- Apply channel model override in model selection (falls back when no session override)
- Add /model-channel command to set/clear channel model via Discord

Priority chain: channel config → guild config → agent default

Closes #3742
This commit is contained in:
Clawdbot 2026-01-29 01:39:08 -10:00
parent 5f4715acfc
commit 0cd7454392
12 changed files with 234 additions and 1 deletions

View File

@ -511,6 +511,20 @@ function buildChatCommands(): ChatCommandDefinition[] {
},
],
}),
defineChatCommand({
key: "model_channel",
nativeName: "model_channel",
description: "Set or clear the default model for this channel.",
textAlias: "/model-channel",
category: "options",
args: [
{
name: "model",
description: "Model id (provider/model or id), or 'clear' to remove override",
type: "string",
},
],
}),
defineChatCommand({
key: "models",
nativeName: "models",

View File

@ -16,7 +16,7 @@ import {
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import { handleModelsCommand, handleModelChannelCommand } from "./commands-models.js";
import { handleTtsCommands } from "./commands-tts.js";
import {
handleAbortTrigger,
@ -53,6 +53,7 @@ const HANDLERS: CommandHandler[] = [
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,
handleModelChannelCommand,
handleStopCommand,
handleCompactCommand,
handleAbortTrigger,

View File

@ -0,0 +1,149 @@
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import { resolveModelRefFromString, buildModelAliasIndex } from "../../agents/model-selection.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import type { MoltbotConfig } from "../../config/config.js";
import type { ReplyPayload } from "../types.js";
import type { CommandHandler } from "./commands-types.js";
export type ModelChannelCommandContext = {
cfg: MoltbotConfig;
commandBodyNormalized: string;
provider?: string;
surface?: string;
accountId?: string;
groupSpace?: string;
groupChannel?: string;
channelId?: string;
commandAuthorized?: boolean;
};
function extractChannelId(ctx: ModelChannelCommandContext): string | null {
// Try to extract channel ID from groupChannel (e.g., "#channel-name" -> id not available)
// or from channelId if passed directly
if (ctx.channelId) return ctx.channelId;
// For Discord, the channel ID is typically in the "To" field as "channel:<id>"
return null;
}
export async function resolveModelChannelCommandReply(
ctx: ModelChannelCommandContext,
): Promise<ReplyPayload | null> {
const body = ctx.commandBodyNormalized.trim();
if (!body.startsWith("/model-channel") && !body.startsWith("/model_channel")) return null;
const surface = ctx.surface?.toLowerCase() ?? ctx.provider?.toLowerCase();
// Only supported for Discord guild channels
if (surface !== "discord") {
return {
text: "The /model-channel command is only available in Discord servers.",
};
}
if (!ctx.groupSpace) {
return {
text: "The /model-channel command can only be used in server channels, not DMs.",
};
}
// Check if config writes are enabled
if (
!resolveChannelConfigWrites({ cfg: ctx.cfg, channelId: "discord", accountId: ctx.accountId })
) {
return {
text: "Config writes are disabled for this account. Enable `configWrites` in your Discord config to use this command.",
};
}
// Check authorization
if (ctx.commandAuthorized === false) {
return {
text: "You are not authorized to use this command.",
};
}
const argText = body.replace(/^\/model[-_]channel\b/i, "").trim();
const channelId = ctx.channelId;
const guildId = ctx.groupSpace;
if (!channelId) {
return {
text: "Could not determine the current channel. Please try again or specify the channel in your config directly.",
};
}
// Handle clear/reset
if (!argText || argText.toLowerCase() === "clear" || argText.toLowerCase() === "reset") {
const currentConfig = loadConfig();
const guildEntry = currentConfig.channels?.discord?.guilds?.[guildId];
const channelEntry = guildEntry?.channels?.[channelId];
if (!channelEntry?.model) {
return {
text: `No model override is set for this channel.`,
};
}
// Remove the model override
delete channelEntry.model;
// Clean up empty objects
if (Object.keys(channelEntry).length === 0 && guildEntry?.channels) {
delete guildEntry.channels[channelId];
}
await writeConfigFile(currentConfig);
return {
text: `Cleared model override for this channel. Messages will now use the guild or agent default model.`,
};
}
// Resolve the model reference
const aliasIndex = buildModelAliasIndex({
cfg: ctx.cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const resolved = resolveModelRefFromString({
raw: argText,
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) {
return {
text: `Unknown model "${argText}". Use /models to see available models.`,
};
}
const modelId = `${resolved.ref.provider}/${resolved.ref.model}`;
// Update the config
const currentConfig = loadConfig();
// Ensure the path exists
if (!currentConfig.channels) currentConfig.channels = {};
if (!currentConfig.channels.discord) currentConfig.channels.discord = {};
if (!currentConfig.channels.discord.guilds) currentConfig.channels.discord.guilds = {};
if (!currentConfig.channels.discord.guilds[guildId]) {
currentConfig.channels.discord.guilds[guildId] = {};
}
if (!currentConfig.channels.discord.guilds[guildId].channels) {
currentConfig.channels.discord.guilds[guildId].channels = {};
}
if (!currentConfig.channels.discord.guilds[guildId].channels[channelId]) {
currentConfig.channels.discord.guilds[guildId].channels[channelId] = {};
}
// Set the model
currentConfig.channels.discord.guilds[guildId].channels[channelId].model = modelId;
await writeConfigFile(currentConfig);
const displayModel = resolved.alias ? `${resolved.alias} (${modelId})` : modelId;
return {
text: `Set default model for this channel to **${displayModel}**. Use \`/model-channel clear\` to remove this override.`,
};
}

View File

@ -241,3 +241,35 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
if (!reply) return null;
return { reply, shouldContinue: false };
};
export const handleModelChannelCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const { resolveModelChannelCommandReply } = await import("./commands-model-channel.js");
const reply = await resolveModelChannelCommandReply({
cfg: params.cfg,
commandBodyNormalized: params.command.commandBodyNormalized,
provider: params.ctx.Provider,
surface: params.ctx.Surface,
accountId: params.ctx.AccountId,
groupSpace: params.ctx.GroupSpace,
groupChannel: params.ctx.GroupChannel,
channelId: extractChannelIdFromTo(params.ctx.To),
commandAuthorized: params.command.isAuthorizedSender,
});
if (!reply) return null;
return { reply, shouldContinue: false };
};
function extractChannelIdFromTo(to?: string): string | undefined {
if (!to) return undefined;
// Handle "channel:<id>" format
if (to.startsWith("channel:")) {
return to.slice("channel:".length);
}
// Handle "discord:channel:<id>" format
if (to.startsWith("discord:channel:")) {
return to.slice("discord:channel:".length);
}
return undefined;
}

View File

@ -106,6 +106,8 @@ export async function resolveReplyDirectives(params: {
typing: TypingController;
opts?: GetReplyOptions;
skillFilter?: string[];
/** Channel-level model override (fallback when no session override). */
channelModelOverride?: string;
}): Promise<ReplyDirectiveResult> {
const {
ctx,
@ -386,6 +388,7 @@ export async function resolveReplyDirectives(params: {
provider,
model,
hasModelDirective: directives.hasModelDirective,
channelModelOverride: params.channelModelOverride ?? opts?.channelModelOverride,
});
provider = modelState.provider;
model = modelState.model;

View File

@ -166,6 +166,7 @@ export async function getReplyFromConfig(
typing,
opts,
skillFilter: opts?.skillFilter,
channelModelOverride: opts?.channelModelOverride,
});
if (directiveResult.kind === "reply") {
return directiveResult.reply;

View File

@ -231,6 +231,8 @@ export async function createModelSelectionState(params: {
provider: string;
model: string;
hasModelDirective: boolean;
/** Channel-level model override (used as fallback when no session override). */
channelModelOverride?: string;
}): Promise<ModelSelectionState> {
const {
cfg,
@ -242,6 +244,7 @@ export async function createModelSelectionState(params: {
storePath,
defaultProvider,
defaultModel,
channelModelOverride,
} = params;
let provider = params.provider;
@ -310,6 +313,20 @@ export async function createModelSelectionState(params: {
provider = candidateProvider;
model = storedOverride.model;
}
} else if (channelModelOverride?.trim()) {
// Apply channel-level model override as fallback when no session override exists.
const channelRef = resolveModelRefFromString({
raw: channelModelOverride.trim(),
defaultProvider,
aliasIndex: { byAlias: new Map(), byKey: new Map() },
});
if (channelRef) {
const key = modelKey(channelRef.ref.provider, channelRef.ref.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
provider = channelRef.ref.provider;
model = channelRef.ref.model;
}
}
}
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {

View File

@ -39,6 +39,8 @@ export type GetReplyOptions = {
skillFilter?: string[];
/** Mutable ref to track if a reply was sent (for Slack "first" threading mode). */
hasRepliedRef?: { value: boolean };
/** Channel-level model override (fallback when no session override). */
channelModelOverride?: string;
};
export type ReplyPayload = {

View File

@ -37,6 +37,8 @@ export type DiscordGuildChannelConfig = {
users?: Array<string | number>;
/** Optional system prompt snippet for this channel. */
systemPrompt?: string;
/** Optional model override for this channel. */
model?: string;
};
export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist";
@ -51,6 +53,8 @@ export type DiscordGuildEntry = {
reactionNotifications?: DiscordReactionNotificationMode;
users?: Array<string | number>;
channels?: Record<string, DiscordGuildChannelConfig>;
/** Optional model override for this guild. */
model?: string;
};
export type DiscordActionConfig = {

View File

@ -196,6 +196,7 @@ export const DiscordGuildChannelSchema = z
users: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
autoThread: z.boolean().optional(),
model: z.string().optional(),
})
.strict();
@ -208,6 +209,7 @@ export const DiscordGuildSchema = z
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
model: z.string().optional(),
})
.strict();

View File

@ -33,8 +33,10 @@ export type DiscordGuildEntryResolved = {
users?: Array<string | number>;
systemPrompt?: string;
autoThread?: boolean;
model?: string;
}
>;
model?: string;
};
export type DiscordChannelConfigResolved = {
@ -47,6 +49,7 @@ export type DiscordChannelConfigResolved = {
autoThread?: boolean;
matchKey?: string;
matchSource?: ChannelMatchSource;
model?: string;
};
export function normalizeDiscordAllowList(
@ -215,6 +218,7 @@ function resolveDiscordChannelConfigEntry(
users: entry.users,
systemPrompt: entry.systemPrompt,
autoThread: entry.autoThread,
model: entry.model,
};
return resolved;
}

View File

@ -368,6 +368,9 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}).onReplyStart,
});
// Resolve channel-level model override: channel config takes precedence over guild config.
const channelModelOverride = channelConfig?.model ?? guildInfo?.model;
const { queuedFinal, counts } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
@ -382,6 +385,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
onModelSelected: (ctx) => {
prefixContext.onModelSelected(ctx);
},
channelModelOverride,
},
});
markDispatchIdle();