# NousResearch/hermes-agent · Gateway per-channel model persistence and routing

## Open now

- 3 issues, 1 PR open on GitHub.
- #48413: Feature: persist /model selection per Discord channel
- #54601: feat: declarative per-channel model routing (channel_model_map)
- #57664: \[Feature\]: Per-topic Model Override for Gateway Sessions (extend topic_configs)
- PR #27504: fix(gateway): persist session model and reasoning overrides

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.

## TL;DR

- 16 member(s): 3 open issue(s), 7 closed issue(s), 1 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #56967. Verify it addresses your case.
- **Recommended:** read PR #56967 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-56967`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #1955: feat: per-channel model and system prompt overrides for gateway platforms
- #17834: Add per-channel model override (\`channel_models\`) — parallel to \`channel_prompts\`
- #21637: Per-channel personality and model routing (extending channel_prompts)
- #26570: \[Feature\]: Persist /model selections per chat/thread across gateway restarts
- #30703: feat: channel-based model routing in gateway config
- #30781: \[Bug\]: Gateway /model without --global does not persist, leaves remote users stuck with stale defaults
- #66107: \[Bug\]: Gateway session-only /model switch silently reverts to config default (persistence write failure swallowed at debug level)

## Prior & competing PRs

- **#26706: feat(gateway): persist per-session /model selections across restarts**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `30e947e0a05ef535e4b25a183d8bbe34fd68d1d5`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#56967: feat(gateway): per-channel model and system prompt overrides (#1991 salvage, fixes #1955)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/config.py`, `gateway/run.py`, `scripts/release.py`, `tests/gateway/test_channel_overrides.py`, `tests/gateway/test_config.py`
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -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."""
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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"),
    ```

    ```diff
    @@ -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", {}),
             )
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -5,6 +5,7 @@
     from unittest.mock import patch
     
     from gateway.config import (
    +    ChannelOverride,
         GatewayConfig,
         HomeChannel,
         Platform,
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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()
    ```
- **#1991: feat: per-channel model and system prompt overrides for gateway platforms (Fixes #1955)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#56967`, `#1955`
- **#21476: feat(gateway): per-channel model binding (Discord)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `0010c14e66cffbed84dfbf1b4a15093f0f8cc76d`, `#1955`, `#1991`
- **#27504: fix(gateway): persist session model and reasoning overrides**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#92187`, `#92187`
  - files: `gateway/run.py`, `gateway/session.py`, `gateway/slash_commands.py`, `tests/gateway/test_reasoning_persistence.py`
- **#66586: fix(gateway): persist session /model override and surface write failures**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#66546`, `#66524`, `#66523`

## Fix status

- Merged to main in PR #56967 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-02`.
- Subsystem: `gateway`
