diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 6af43959776a..4ec9ff6e8d18 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -43,7 +43,18 @@ If a provider block is missing entirely (`channels.` absent), runtime ### Channel model overrides -Use `channels.modelByChannel` to pin specific channel IDs to a model. Values accept `provider/model` or configured model aliases. The channel mapping applies when a session does not already have a model override (for example, set via `/model`). +Use `channels.modelByChannel` to pin specific channel IDs or direct-message peers to a model. Values accept `provider/model` or configured model aliases. The channel mapping applies when a session does not already have a model override (for example, set via `/model`). + +For group/thread conversations, keys are channel-specific group IDs, topic IDs, or channel names. For direct-message (DM) conversations, keys are peer identifiers derived from the channel's sender identity (`nativeDirectUserId`, `origin.from`, `origin.to`, `OriginatingTo`, `From`, or `SenderId`). The exact key form depends on the channel: + +| Channel | DM key form | Example | +| -------- | ------------------- | -------------------------------------------- | +| Slack | `user:U...` | `user:U12345` | +| Telegram | raw user ID | `123456789` | +| Discord | raw user ID | `987654321` | +| WhatsApp | phone number or JID | `15551234567` | +| Matrix | Matrix user ID | `@user:matrix.org` | +| Feishu | `feishu:ou_...` | `feishu:ou_a8b6cab7e945387de5f253775d9b4d85` | ```json5 { @@ -54,16 +65,20 @@ Use `channels.modelByChannel` to pin specific channel IDs to a model. Values acc }, slack: { C1234567890: "openai/gpt-5.5", + "user:U12345": "openai/gpt-5.4-mini", }, telegram: { "-1001234567890": "openai/gpt-5.4-mini", "-1001234567890:topic:99": "anthropic/claude-sonnet-4-6", + "123456789": "openai/gpt-4.1", }, }, }, } ``` +DM-specific keys only match in direct-message conversations; they do not affect group/thread routing. + ### Channel defaults and heartbeat Use `channels.defaults` for shared group-policy and heartbeat behavior across providers: diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 434a745b0474..c2ddd5e3e063 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1428,6 +1428,11 @@ async function agentCommandInternal( groupChannel: runContext.groupChannel ?? sessionEntry?.groupChannel, groupSubject: sessionEntry?.subject, parentSessionKey: sessionEntry?.parentSessionKey ?? sessionKey, + directUserIds: [ + sessionEntry?.origin?.nativeDirectUserId, + sessionEntry?.origin?.from, + sessionEntry?.origin?.to, + ], }) : null; const normalizedChannelOverride = channelModelOverride diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 93ead8a9ce97..e79f40d13568 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -557,6 +557,14 @@ function resolveChannelModelCandidate(params: { groupChannel: params.entry?.groupChannel ?? params.ctx.GroupChannel, groupSubject: params.entry?.subject ?? params.ctx.GroupSubject, parentSessionKey: params.parentSessionKey, + directUserIds: [ + params.entry?.origin?.nativeDirectUserId, + params.entry?.origin?.from, + params.entry?.origin?.to, + params.ctx.OriginatingTo, + params.ctx.From, + params.ctx.SenderId, + ], }); if (!channelModelOverride) { return undefined; diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 08063dc439c7..058a80e2db7e 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -587,6 +587,14 @@ export async function getReplyFromConfig( sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel, groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject, parentSessionKey: sessionCtx.ModelParentSessionKey ?? sessionCtx.ParentSessionKey, + directUserIds: [ + sessionEntry.origin?.nativeDirectUserId, + sessionEntry.origin?.from, + sessionEntry.origin?.to, + finalized.OriginatingTo, + finalized.From, + finalized.SenderId, + ], }) : null; const resolvedChannelModelOverride = diff --git a/src/channels/model-overrides.test.ts b/src/channels/model-overrides.test.ts index 00fb8e79f2e6..517b6e98545e 100644 --- a/src/channels/model-overrides.test.ts +++ b/src/channels/model-overrides.test.ts @@ -222,4 +222,248 @@ describe("resolveChannelModelOverride", () => { expect(resolved?.model).toBe("demo-provider/demo-parent-model"); expect(resolved?.matchKey).toBe("-100123"); }); + + it("matches direct-user-specific model override via directUserId", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + user123: "demo-provider/demo-direct-user-model", + "*": "demo-provider/demo-wildcard-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupChatType: "direct", + directUserIds: ["user123"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-direct-user-model"); + expect(resolved?.matchKey).toBe("user123"); + }); + + it("falls back to wildcard when no directUserId match exists", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + user999: "demo-provider/demo-other-user-model", + "*": "demo-provider/demo-wildcard-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupChatType: "direct", + directUserIds: ["user123"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-wildcard-model"); + expect(resolved?.matchKey).toBe("*"); + expect(resolved?.matchSource).toBe("wildcard"); + }); + + it("matches direct-user-specific model override via directUserId from origin.from", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + slack: { + "user:U12345": "demo-provider/demo-slack-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "slack", + groupChatType: "direct", + directUserIds: ["user:U12345"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-slack-dm-model"); + expect(resolved?.matchKey).toBe("user:U12345"); + }); + + it("ignores directUserId when a groupId is present (group takes precedence)", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + "-100123": "demo-provider/demo-group-model", + user456: "demo-provider/demo-direct-user-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupId: "-100123", + directUserIds: ["user456"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-group-model"); + expect(resolved?.matchKey).toBe("-100123"); + }); + + it("matches slack DM when origin.from is slack:U... but config has user:U... (multi-candidate)", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + slack: { + "user:U12345": "demo-provider/demo-slack-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "slack", + groupChatType: "direct", + directUserIds: ["slack:U12345", "user:U12345"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-slack-dm-model"); + expect(resolved?.matchKey).toBe("user:U12345"); + }); + + it("matches discord DM when multiple candidate forms are present", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + discord: { + "12345": "demo-provider/demo-discord-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "discord", + groupChatType: "direct", + directUserIds: ["discord:12345", "user:12345", "12345"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-discord-dm-model"); + expect(resolved?.matchKey).toBe("12345"); + }); + + it("matches telegram DM when raw SenderId is in candidates alongside prefixed forms", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + "67890": "demo-provider/demo-telegram-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupChatType: "direct", + directUserIds: ["telegram:67890", "user:67890", "67890"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-telegram-dm-model"); + expect(resolved?.matchKey).toBe("67890"); + }); + + it("prefers first matching candidate over later candidates", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + slack: { + "slack:U12345": "demo-provider/demo-prefixed-model", + "user:U12345": "demo-provider/demo-user-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "slack", + groupChatType: "direct", + directUserIds: ["slack:U12345", "user:U12345"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-prefixed-model"); + expect(resolved?.matchKey).toBe("slack:U12345"); + }); + + it("derives raw peer ID from channel-prefixed origin.from for telegram DM", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + "12345": "demo-provider/demo-telegram-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupChatType: "direct", + directUserIds: ["telegram:12345"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-telegram-dm-model"); + expect(resolved?.matchKey).toBe("12345"); + }); + + it("derives raw peer ID from channel-prefixed origin.from for discord DM", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + discord: { + "67890": "demo-provider/demo-discord-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "discord", + groupChatType: "direct", + directUserIds: ["discord:67890"], + }); + + expect(resolved?.model).toBe("demo-provider/demo-discord-dm-model"); + expect(resolved?.matchKey).toBe("67890"); + }); + + it("does not strip prefix for a different channel", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + "12345": "demo-provider/demo-telegram-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupChatType: "direct", + directUserIds: ["discord:12345"], + }); + + expect(resolved).toBeNull(); + }); + + it("does not leak directUserId match into non-direct conversations", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + user123: "demo-provider/demo-dm-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupChatType: "group", + groupId: "some-group", + directUserIds: ["user123"], + }); + + expect(resolved).toBeNull(); + }); }); diff --git a/src/channels/model-overrides.ts b/src/channels/model-overrides.ts index 7eca74aede00..b2b01bc73d2e 100644 --- a/src/channels/model-overrides.ts +++ b/src/channels/model-overrides.ts @@ -44,6 +44,7 @@ type ChannelModelOverrideParams = { groupChannel?: string | null; groupSubject?: string | null; parentSessionKey?: string | null; + directUserIds?: (string | null | undefined)[]; }; function resolveProviderEntry( @@ -130,14 +131,35 @@ function buildGenericParentOverrideCandidates(sessionKey: string | null | undefi return buildChannelKeyCandidates(threadId ? baseSessionKey : raw.rawId); } +/** Expand prefixed peer IDs by also trying the raw form after the channel prefix. */ +function expandPeerIds( + ids: (string | null | undefined)[], + channel: string, +): (string | null | undefined)[] { + const channelPrefix = channel.toLowerCase() + ":"; + const expanded: (string | null | undefined)[] = []; + for (const id of ids) { + if (id != null) { + expanded.push(id); + if (id.toLowerCase().startsWith(channelPrefix)) { + expanded.push(id.slice(channelPrefix.length)); + } + } + } + return expanded; +} + function resolveDirectChannelModelMatch(params: { channel: string; providerEntries: Record; groupId?: string | null; parentSessionKey?: string | null; + directUserIds?: (string | null | undefined)[]; }): { model: string; matchKey?: string; matchSource?: ChannelMatchSource } | null { + const expandedUserIds = expandPeerIds(params.directUserIds ?? [], params.channel); const directKeys = buildChannelKeyCandidates( params.groupId, + ...expandedUserIds, ...buildGenericParentOverrideCandidates(params.parentSessionKey), ); if (directKeys.length === 0) { @@ -179,12 +201,17 @@ export function resolveChannelModelOverride( if (!providerEntries) { return null; } - const directMatch = resolveDirectChannelModelMatch({ - channel, - providerEntries, - groupId: params.groupId, - parentSessionKey: params.parentSessionKey, - }); + const isDirectChat = normalizeChatType(params.groupChatType ?? undefined) === "direct"; + let directMatch = null; + if (isDirectChat) { + directMatch = resolveDirectChannelModelMatch({ + channel, + providerEntries, + groupId: params.groupId, + parentSessionKey: params.parentSessionKey, + directUserIds: params.directUserIds, + }); + } if (directMatch) { return { channel: normalizeMessageChannel(channel) ?? normalizeOptionalLowercaseString(channel) ?? "", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 663ef3ff17e3..cfb5e6de8216 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1964,7 +1964,7 @@ export const FIELD_HELP: Record = { "channels.mattermost.configWrites": "Allow Mattermost to write config in response to channel events/commands (default: true).", "channels.modelByChannel": - "Map provider -> channel id -> model override (values are provider/model or aliases).", + "Map provider -> channel id / DM peer id -> model override (values are provider/model or aliases).", "messages.suppressToolErrors": "When true, suppress ⚠️ tool-error warnings from being shown to the user. The agent already sees errors in context and can retry. Default: false.", "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 529e3b537774..cf9f44f5923e 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -33,7 +33,7 @@ export type ChannelDefaultsConfig = { botLoopProtection?: ChannelBotLoopProtectionConfig; }; -/** Provider/channel/target model override map used by channel dispatch. */ +/** Provider/channel/target model override map used by channel dispatch. Keys are channel-specific group IDs, thread IDs, channel names, or DM peer identifiers (see docs/gateway/config-channels.md). */ export type ChannelModelByChannelConfig = Record>; export type ExtensionNestedPolicyConfig = { @@ -127,7 +127,7 @@ export type ExtensionChannelConfig = { export interface ChannelsConfig { /** Shared defaults inherited by channel sections unless they override them. */ defaults?: ChannelDefaultsConfig; - /** Map provider -> channel id -> model override. */ + /** Map provider -> channel id / DM peer id -> model override. See docs/gateway/config-channels.md for supported key forms. */ modelByChannel?: ChannelModelByChannelConfig; discord?: DiscordConfig; googlechat?: GoogleChatConfig; diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 1652cb291d5a..9c1f78146687 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -516,6 +516,11 @@ function resolveChannelModelNote(params: { groupChannel: params.entry.groupChannel, groupSubject: params.entry.subject, parentSessionKey: params.parentSessionKey, + directUserIds: [ + params.entry.origin?.nativeDirectUserId, + params.entry.origin?.from, + params.entry.origin?.to, + ], }); if (!channelOverride) { return undefined;