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

3open issues
1open PR
16items grouped
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.

These 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

  • Issue#48413Feature: persist /model selection per Discord channel
  • Issue#54601feat: declarative per-channel model routing (channel_model_map)
  • Issue#57664[Feature]: Per-topic Model Override for Gateway Sessions (extend topic_configs)

Open PRs1

  • PR#27504fix(gateway): persist session model and reasoning overrides

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56967 feat(gateway): per-channel model and system prompt overrides (#1991 salvage, fixes #1955)

    merged to main

    files: gateway/config.py, gateway/run.py, scripts/release.py, tests/gateway/test_channel_overrides.py, tests/gateway/test_config.py

    Show 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.00 means 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 Lab

At a glance

Subsystem
gateway
Merged PRs
#56967
Closed issues
7
Attempts
4 PRs closed without merging
Fix link checked
2 Jul 2026

For your agent

The same facts, typed.