Gateway per-channel model persistence and routing
NousResearch/hermes-agent · cluster 1955 · gateway
Data as of
4 open items. Fix merged in #56967. Check it covers your case.
The short answer
- Known?
- Yes. 16 related items grouped here, 7 issues already closed.
- Fix event
- A fix was merged to main in PR #56967. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #56967. That is separate from the fix event. - Still open
- 3 issues and 1 PR.
Next step: read #56967, then check whether your report is one of the 3 still open.
Read #56967Pick up a Lab taskThese issues and PRs cluster around two adjacent themes: persisting /model selections per channel or session across gateway restarts, and adding declarative per-channel model routing configuration. A subset addresses bugs where model switches silently fail to persist or cached agents revert to config defaults.
Open issues3
Open PRs1
- PR#27504fix(gateway): persist session model and reasoning overrides
Fixes on record
-
#56967 feat(gateway): per-channel model and system prompt overrides (#1991 salvage, fixes #1955)
files:
gateway/config.py,gateway/run.py,scripts/release.py,tests/gateway/test_channel_overrides.py,tests/gateway/test_config.pyShow the diff
-
gateway/config.py@@ -324,6 +324,40 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": ) +@dataclass +class ChannelOverride: + """ + Per-channel override for model, provider, and system prompt. + + Used in config under platforms.<name>.channel_overrides[channel_id]. + Enables different channels (e.g. Discord #daily vs #dev) to use different + models and personas without running separate gateway instances. + """ + model: Optional[str] = None + provider: Optional[str] = None + system_prompt: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + out: Dict[str, Any] = {} + if self.model is not None: + out["model"] = self.model + if self.provider is not None: + out["provider"] = self.provider + if self.system_prompt is not None: + out["system_prompt"] = self.system_prompt + return out + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChannelOverride": + if not data: + return cls() + return cls( + model=data.get("model"), + provider=data.get("provider"), + system_prompt=data.get("system_prompt"), + ) + + @dataclass class PlatformConfig: """Configuration for a single messaging platform.""" @@ -331,7 +365,7 @@ class PlatformConfig: token: [redacted:secret-assignment] = None # Bot token (Telegram, Discord) api_key: [redacted:secret-assignment] = None # API key if different from token home_channel: Optional[HomeChannel] = None - + # Reply threading mode (Telegram/Slack) # - "off": Never thread replies to original message # - "first": Only first chunk threads to user's message (default) @@ -354,6 +388,9 @@ class PlatformConfig: # gateway/platforms/base.py. typing_indicator: bool = True + # Per-channel model/provider/system_prompt overrides (channel_id -> ChannelOverride) + channel_overrides: Dict[str, ChannelOverride] = field(default_factory=dict) + # Platform-specific settings extra: Dict[str, Any] = field(default_factory=dict) @@ -371,6 +408,10 @@ def to_dict(self) -> Dict[str, Any]: result["api_key"] = self.api_key if self.home_channel: result["home_channel"] = self.home_channel.to_dict() + if self.channel_overrides: + result["channel_overrides"] = { + cid: ov.to_dict() for cid, ov in self.channel_overrides.items() + } return result @classmethod @@ -394,6 +435,13 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": if _typing is None: _typing = data.get("extra", {}).get("typing_indicator") + channel_overrides: Dict[str, ChannelOverride] = {} + raw_overrides = data.get("channel_overrides") or {} + if isinstance(raw_overrides, dict): + for cid, ov_data in raw_overrides.items(): + if isinstance(ov_data, dict): + channel_overrides[str(cid)] = ChannelOverride.from_dict(ov_data) + return cls( enabled=_coerce_bool(data.get("enabled"), False), token=[redacted:secret-assignment]"token"), @@ -402,6 +450,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": reply_to_mode=data.get("reply_to_mode", "first"), gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), + channel_overrides=channel_overrides, extra=data.get("extra", {}), ) @@ -1054,8 +1103,20 @@ def _merge_platform_map(source_platforms: Any) -> None: bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"] if "typing_indicator" in platform_cfg: bridged["typing_indicator"] = platform_cfg["typing_indicator"] + has_channel_overrides = "channel_overrides" in platform_cfg + if has_channel_overrides: + raw_overrides = platform_cfg.get("channel_overrides") + if isinstance(raw_overrides, dict): + plat_data, _extra = _ensure_platform_extra_dict( + platforms_data, plat.value + ) + plat_data["channel_overrides"] = { + str(cid): ov_data + for cid, ov_data in raw_overrides.items() + if isinstance(ov_data, dict) + } enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg - if not bridged and not enabled_was_explicit: + if not bridged and not enabled_was_explicit and not has_channel_overrides: continue plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value) if enabled_was_explicit: -
gateway/run.py@@ -1660,6 +1660,7 @@ def _profile_runtime_scope(profile_home: "Path"): os.environ["TERMINAL_CWD"] = _fallback from gateway.config import ( + ChannelOverride, Platform, _BUILTIN_PLATFORM_VALUES, GatewayConfig, @@ -1825,6 +1826,27 @@ def _resolve_runtime_agent_kwargs() -> dict: } +def _resolve_runtime_agent_kwargs_for_provider(provider: str) -> dict: + """Resolve runtime credentials for a specific provider (e.g. from channel override).""" + from hermes_cli.runtime_provider import ( + resolve_runtime_provider, + format_runtime_provider_error, + ) + try: + runtime = resolve_runtime_provider(requested=provider) + except Exception as exc: + raise RuntimeError(format_runtime_provider_error(exc)) from exc + return { + "api_key": runtime.get("api_key"), + "base_url": runtime.get("base_url"), + "provider": runtime.get("provider"), + "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), + "credential_pool": runtime.get("credential_pool"), + } + + def _try_resolve_fallback_provider() -> dict | None: """Attempt to resolve credentials from the fallback_model/fallback_providers config.""" from hermes_cli.runtime_provider import resolve_runtime_provider @@ -2284,6 +2306,59 @@ def _resolve_gateway_model(config: dict | None = None) -> str: return "" +def _channel_override_lookup_keys( + chat_id: str, + *, + thread_id: Optional[str] = None, + parent_id: Optional[str] = None, +) -> list[str]: + """Ordered, de-duplicated keys for ``channel_overrides`` lookup. + + Matches ``resolve_channel_prompt`` semantics: exact thread/channel id first, + then parent channel/forum id (Discord threads inherit parent overrides). + """ + keys: list[str] = [] + seen: set[str] = set() + for key in (chat_id, thread_id, parent_id): + if not key: + continue + sk = str(key) + if sk in seen: + continue + seen.add(sk) + keys.append(sk) + return keys + + +def _get_channel_override( + config: GatewayConfig, + platform: Platform, + chat_id: str, + *, + thread_id: Optional[str] = None, + parent_id: Optional[str] = None, +) -> Optional[ChannelOverride]: + """Return per-channel override for this platform/chat_id, or None. + + Looks up ``channel_overrides`` by ``chat_id``, then ``thread_id``, then + ``parent_id`` (forum threads / child channels inherit the parent entry). + """ + platforms = getattr(config, "platforms", None) + if not platforms: + return None + platform_config = platforms.get(platform) + if not platform_config or not platform_config.channel_overrides: + return None + overrides = platform_config.channel_overrides + for key in _channel_override_lookup_keys( + chat_id, thread_id=thread_id, parent_id=parent_id + ): + ov = overrides.get(key) + if ov is not None: + return ov + return None + + def _resolve_hermes_bin() -> Optional[list[str]]: """Resolve the Hermes update command as argv parts. @@ -3543,11 +3618,11 @@ def _resolve_session_agent_runtime( session_key: Optional[str] = None, user_config: Optional[dict] = None, ) -> tuple[str, dict]: - """Resolve model/runtime for a session, honoring session-scoped /model overrides. + """Resolve model/runtime for a session. - If the session override already contains a complete provider bundle - (provider/api_key/base_url/api_mode), prefer it directly instead of - resolving fresh global runtime state first. + Priority (highest first): session ``/model`` → ``channel_overrides`` → + global config/env (``_resolve_gateway_model(user_config)`` and default + provider resolution). """ resolved_session_key = session_key if not resolved_session_key and source is not None: @@ -3596,6 +3671,38 @@ def _resolve_session_agent_runtime( runtime_model, ) model = runtime_model + + cfg = getattr(self, "config", None) + if cfg and source is not None: + chat_id = str(source.chat_id) if source.chat_id else "" + thread_id = ( + str(source.thread_id) if getattr(source, "thread_id", None) else None + ) + parent_id = ( + str(source.parent_chat_id) + if getattr(source, "parent_chat_id", None) + else None + ) + ch = _get_channel_override( + cfg, + source.platform, + chat_id, + thread_id=thread_id, + parent_id=parent_id, + ) + if ch: + if ch.model: + model = ch.model + if ch.provider: + runtime_kwargs = _resolve_runtime_agent_kwargs_for_provider( + ch.provider + ) + ch_runtime_model = runtime_kwargs.pop("model", None) + # Only adopt the provider's bundled model when the override + # did not specify an explicit model. + if ch_runtime_model and not ch.model: + model = ch_runtime_model + if override and resolved_session_key: model, runtime_kwargs = self._apply_session_model_override( resolved_session_key, model, runtime_kwargs @@ -4473,6 +4580,57 @@ def _load_ephemeral_system_prompt() -> str: cfg = _load_gateway_runtime_config() return str(cfg_get(cfg, "agent", "system_prompt", default="") or "").strip() + def _resolve_model_for_channel( + self, + platform: Platform, + chat_id: str, + *, + user_config: Optional[dict] = None, + thread_id: Optional[str] = None, + parent_id: Optional[str] = None, + ) -> str: + """Resolve model for this channel: channel_overrides else global default.""" + config = getattr(self, "config", None) + if config: + override = _get_channel_override( + config, + platform, + chat_id, + thread_id=thread_id, + parent_id=parent_id, + ) + if override and override.model: + return override.model + return _resolve_gateway_model(user_config) + + def _get_system_prompt_for_channel( + self, + platform: Platform, + chat_id: str, + *, + thread_id: Optional[str] = None, + parent_id: Optional[str] = None, + ) -> str: + """Ephemeral system prompt for this channel/thread. + + Uses ``channel_overrides`` when set, else the global gateway prompt. + Legacy ``channel_prompts`` are applied separately via ``event.channel_prompt`` + in ``run_sync`` (adapter ``resolve_channel_prompt``), so they are not + duplicated here. + """ + config = getattr(self, "config", None) + if config: + override = _get_channel_override( + config, + platform, + chat_id, + thread_id=thread_id, + parent_id=parent_id, + ) + if override and override.system_prompt: + return (override.system_prompt or "").strip() + return getattr(self, "_ephemeral_system_prompt", None) or "" + @staticmethod def _load_reasoning_config() -> dict | None: """Load reasoning effort from config.yaml. @@ -16791,14 +16949,21 @@ def run_sync(): # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value - # Combine platform context, per-channel context, and the user-configured - # ephemeral system prompt. + # Combine platform context, YAML channel_prompts hint for this chat, + # channel_overrides system_prompt (or global ephemeral), and gateway + # ephemeral prompt from _get_system_prompt_for_channel. combined_ephemeral = context_prompt or "" event_channel_prompt = (channel_prompt or "").strip() if event_channel_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + event_channel_prompt).strip() - if self._ephemeral_system_prompt: - combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() + cfg_channel_prompt = self._get_system_prompt_for_channel( + source.platform, + source.chat_id or "", + thread_id=getattr(source, "thread_id", None), + parent_id=getattr(source, "parent_chat_id", None), + ) + if cfg_channel_prompt: + combined_ephemeral = (combined_ephemeral + "\n\n" + cfg_channel_prompt).strip() max_iterations = _current_max_iterations() -
scripts/release.py@@ -917,6 +917,7 @@ "[redacted:email]": "ifrederico", "[redacted:email]": "cdanis", "[redacted:email]": "samherring99", + "[redacted:email]": "crazywriter1", "[redacted:email]": "Aum08Desai", "[redacted:email]": "shannonsands", "[redacted:email]": "shannonsands", -
tests/gateway/test_channel_overrides.py@@ -0,0 +1,302 @@ +"""Tests for per-channel model and system prompt overrides (Fixes #1955).""" + +from unittest.mock import patch + +import pytest + +from gateway.config import ( + ChannelOverride, + GatewayConfig, + Platform, + PlatformConfig, +) +from gateway.run import _get_channel_override, GatewayRunner +from gateway.session import SessionSource + + +class TestGetChannelOverride: + def test_no_override_when_empty_config(self): + config = GatewayConfig() + assert _get_channel_override(config, Platform.DISCORD, "123") is None + + def test_no_override_when_platform_not_configured(self): + config = GatewayConfig(platforms={}) + assert _get_channel_override(config, Platform.DISCORD, "123") is None + + def test_no_override_when_channel_not_in_overrides(self): + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "999": ChannelOverride(model="openrouter/healer-alpha"), + }, + ), + }, + ) + assert _get_channel_override(config, Platform.DISCORD, "123") is None + + def test_returns_override_when_channel_matches(self): + ov = ChannelOverride( + model="openrouter/healer-alpha", + provider="openrouter", + system_prompt="You are a summarizer.", + ) + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={"1234567890": ov}, + ), + }, + ) + result = _get_channel_override(config, Platform.DISCORD, "1234567890") + assert result is not None + assert result.model == "openrouter/healer-alpha" + assert result.provider == "openrouter" + assert result.system_prompt == "You are a summarizer." + + def test_returns_override_when_chat_id_is_int_like(self): + """Caller may pass str(chat_id); override keys are normalized to str.""" + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={"123": ChannelOverride(model="gpt-4")}, + ), + }, + ) + assert _get_channel_override(config, Platform.DISCORD, "123").model == "gpt-4" + + def test_thread_id_lookup_when_chat_id_misses(self): + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "thread_99": ChannelOverride(model="topic-model"), + }, + ), + }, + ) + result = _get_channel_override( + config, Platform.DISCORD, "parent_chan", thread_id="thread_99" + ) + assert result is not None + assert result.model == "topic-model" + + def test_parent_id_fallback_when_thread_has_no_entry(self): + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "parent_chan": ChannelOverride(model="parent-model"), + }, + ), + }, + ) + result = _get_channel_override( + config, + Platform.DISCORD, + "thread_only", + parent_id="parent_chan", + ) + assert result is not None + assert result.model == "parent-model" + + def test_exact_thread_overrides_parent(self): + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "thread_1": ChannelOverride(model="thread-model"), + "parent_chan": ChannelOverride(model="parent-model"), + }, + ), + }, + ) + result = _get_channel_override( + config, Platform.DISCORD, "thread_1", parent_id="parent_chan" + ) + assert result.model == "thread-model" + + +class TestResolveModelForChannel: + def test_uses_channel_override_when_present(self): + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "chan_1": ChannelOverride(model="anthropic/claude-opus-4.6"), + }, + ), + }, + ) + runner = object.__new__(GatewayRunner) + runner.config = config + model = runner._resolve_model_for_channel(Platform.DISCORD, "chan_1") + assert model == "anthropic/claude-opus-4.6" + + def test_falls_back_to_global_when_no_override(self, monkeypatch): + monkeypatch.setattr( + "gateway.run._resolve_gateway_model", + lambda _cfg=None: "global-model/default", + ) + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig(enabled=True, channel_overrides={}), + }, + ) + runner = object.__new__(GatewayRunner) + runner.config = config + model = runner._resolve_model_for_channel(Platform.DISCORD, "unknown_channel") + assert model == "global-model/default" + + +class TestGetSystemPromptForChannel: + def test_uses_channel_override_when_present(self): + config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "chan_1": ChannelOverride(system_prompt="You are a coding assistant."), + }, + ), + }, + ) + runner = object.__new__(GatewayRunner) + runner.config = config + runner._ephemeral_system_prompt = "Global prompt" + prompt = runner._get_system_prompt_for_channel(Platform.DISCORD, "chan_1") + assert prompt == "You are a coding assistant." + + def test_falls_back_to_global_when_no_override(self): + config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True)}, + ) + runner = object.__new__(GatewayRunner) + runner.config = config + runner._ephemeral_system_prompt = "Global prompt" + prompt = runner._get_system_prompt_for_channel(Platform.DISCORD, "other") + assert prompt == "Global prompt" + + +class TestResolveSessionAgentRuntimePriority: + """Model/runtime priority: session /model → channel_overrides → global.""" + + def test_channel_override_beats_global(self): + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "chan_1": ChannelOverride( + model="channel/model", + provider="openrouter", + ), + }, + ), + }, + ) + source = SessionSource( + platform=Platform.DISCORD, + chat_id="chan_1", + user_id="u1", + ) + with patch("gateway.run._resolve_gateway_model", return_value="global/model"), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "anthropic", + "api_key": "k", + "base_url": "https://api.anthropic.com", + "api_mode": "chat_completions", + }), \ + patch( + "gateway.run._resolve_runtime_agent_kwargs_for_provider", + return_value={ + "provider": "openrouter", + "api_key": "k2", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + }, + ): + model, runtime = runner._resolve_session_agent_runtime( + source=source, + user_config={"model": {"default": "global/model"}}, + ) + assert model == "channel/model" + assert runtime["provider"] == "openrouter" + + def test_session_model_beats_channel_override(self): + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "chan_1": ChannelOverride(model="channel/model"), + }, + ), + }, + ) + session_key = "agent:main:discord:channel:chan_1" + runner._session_model_overrides = { + session_key: { + "model": "session/model", + "provider": "anthropic", + }, + } + source = SessionSource( + platform=Platform.DISCORD, + chat_id="chan_1", + chat_type="channel", + user_id="u1", + ) + with patch("gateway.run._resolve_gateway_model", return_value="global/model"), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "openrouter", + "api_key": "k", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + }): + model, runtime = runner._resolve_session_agent_runtime( + source=source, + session_key=session_key, + ) + assert model == "session/model" + assert runtime["provider"] == "anthropic" + + def test_parent_channel_model_inherited_in_thread(self): + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + channel_overrides={ + "parent_chan": ChannelOverride(model="parent/model"), + }, + ), + }, + ) + source = SessionSource( + platform=Platform.DISCORD, + chat_id="thread_1", + chat_type="thread", + parent_chat_id="parent_chan", + user_id="u1", + ) + with patch("gateway.run._resolve_gateway_model", return_value="global/model"), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "anthropic", + "api_key": "k", + "base_url": "https://api.anthropic.com", + "api_mode": "chat_completions", + }): + model, _runtime = runner._resolve_session_agent_runtime(source=source) + assert model == "parent/model" -
tests/gateway/test_config.py@@ -5,6 +5,7 @@ from unittest.mock import patch from gateway.config import ( + ChannelOverride, GatewayConfig, HomeChannel, Platform, @@ -89,6 +90,54 @@ def test_typing_indicator_resolved_from_extra(self): # extra; from_dict must honor it there too (mirrors _grn fallback). restored = PlatformConfig.from_dict({"extra": {"typing_indicator": False}}) assert restored.typing_indicator is False + def test_channel_overrides_roundtrip(self): + pc = PlatformConfig( + enabled=True, + channel_overrides={ + "1234567890": ChannelOverride( + model="openrouter/healer-alpha", + provider="openrouter", + system_prompt="You are a daily news summarizer.", + ), + "9876543210": ChannelOverride( + model="anthropic/claude-opus-4.6", + provider="anthropic", + system_prompt="You are a coding assistant.", + ), + }, + ) + d = pc.to_dict() + assert "channel_overrides" in d + assert d["channel_overrides"]["1234567890"]["model"] == "openrouter/healer-alpha" + assert d["channel_overrides"]["9876543210"]["system_prompt"] == "You are a coding assistant." + restored = PlatformConfig.from_dict(d) + assert restored.channel_overrides["1234567890"].model == "openrouter/healer-alpha" + assert restored.channel_overrides["9876543210"].provider == "anthropic" + + def test_channel_overrides_from_dict_normalizes_channel_id_to_str(self): + """YAML may have numeric channel IDs; we store as str.""" + data = { + "enabled": True, + "channel_overrides": { + 1234567890: {"model": "openrouter/healer-alpha"}, + }, + } + pc = PlatformConfig.from_dict(data) + assert "1234567890" in pc.channel_overrides + assert pc.channel_overrides["1234567890"].model == "openrouter/healer-alpha" + + +class TestChannelOverride: + def test_from_dict_empty(self): + assert ChannelOverride.from_dict({}).model is None + assert ChannelOverride.from_dict(None).model is None + + def test_to_dict_omits_none(self): + ov = ChannelOverride(model="gpt-4", provider=None, system_prompt="Hi") + d = ov.to_dict() + assert d["model"] == "gpt-4" + assert "provider" not in d + assert d["system_prompt"] == "Hi" class TestGetConnectedPlatforms: @@ -782,6 +831,31 @@ def test_bridges_quoted_false_always_log_local_from_config_yaml(self, tmp_path, assert config.always_log_local is False + def test_bridges_discord_channel_overrides_from_top_level_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " channel_overrides:\n" + ' "1234567890":\n' + " model: openrouter/healer-alpha\n" + " provider: openrouter\n" + " system_prompt: Daily news summarizer\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + discord = config.platforms[Platform.DISCORD] + assert "1234567890" in discord.channel_overrides + ov = discord.channel_overrides["1234567890"] + assert ov.model == "openrouter/healer-alpha" + assert ov.provider == "openrouter" + assert ov.system_prompt == "Daily news summarizer" + def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir()
-
4 PRs closed without merging
From the close comments: merged via another change 1 · stale or abandoned 1 · other 2
-
#1991 feat: per-channel model and system prompt overrides for gateway platforms (Fixes #1955) · merged via another change →
#56967,#1955 -
#21476 feat(gateway): per-channel model binding (Discord) · other →
0010c14,#1955,#1991 -
#26706 feat(gateway): persist per-session /model selections across restarts · other →
30e947e -
#66586 fix(gateway): persist session /model override and surface write failures · stale or abandoned →
#66546,#66524,#66523
Closed issues · 7
- Closed#1955feat: per-channel model and system prompt overrides for gateway platforms
- Closed#17834Add per-channel model override (`channel_models`) — parallel to `channel_prompts`
- Closed#21637Per-channel personality and model routing (extending channel_prompts)
- Closed#26570[Feature]: Persist /model selections per chat/thread across gateway restarts
- Closed#30703feat: channel-based model routing in gateway config
- Closed#30781[Bug]: Gateway /model without --global does not persist, leaves remote users stuck with stale defaults
- Closed#66107[Bug]: Gateway session-only /model switch silently reverts to config default (persistence write failure swallowed at debug level)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.08: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #56967
- Closed issues
- 7
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 2 Jul 2026