feat(channels): add directUserId support for per-DM model override (#95120)

Add optional directUserId field to ChannelModelOverrideParams so the
shared channels.modelByChannel resolver can match DM-specific config
entries. Callers pass sessionEntry.origin?.nativeDirectUserId.

Closes #53638

Co-authored-by: Thomas Zhengtao <thomas.zhengtao@gmail.com>
This commit is contained in:
thomas.szbay
2026-06-23 01:26:01 +08:00
committed by GitHub
parent feb3694243
commit 9bf681d663
9 changed files with 322 additions and 10 deletions
+16 -1
View File
@@ -43,7 +43,18 @@ If a provider block is missing entirely (`channels.<provider>` 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:
+5
View File
@@ -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
@@ -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;
+8
View File
@@ -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 =
+244
View File
@@ -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();
});
});
+33 -6
View File
@@ -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<string, string>;
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) ?? "",
+1 -1
View File
@@ -1964,7 +1964,7 @@ export const FIELD_HELP: Record<string, string> = {
"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).",
+2 -2
View File
@@ -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<string, Record<string, string>>;
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;
+5
View File
@@ -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;