mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ?? "",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user