Channels: add thread-aware model overrides

This commit is contained in:
Shadow
2026-02-20 19:26:25 -06:00
committed by GitHub
parent ee8dd40509
commit f555835b09
53 changed files with 1379 additions and 1398 deletions

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveChannelModelOverride } from "./model-overrides.js";
describe("resolveChannelModelOverride", () => {
it("matches parent group id when topic suffix is present", () => {
const cfg = {
channels: {
modelByChannel: {
telegram: {
"-100123": "openai/gpt-4.1",
},
},
},
} as unknown as OpenClawConfig;
const resolved = resolveChannelModelOverride({
cfg,
channel: "telegram",
groupId: "-100123:topic:99",
});
expect(resolved?.model).toBe("openai/gpt-4.1");
expect(resolved?.matchKey).toBe("-100123");
});
it("prefers topic-specific match over parent group id", () => {
const cfg = {
channels: {
modelByChannel: {
telegram: {
"-100123": "openai/gpt-4.1",
"-100123:topic:99": "anthropic/claude-sonnet-4-6",
},
},
},
} as unknown as OpenClawConfig;
const resolved = resolveChannelModelOverride({
cfg,
channel: "telegram",
groupId: "-100123:topic:99",
});
expect(resolved?.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved?.matchKey).toBe("-100123:topic:99");
});
it("falls back to parent session key when thread id does not match", () => {
const cfg = {
channels: {
modelByChannel: {
discord: {
"123": "openai/gpt-4.1",
},
},
},
} as unknown as OpenClawConfig;
const resolved = resolveChannelModelOverride({
cfg,
channel: "discord",
groupId: "999",
parentSessionKey: "agent:main:discord:channel:123:thread:456",
});
expect(resolved?.model).toBe("openai/gpt-4.1");
expect(resolved?.matchKey).toBe("123");
});
});

View File

@@ -0,0 +1,142 @@
import type { OpenClawConfig } from "../config/config.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatchWithFallback,
type ChannelMatchSource,
} from "./channel-config.js";
const THREAD_SUFFIX_REGEX = /:(?:thread|topic):[^:]+$/i;
export type ChannelModelOverride = {
channel: string;
model: string;
matchKey?: string;
matchSource?: ChannelMatchSource;
};
type ChannelModelByChannelConfig = Record<string, Record<string, string>>;
type ChannelModelOverrideParams = {
cfg: OpenClawConfig;
channel?: string | null;
groupId?: string | null;
groupChannel?: string | null;
groupSubject?: string | null;
parentSessionKey?: string | null;
};
function resolveProviderEntry(
modelByChannel: ChannelModelByChannelConfig | undefined,
channel: string,
): Record<string, string> | undefined {
const normalized = normalizeMessageChannel(channel) ?? channel.trim().toLowerCase();
return (
modelByChannel?.[normalized] ??
modelByChannel?.[
Object.keys(modelByChannel ?? {}).find((key) => {
const normalizedKey = normalizeMessageChannel(key) ?? key.trim().toLowerCase();
return normalizedKey === normalized;
}) ?? ""
]
);
}
function resolveParentGroupId(groupId: string | undefined): string | undefined {
const raw = groupId?.trim();
if (!raw || !THREAD_SUFFIX_REGEX.test(raw)) {
return undefined;
}
const parent = raw.replace(THREAD_SUFFIX_REGEX, "").trim();
return parent && parent !== raw ? parent : undefined;
}
function resolveGroupIdFromSessionKey(sessionKey?: string | null): string | undefined {
const raw = sessionKey?.trim();
if (!raw) {
return undefined;
}
const parsed = parseAgentSessionKey(raw);
const candidate = parsed?.rest ?? raw;
const match = candidate.match(/(?:^|:)(?:group|channel):([^:]+)(?::|$)/i);
const id = match?.[1]?.trim();
return id || undefined;
}
function buildChannelCandidates(
params: Pick<
ChannelModelOverrideParams,
"groupId" | "groupChannel" | "groupSubject" | "parentSessionKey"
>,
) {
const groupId = params.groupId?.trim();
const parentGroupId = resolveParentGroupId(groupId);
const parentGroupIdFromSession = resolveGroupIdFromSessionKey(params.parentSessionKey);
const parentGroupIdResolved =
resolveParentGroupId(parentGroupIdFromSession) ?? parentGroupIdFromSession;
const groupChannel = params.groupChannel?.trim();
const groupSubject = params.groupSubject?.trim();
const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined;
const subjectBare = groupSubject ? groupSubject.replace(/^#/, "") : undefined;
const channelSlug = channelBare ? normalizeChannelSlug(channelBare) : undefined;
const subjectSlug = subjectBare ? normalizeChannelSlug(subjectBare) : undefined;
return buildChannelKeyCandidates(
groupId,
parentGroupId,
parentGroupIdResolved,
groupChannel,
channelBare,
channelSlug,
groupSubject,
subjectBare,
subjectSlug,
);
}
export function resolveChannelModelOverride(
params: ChannelModelOverrideParams,
): ChannelModelOverride | null {
const channel = params.channel?.trim();
if (!channel) {
return null;
}
const modelByChannel = params.cfg.channels?.modelByChannel as
| ChannelModelByChannelConfig
| undefined;
if (!modelByChannel) {
return null;
}
const providerEntries = resolveProviderEntry(modelByChannel, channel);
if (!providerEntries) {
return null;
}
const candidates = buildChannelCandidates(params);
if (candidates.length === 0) {
return null;
}
const match = resolveChannelEntryMatchWithFallback({
entries: providerEntries,
keys: candidates,
wildcardKey: "*",
normalizeKey: (value) => value.trim().toLowerCase(),
});
const raw = match.entry ?? match.wildcardEntry;
if (typeof raw !== "string") {
return null;
}
const model = raw.trim();
if (!model) {
return null;
}
return {
channel: normalizeMessageChannel(channel) ?? channel.trim().toLowerCase(),
model,
matchKey: match.matchKey,
matchSource: match.matchSource,
};
}

View File

@@ -50,14 +50,14 @@ export type StatusReactionController = {
export const DEFAULT_EMOJIS: Required<StatusReactionEmojis> = {
queued: "👀",
thinking: "🧠",
tool: "🛠️",
coding: "💻",
web: "🌐",
done: "",
error: "",
stallSoft: "",
stallHard: "⚠️",
thinking: "🤔",
tool: "🔥",
coding: "👨‍💻",
web: "",
done: "👍",
error: "😱",
stallSoft: "🥱",
stallHard: "😨",
};
export const DEFAULT_TIMING: Required<StatusReactionTiming> = {