From 86f437509b9b38f2ca0f8c740e32c4e789d56609 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:58:21 +0700 Subject: [PATCH] fix(gateway): align api_server and sms checkers with profile scope and startup guard --- gateway/config.py | 385 ++++-- tests/gateway/test_api_server.py | 1226 ++++++++++++----- .../test_platform_connected_checkers.py | 62 +- 3 files changed, 1227 insertions(+), 446 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 1b654b74e4c2..d2e8ea3986ca 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -277,6 +277,7 @@ class Platform(Enum): ``Platform("irc")`` works without modifying this enum. Dynamic members are cached in ``_value2member_map_`` for identity-stable comparisons. """ + LOCAL = "local" TELEGRAM = "telegram" DISCORD = "discord" @@ -301,6 +302,7 @@ class Platform(Enum): QQBOT = "qqbot" YUANBAO = "yuanbao" RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL) + @classmethod def _missing_(cls, value): """Accept unknown platform names only for known plugin adapters. @@ -334,6 +336,7 @@ class Platform(Enum): # the enum was defined). try: from gateway.platform_registry import platform_registry + if platform_registry.is_registered(value): pseudo = object.__new__(cls) pseudo._value_ = value @@ -421,17 +424,18 @@ def platform_binds_port(platform_value: str, extra: Optional[dict] = None) -> bo class HomeChannel: """ Default destination for a platform. - + When a cron job specifies deliver="telegram" without a specific chat ID, messages are sent to this home channel. Thread-aware platforms may also store a thread/topic ID so the bare platform target routes to the exact conversation where /sethome was run. """ + platform: Platform chat_id: str name: str # Human-readable name for display thread_id: Optional[str] = None - + def to_dict(self) -> Dict[str, Any]: result = { "platform": self.platform.value, @@ -441,7 +445,7 @@ class HomeChannel: if self.thread_id: result["thread_id"] = self.thread_id return result - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel": return cls( @@ -456,7 +460,7 @@ class HomeChannel: class SessionResetPolicy: """ Controls when sessions reset (lose context). - + Modes: - "daily": Reset at a specific hour each day - "idle": Reset after N minutes of inactivity @@ -468,11 +472,15 @@ class SessionResetPolicy: overrides). Changed July 2026 from "both" (24h idle + daily 4am), which surprised users who expected their conversations to persist. """ + mode: str = "none" # "daily", "idle", "both", or "none" at_hour: int = 4 # Hour for daily reset (0-23, local time) idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours) notify: bool = True # Send a notification to the user when auto-reset occurs - notify_exclude_platforms: tuple = ("api_server", "webhook") # Platforms that don't get reset notifications + notify_exclude_platforms: tuple = ( + "api_server", + "webhook", + ) # Platforms that don't get reset notifications # A background process this many hours old (or older) no longer blocks # session idle/daily reset. A forgotten preview server should not keep a # session alive forever (#29177). The process is NOT killed — only ignored @@ -489,7 +497,7 @@ class SessionResetPolicy: "notify_exclude_platforms": list(self.notify_exclude_platforms), "bg_process_max_age_hours": self.bg_process_max_age_hours, } - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": data = _coerce_dict(data) @@ -505,7 +513,9 @@ class SessionResetPolicy: at_hour=at_hour if at_hour is not None else 4, idle_minutes=idle_minutes if idle_minutes is not None else 1440, notify=_coerce_bool(notify, True), - notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"), + notify_exclude_platforms=tuple(exclude) + if exclude is not None + else ("api_server", "webhook"), bg_process_max_age_hours=bg_max_age if bg_max_age is not None else 24, ) @@ -519,6 +529,7 @@ class ChannelOverride: 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 @@ -563,6 +574,7 @@ PLATFORM_TOKEN_ENV_NAMES: dict["Platform", str] = { @dataclass class PlatformConfig: """Configuration for a single messaging platform.""" + enabled: bool = False token: Optional[str] = None # Bot token (Telegram, Discord) api_key: Optional[str] = None # API key if different from token @@ -690,6 +702,7 @@ DEFAULT_STREAMING_CURSOR: str = " ▉" @dataclass class StreamingConfig: """Configuration for real-time token streaming to messaging platforms.""" + enabled: bool = False # Transport selection: # "auto" — prefer native streaming-draft updates when the platform @@ -770,10 +783,12 @@ class StreamingConfig: enabled=enabled, transport=transport, edit_interval=_coerce_float( - data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL, + data.get("edit_interval"), + DEFAULT_STREAMING_EDIT_INTERVAL, ), buffer_threshold=_coerce_int( - data.get("buffer_threshold"), DEFAULT_STREAMING_BUFFER_THRESHOLD, + data.get("buffer_threshold"), + DEFAULT_STREAMING_BUFFER_THRESHOLD, ), cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR), fresh_final_after_seconds=_coerce_float( @@ -789,6 +804,20 @@ class StreamingConfig: # platform is sufficiently configured to be considered "connected". Platforms # that rely on the generic ``token or api_key`` check (Telegram, Discord, # Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here. +def _check_api_server(cfg: PlatformConfig) -> bool: + if not cfg: + return False + key = cfg.extra.get("key") + if not key: + return False + try: + from hermes_cli.auth import has_usable_secret + + return has_usable_secret(key, min_length=16) + except ImportError: + return len(str(key).strip()) >= 16 + + _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = { Platform.WEIXIN: lambda cfg: bool( cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token")) @@ -798,8 +827,8 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = ), Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")), Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")), - Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")), - Platform.API_SERVER: lambda cfg: bool(cfg.extra.get("key")) if cfg else False, + Platform.SMS: lambda cfg: bool(_getenv("TWILIO_ACCOUNT_SID")), + Platform.API_SERVER: _check_api_server, Platform.WEBHOOK: lambda cfg: True, Platform.MSGRAPH_WEBHOOK: lambda cfg: bool( str(cfg.extra.get("client_state") or "").strip() @@ -827,23 +856,24 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = class GatewayConfig: """ Main gateway configuration. - + Manages all platform connections, session policies, and delivery settings. """ + # Platform configurations platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict) - + # Session reset policies by type default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy) reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict) reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict) - + # Reset trigger commands reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"]) # User-defined quick commands (slash commands that bypass the agent loop) quick_commands: Dict[str, Any] = field(default_factory=dict) - + # Storage paths sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions") @@ -853,7 +883,7 @@ class GatewayConfig: # tooling and downgrade safety; set gateway.write_sessions_json: false in # config.yaml to stop producing the file. write_sessions_json: bool = True - + # Delivery settings always_log_local: bool = True # Always save cron outputs to local files # Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare @@ -866,12 +896,18 @@ class GatewayConfig: # STT settings stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages - stt_echo_transcripts: bool = True # Whether to echo raw STT transcripts back to the user + stt_echo_transcripts: bool = ( + True # Whether to echo raw STT transcripts back to the user + ) # Session isolation in shared chats group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available - thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants - max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active chat sessions + thread_sessions_per_user: bool = ( + False # When False (default), threads are shared across all participants + ) + max_concurrent_sessions: Optional[int] = ( + None # Positive int caps simultaneous active chat sessions + ) # Multi-profile multiplexing (opt-in; default off preserves one-gateway-per-profile). # When True, the default profile's gateway serves inbound messages for every @@ -924,7 +960,9 @@ class GatewayConfig: connected.append(platform) return sorted(connected, key=lambda p: str(p.value)) - def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool: + def _is_platform_connected( + self, platform: Platform, config: PlatformConfig + ) -> bool: """Check whether a single platform is sufficiently configured.""" # Weixin requires both a token and an account_id (checked first so # the generic token branch doesn't let it through without account_id). @@ -949,8 +987,10 @@ class GatewayConfig: # discovery in the normal path). discover_plugins() is idempotent. try: from gateway.platform_registry import platform_registry + try: from hermes_cli.plugins import discover_plugins + discover_plugins() except Exception: pass @@ -965,43 +1005,37 @@ class GatewayConfig: pass # Registry not yet initialised during early import return False - + def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: """Get the home channel for a platform.""" config = self.platforms.get(platform) if config: return config.home_channel return None - + def get_reset_policy( - self, - platform: Optional[Platform] = None, - session_type: Optional[str] = None + self, platform: Optional[Platform] = None, session_type: Optional[str] = None ) -> SessionResetPolicy: """ Get the appropriate reset policy for a session. - + Priority: platform override > type override > default """ # Platform-specific override takes precedence if platform and platform in self.reset_by_platform: return self.reset_by_platform[platform] - + # Type-specific override (dm, group, thread) if session_type and session_type in self.reset_by_type: return self.reset_by_type[session_type] - + return self.default_reset_policy - + def to_dict(self) -> Dict[str, Any]: return { - "platforms": { - p.value: c.to_dict() for p, c in self.platforms.items() - }, + "platforms": {p.value: c.to_dict() for p, c in self.platforms.items()}, "default_reset_policy": self.default_reset_policy.to_dict(), - "reset_by_type": { - k: v.to_dict() for k, v in self.reset_by_type.items() - }, + "reset_by_type": {k: v.to_dict() for k, v in self.reset_by_type.items()}, "reset_by_platform": { p.value: v.to_dict() for p, v in self.reset_by_platform.items() }, @@ -1026,7 +1060,7 @@ class GatewayConfig: for r in self.profile_routes ], } - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": data = _coerce_dict(data) @@ -1040,34 +1074,42 @@ class GatewayConfig: platforms[platform] = PlatformConfig.from_dict(platform_data) except ValueError: pass # Skip unknown platforms - + reset_by_type = {} - for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): + for type_name, policy_data in _coerce_dict( + data.get("reset_by_type", {}) + ).items(): reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) - + reset_by_platform = {} - for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): + for platform_name, policy_data in _coerce_dict( + data.get("reset_by_platform", {}) + ).items(): try: platform = Platform(platform_name) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) except ValueError: pass - + default_policy = SessionResetPolicy() if "default_reset_policy" in data: default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"]) - + sessions_dir = get_hermes_home() / "sessions" if "sessions_dir" in data: sessions_dir = Path(data["sessions_dir"]) - + quick_commands = data.get("quick_commands", {}) if not isinstance(quick_commands, dict): quick_commands = {} stt_enabled = data.get("stt_enabled") if stt_enabled is None: - stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None + stt_enabled = ( + data.get("stt", {}).get("enabled") + if isinstance(data.get("stt"), dict) + else None + ) stt_echo_transcripts = data.get("stt_echo_transcripts") if stt_echo_transcripts is None: stt_echo_transcripts = ( @@ -1079,7 +1121,9 @@ class GatewayConfig: group_sessions_per_user = data.get("group_sessions_per_user") thread_sessions_per_user = data.get("thread_sessions_per_user") multiplex_profiles = data.get("multiplex_profiles") - nested_gateway = data.get("gateway") if isinstance(data.get("gateway"), dict) else {} + nested_gateway = ( + data.get("gateway") if isinstance(data.get("gateway"), dict) else {} + ) if "systemd_watchdog_seconds" in data: systemd_watchdog_raw = data.get("systemd_watchdog_seconds") systemd_watchdog_key = "systemd_watchdog_seconds" @@ -1128,6 +1172,7 @@ class GatewayConfig: # Parse profile routes (validated by gateway.profile_routing) from gateway.profile_routing import parse_profile_routes + profile_routes = parse_profile_routes(data.get("profile_routes") or []) return cls( @@ -1216,6 +1261,7 @@ def load_gateway_config() -> GatewayConfig: # Primary source: config.yaml try: import yaml + config_yaml_path = _home / "config.yaml" if config_yaml_path.exists(): with open(config_yaml_path, encoding="utf-8") as f: @@ -1227,6 +1273,7 @@ def load_gateway_config() -> GatewayConfig: # session_reset / quick_commands / stt / model would be ignored by # the messaging gateway. Fail-open via the shared helper. from hermes_cli import managed_scope + yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg) # Shared nested-fallback source: settings meant to be top-level @@ -1300,11 +1347,18 @@ def load_gateway_config() -> GatewayConfig: gw_data["profile_routes"] = _pr if isinstance(gateway_section, dict): - if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data: + if ( + "multiplex_profiles" in gateway_section + and "multiplex_profiles" not in gw_data + ): # gateway.multiplex_profiles written by `hermes config set gateway.multiplex_profiles true` - gw_data["multiplex_profiles"] = gateway_section["multiplex_profiles"] + gw_data["multiplex_profiles"] = gateway_section[ + "multiplex_profiles" + ] if "max_concurrent_sessions" in gateway_section: - gw_data["max_concurrent_sessions"] = gateway_section["max_concurrent_sessions"] + gw_data["max_concurrent_sessions"] = gateway_section[ + "max_concurrent_sessions" + ] if "systemd_watchdog_seconds" in gateway_section: gw_data["systemd_watchdog_seconds"] = gateway_section[ "systemd_watchdog_seconds" @@ -1348,9 +1402,11 @@ def load_gateway_config() -> GatewayConfig: ] if "unauthorized_dm_behavior" in yaml_cfg: - gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( - yaml_cfg.get("unauthorized_dm_behavior"), - "pair", + gw_data["unauthorized_dm_behavior"] = ( + _normalize_unauthorized_dm_behavior( + yaml_cfg.get("unauthorized_dm_behavior"), + "pair", + ) ) elif isinstance(gateway_section, dict) and "unauthorized_dm_behavior" in gateway_section: gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( @@ -1362,7 +1418,9 @@ def load_gateway_config() -> GatewayConfig: # ``gateway.platforms`` are loaded the same way as top-level # ``platforms``. Merge nested first so top-level config keeps # precedence, matching the existing gateway.streaming fallback. - gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None + gateway_platforms = ( + gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None + ) platforms_data = gw_data.setdefault("platforms", {}) if not isinstance(platforms_data, dict): platforms_data = {} @@ -1378,7 +1436,10 @@ def load_gateway_config() -> GatewayConfig: if not isinstance(existing, dict): existing = {} # Deep-merge extra dicts so gateway.json defaults survive - merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})} + merged_extra = { + **existing.get("extra", {}), + **plat_block.get("extra", {}), + } if "enabled" in plat_block: merged_extra["_enabled_explicit"] = True merged = {**existing, **plat_block} @@ -1394,6 +1455,7 @@ def load_gateway_config() -> GatewayConfig: # so plugin authors get the same shared-key bridging (#24836). try: from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent from gateway.platform_registry import platform_registry as _pr except Exception as e: @@ -1438,9 +1500,11 @@ def load_gateway_config() -> GatewayConfig: # Collect bridgeable keys from this platform section bridged = {} if "unauthorized_dm_behavior" in platform_cfg: - bridged["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( - platform_cfg.get("unauthorized_dm_behavior"), - gw_data.get("unauthorized_dm_behavior", "pair"), + bridged["unauthorized_dm_behavior"] = ( + _normalize_unauthorized_dm_behavior( + platform_cfg.get("unauthorized_dm_behavior"), + gw_data.get("unauthorized_dm_behavior", "pair"), + ) ) if "notice_delivery" in platform_cfg: bridged["notice_delivery"] = _normalize_notice_delivery( @@ -1452,7 +1516,9 @@ def load_gateway_config() -> GatewayConfig: if "reply_in_thread" in platform_cfg: bridged["reply_in_thread"] = platform_cfg["reply_in_thread"] if "cron_continuable_surface" in platform_cfg: - bridged["cron_continuable_surface"] = platform_cfg["cron_continuable_surface"] + bridged["cron_continuable_surface"] = platform_cfg[ + "cron_continuable_surface" + ] if "require_mention" in platform_cfg: bridged["require_mention"] = platform_cfg["require_mention"] if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg: @@ -1462,13 +1528,22 @@ def load_gateway_config() -> GatewayConfig: if plat == Platform.TELEGRAM and "allowed_topics" in platform_cfg: bridged["allowed_topics"] = platform_cfg["allowed_topics"] if "free_response_channels" in platform_cfg: - bridged["free_response_channels"] = platform_cfg["free_response_channels"] + bridged["free_response_channels"] = platform_cfg[ + "free_response_channels" + ] if "mention_patterns" in platform_cfg: bridged["mention_patterns"] = platform_cfg["mention_patterns"] if "exclusive_bot_mentions" in platform_cfg: - bridged["exclusive_bot_mentions"] = platform_cfg["exclusive_bot_mentions"] - if plat == Platform.TELEGRAM and "observe_unmentioned_group_messages" in platform_cfg: - bridged["observe_unmentioned_group_messages"] = platform_cfg["observe_unmentioned_group_messages"] + bridged["exclusive_bot_mentions"] = platform_cfg[ + "exclusive_bot_mentions" + ] + if ( + plat == Platform.TELEGRAM + and "observe_unmentioned_group_messages" in platform_cfg + ): + bridged["observe_unmentioned_group_messages"] = platform_cfg[ + "observe_unmentioned_group_messages" + ] if "dm_policy" in platform_cfg: bridged["dm_policy"] = platform_cfg["dm_policy"] if "allow_from" in platform_cfg: @@ -1476,25 +1551,40 @@ def load_gateway_config() -> GatewayConfig: if "allow_admin_from" in platform_cfg: bridged["allow_admin_from"] = platform_cfg["allow_admin_from"] if "user_allowed_commands" in platform_cfg: - bridged["user_allowed_commands"] = platform_cfg["user_allowed_commands"] + bridged["user_allowed_commands"] = platform_cfg[ + "user_allowed_commands" + ] if "group_policy" in platform_cfg: bridged["group_policy"] = platform_cfg["group_policy"] if "group_allow_from" in platform_cfg: bridged["group_allow_from"] = platform_cfg["group_allow_from"] if "group_allow_admin_from" in platform_cfg: - bridged["group_allow_admin_from"] = platform_cfg["group_allow_admin_from"] + bridged["group_allow_admin_from"] = platform_cfg[ + "group_allow_admin_from" + ] if "group_user_allowed_commands" in platform_cfg: - bridged["group_user_allowed_commands"] = platform_cfg["group_user_allowed_commands"] - if plat in {Platform.DISCORD, Platform.SLACK} and "channel_skill_bindings" in platform_cfg: - bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] + bridged["group_user_allowed_commands"] = platform_cfg[ + "group_user_allowed_commands" + ] + if ( + plat in {Platform.DISCORD, Platform.SLACK} + and "channel_skill_bindings" in platform_cfg + ): + bridged["channel_skill_bindings"] = platform_cfg[ + "channel_skill_bindings" + ] if "channel_prompts" in platform_cfg: channel_prompts = platform_cfg["channel_prompts"] if isinstance(channel_prompts, dict): - bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()} + bridged["channel_prompts"] = { + str(k): v for k, v in channel_prompts.items() + } else: bridged["channel_prompts"] = channel_prompts if "gateway_restart_notification" in platform_cfg: - bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"] + bridged["gateway_restart_notification"] = platform_cfg[ + "gateway_restart_notification" + ] if "typing_indicator" in platform_cfg: bridged["typing_indicator"] = platform_cfg["typing_indicator"] if "typing_status_text" in platform_cfg: @@ -1512,9 +1602,15 @@ def load_gateway_config() -> GatewayConfig: if isinstance(ov_data, dict) } enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg - if not bridged and not enabled_was_explicit and not has_channel_overrides: + 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) + plat_data, extra = _ensure_platform_extra_dict( + platforms_data, plat.value + ) if enabled_was_explicit: plat_data["enabled"] = platform_cfg["enabled"] # Mark the explicit enable/disable so the registry-driven @@ -1554,7 +1650,8 @@ def load_gateway_config() -> GatewayConfig: except Exception as e: logger.debug( "apply_yaml_config_fn for %s raised: %s", - entry.name, e, + entry.name, + e, ) continue if not isinstance(seeded, dict) or not seeded: @@ -1585,7 +1682,9 @@ def load_gateway_config() -> GatewayConfig: # apply_yaml_config_fn hook — which only runs when a telegram config # block exists — can't cover the no-telegram-block case (#3979). if not os.getenv("TELEGRAM_REQUIRE_MENTION"): - os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_tl_require_mention).lower() + os.environ["TELEGRAM_REQUIRE_MENTION"] = str( + _tl_require_mention + ).lower() # Telegram settings → env vars / extra: migrated to the telegram # plugin's apply_yaml_config_fn hook @@ -1598,8 +1697,12 @@ def load_gateway_config() -> GatewayConfig: # Signal settings → env vars (env vars take precedence) signal_cfg = yaml_cfg.get("signal", {}) if isinstance(signal_cfg, dict): - if "require_mention" in signal_cfg and not os.getenv("SIGNAL_REQUIRE_MENTION"): - os.environ["SIGNAL_REQUIRE_MENTION"] = str(signal_cfg["require_mention"]).lower() + if "require_mention" in signal_cfg and not os.getenv( + "SIGNAL_REQUIRE_MENTION" + ): + os.environ["SIGNAL_REQUIRE_MENTION"] = str( + signal_cfg["require_mention"] + ).lower() # DingTalk settings → env vars: migrated to the dingtalk plugin's # apply_yaml_config_fn hook (plugins/platforms/dingtalk/adapter.py). @@ -1628,7 +1731,7 @@ def load_gateway_config() -> GatewayConfig: # Override with environment variables _apply_env_overrides(config) - + # --- Validate loaded values --- _validate_gateway_config(config) @@ -1667,7 +1770,8 @@ def _validate_gateway_config(config: "GatewayConfig") -> None: logger.warning( "%s is enabled but %s is empty. " "The adapter will likely fail to connect.", - platform.value, env_name, + platform.value, + env_name, ) # Reject known-weak placeholder tokens. @@ -1692,7 +1796,9 @@ def _validate_gateway_config(config: "GatewayConfig") -> None: "%s is enabled but %s is set to a placeholder value ('%s'). " "Set a real bot token before starting the gateway. " "The adapter will NOT be started.", - platform.value, env_name, token.strip()[:6] + "...", + platform.value, + env_name, + token.strip()[:6] + "...", ) pconfig.enabled = False @@ -1714,24 +1820,26 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # platforms — telegram, matrix — flow through here too, #41112). The # flag is cleared once for all platforms in the final cleanup at the # end of _apply_env_overrides. - enabled_was_explicit = bool(platform_config.extra.get("_enabled_explicit", False)) + enabled_was_explicit = bool( + platform_config.extra.get("_enabled_explicit", False) + ) if not platform_config.enabled and not enabled_was_explicit: platform_config.enabled = True return platform_config - + # Telegram telegram_token = getenv("TELEGRAM_BOT_TOKEN") if telegram_token: telegram_config = _enable_from_env(Platform.TELEGRAM) telegram_config.token = telegram_token - + # Reply threading mode for Telegram (off/first/all) telegram_reply_mode = getenv("TELEGRAM_REPLY_TO_MODE", "").lower() if telegram_reply_mode in {"off", "first", "all"}: if Platform.TELEGRAM not in config.platforms: config.platforms[Platform.TELEGRAM] = PlatformConfig() config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode - + telegram_fallback_ips = getenv("TELEGRAM_FALLBACK_IPS", "") if telegram_fallback_ips: if Platform.TELEGRAM not in config.platforms: @@ -1748,13 +1856,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"), thread_id=getenv("TELEGRAM_HOME_CHANNEL_THREAD_ID") or None, ) - + # Discord discord_token = getenv("DISCORD_BOT_TOKEN") if discord_token: discord_config = _enable_from_env(Platform.DISCORD) discord_config.token = discord_token - + discord_home = getenv("DISCORD_HOME_CHANNEL") if discord_home and Platform.DISCORD in config.platforms: config.platforms[Platform.DISCORD].home_channel = HomeChannel( @@ -1763,17 +1871,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=getenv("DISCORD_HOME_CHANNEL_NAME", "Home"), thread_id=getenv("DISCORD_HOME_CHANNEL_THREAD_ID") or None, ) - + # Reply threading mode for Discord (off/first/all) discord_reply_mode = getenv("DISCORD_REPLY_TO_MODE", "").lower() if discord_reply_mode in {"off", "first", "all"}: if Platform.DISCORD not in config.platforms: config.platforms[Platform.DISCORD] = PlatformConfig() config.platforms[Platform.DISCORD].reply_to_mode = discord_reply_mode - + # WhatsApp (typically uses different auth mechanism) whatsapp_enabled = is_truthy_value(getenv("WHATSAPP_ENABLED", "")) - whatsapp_disabled_explicitly = getenv("WHATSAPP_ENABLED", "").lower() in {"false", "0", "no"} + whatsapp_disabled_explicitly = getenv("WHATSAPP_ENABLED", "").lower() in { + "false", + "0", + "no", + } if Platform.WHATSAPP in config.platforms: # YAML config exists — respect explicit disable wa_cfg = config.platforms[Platform.WHATSAPP] @@ -1813,32 +1925,46 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.WHATSAPP_CLOUD].extra["app_id"] = wa_cloud_app_id wa_cloud_app_secret = getenv("WHATSAPP_CLOUD_APP_SECRET") if wa_cloud_app_secret: - config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = wa_cloud_app_secret + config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = ( + wa_cloud_app_secret + ) # Optional: WABA id (analytics, future use) wa_cloud_waba_id = getenv("WHATSAPP_CLOUD_WABA_ID") if wa_cloud_waba_id: - config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = wa_cloud_waba_id + config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = ( + wa_cloud_waba_id + ) # Webhook verify token — Meta hub.verify_token shared secret wa_cloud_verify_token = getenv("WHATSAPP_CLOUD_VERIFY_TOKEN") if wa_cloud_verify_token: - config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = wa_cloud_verify_token + config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = ( + wa_cloud_verify_token + ) # Webhook server bind config (defaults baked into the adapter) wa_cloud_host = getenv("WHATSAPP_CLOUD_WEBHOOK_HOST") if wa_cloud_host: - config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = wa_cloud_host + config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = ( + wa_cloud_host + ) wa_cloud_port = getenv("WHATSAPP_CLOUD_WEBHOOK_PORT") if wa_cloud_port: try: - config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(wa_cloud_port) + config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int( + wa_cloud_port + ) except ValueError: pass wa_cloud_path = getenv("WHATSAPP_CLOUD_WEBHOOK_PATH") if wa_cloud_path: - config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = wa_cloud_path + config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = ( + wa_cloud_path + ) # Graph API version override (rarely needed) wa_cloud_api_version = getenv("WHATSAPP_CLOUD_API_VERSION") if wa_cloud_api_version: - config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = wa_cloud_api_version + config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = ( + wa_cloud_api_version + ) whatsapp_cloud_home = getenv("WHATSAPP_CLOUD_HOME_CHANNEL") if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms: config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel( @@ -1862,7 +1988,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # platform the user explicitly disabled (Slack is now a plugin # entry — #41112). The flag is cleared once for all platforms in # the final cleanup at the end of _apply_env_overrides. - enabled_was_explicit = bool(slack_config.extra.get("_enabled_explicit", False)) + enabled_was_explicit = bool( + slack_config.extra.get("_enabled_explicit", False) + ) if not slack_config.enabled and not enabled_was_explicit: # Top-level Slack settings such as channel prompts should not # turn an env-token setup into a disabled platform. Only an @@ -1880,7 +2008,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=getenv("SLACK_HOME_CHANNEL_NAME", ""), thread_id=getenv("SLACK_HOME_CHANNEL_THREAD_ID") or None, ) - + # Signal signal_url = getenv("SIGNAL_HTTP_URL") signal_account = getenv("SIGNAL_ACCOUNT") @@ -1923,7 +2051,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: matrix_homeserver = getenv("MATRIX_HOMESERVER", "") if matrix_token or getenv("MATRIX_PASSWORD"): if not matrix_homeserver: - logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing") + logger.warning( + "MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing" + ) matrix_config = _enable_from_env(Platform.MATRIX) if matrix_token: matrix_config.token = matrix_token @@ -1935,10 +2065,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if matrix_password: matrix_config.extra["password"] = matrix_password matrix_e2ee_mode = getenv("MATRIX_E2EE_MODE", "").strip().lower() - matrix_e2ee = ( - matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred") - or is_truthy_value(getenv("MATRIX_ENCRYPTION", "")) - ) + matrix_e2ee = matrix_e2ee_mode in ( + "required", + "require", + "optional", + "prefer", + "preferred", + ) or is_truthy_value(getenv("MATRIX_ENCRYPTION", "")) matrix_config.extra["encryption"] = matrix_e2ee if matrix_e2ee_mode: matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode @@ -2020,19 +2153,27 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if api_server_key: config.platforms[Platform.API_SERVER].extra["key"] = api_server_key if api_server_cors_origins: - origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()] + origins = [ + origin.strip() + for origin in api_server_cors_origins.split(",") + if origin.strip() + ] if origins: config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins if api_server_port: try: - config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port) + config.platforms[Platform.API_SERVER].extra["port"] = int( + api_server_port + ) except ValueError: pass if api_server_host: config.platforms[Platform.API_SERVER].extra["host"] = api_server_host api_server_model_name = getenv("API_SERVER_MODEL_NAME", "") if api_server_model_name: - config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name + config.platforms[Platform.API_SERVER].extra["model_name"] = ( + api_server_model_name + ) # Webhook platform webhook_enabled = is_truthy_value(getenv("WEBHOOK_ENABLED", "")) @@ -2055,9 +2196,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: msgraph_webhook_port = getenv("MSGRAPH_WEBHOOK_PORT") msgraph_webhook_client_state = getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "") msgraph_webhook_resources = getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", "") - msgraph_webhook_allowed_cidrs = getenv( - "MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS", "" - ) + msgraph_webhook_allowed_cidrs = getenv("MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS", "") if ( msgraph_webhook_enabled or Platform.MSGRAPH_WEBHOOK in config.platforms @@ -2140,7 +2279,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key feishu_verification_token = getenv("FEISHU_VERIFICATION_TOKEN", "") if feishu_verification_token: - config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token + config.platforms[Platform.FEISHU].extra["verification_token"] = ( + feishu_verification_token + ) feishu_home = getenv("FEISHU_HOME_CHANNEL") if feishu_home: config.platforms[Platform.FEISHU].home_channel = HomeChannel( @@ -2245,7 +2386,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: "webhook_host": getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), "webhook_port": getenv_int("BLUEBUBBLES_WEBHOOK_PORT", 8645), "webhook_path": getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), - "send_read_receipts": is_truthy_value(getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true")), + "send_read_receipts": is_truthy_value( + getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true") + ), }) bluebubbles_require_mention = getenv("BLUEBUBBLES_REQUIRE_MENTION") if bluebubbles_require_mention is not None: @@ -2259,10 +2402,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None: except Exception: parsed_patterns = [ part.strip() - for part in bluebubbles_mention_patterns.replace("\n", ",").split(",") + for part in bluebubbles_mention_patterns.replace("\n", ",").split( + "," + ) if part.strip() ] - config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns + config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = ( + parsed_patterns + ) bluebubbles_home = getenv("BLUEBUBBLES_HOME_CHANNEL") if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel( @@ -2306,7 +2453,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.QQBOT].home_channel = HomeChannel( platform=Platform.QQBOT, chat_id=qq_home, - name=getenv("QQBOT_HOME_CHANNEL_NAME") or getenv(qq_home_name_env, "Home"), + name=getenv("QQBOT_HOME_CHANNEL_NAME") + or getenv(qq_home_name_env, "Home"), thread_id=( getenv("QQBOT_HOME_CHANNEL_THREAD_ID") or getenv("QQ_HOME_CHANNEL_THREAD_ID") @@ -2364,7 +2512,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.default_reset_policy.idle_minutes = int(idle_minutes) except ValueError: pass - + reset_hour = getenv("SESSION_RESET_HOUR") if reset_hour: try: @@ -2392,8 +2540,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # counterpart. try: from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent from gateway.platform_registry import platform_registry + for entry in platform_registry.plugin_entries(): try: platform = Platform(entry.name) @@ -2428,9 +2578,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: try: seed_for_probe = entry.env_enablement_fn() except Exception as e: - logger.debug( - "env_enablement_fn for %s raised: %s", entry.name, e - ) + logger.debug("env_enablement_fn for %s raised: %s", entry.name, e) seed_for_probe = None # Only consult is_connected for platforms that are NOT already @@ -2473,7 +2621,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None: except Exception as exc: logger.debug( "is_connected for %s raised: %s — skipping enablement", - entry.name, exc, + entry.name, + exc, ) configured = False if not configured: @@ -2518,9 +2667,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: chat_id=str(home["chat_id"]), name=str(home.get("name") or "Home"), thread_id=( - str(home["thread_id"]) - if home.get("thread_id") - else None + str(home["thread_id"]) if home.get("thread_id") else None ), ) except Exception as e: diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 9fede35d034a..211a3e2ebc19 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -70,7 +70,9 @@ class TestRedactApiErrorText: # force=True must mask even when global redaction is disabled. secret = "sk-forced-redaction-0987654321" with patch("agent.redact._REDACT_ENABLED", False): - out = _redact_api_error_text(Exception(f"boom AWS_SECRET_ACCESS_KEY={secret}")) + out = _redact_api_error_text( + Exception(f"boom AWS_SECRET_ACCESS_KEY={secret}") + ) assert secret not in out def test_limit_truncates_after_redaction(self): @@ -171,7 +173,9 @@ class TestResponseStore: "resp_secret", { "response": {"id": "resp_secret"}, - "conversation_history": [{"role": "tool", "content": "dummy-marker"}], + "conversation_history": [ + {"role": "tool", "content": "dummy-marker"} + ], }, ) finally: @@ -314,7 +318,9 @@ class TestAdapterInit: monkeypatch.setenv("API_SERVER_HOST", "10.0.0.1") monkeypatch.setenv("API_SERVER_PORT", "7777") monkeypatch.setenv("API_SERVER_KEY", "sk-env") - monkeypatch.setenv("API_SERVER_CORS_ORIGINS", "http://localhost:3000, http://127.0.0.1:3000") + monkeypatch.setenv( + "API_SERVER_CORS_ORIGINS", "http://localhost:3000, http://127.0.0.1:3000" + ) config = PlatformConfig(enabled=True) adapter = APIServerAdapter(config) assert adapter._host == "10.0.0.1" @@ -364,8 +370,12 @@ class TestAdapterInit: "gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda: {"enabled": True, "effort": "xhigh"}), ) - monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None)) - monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set()) + monkeypatch.setattr( + "gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None) + ) + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", lambda *_: set() + ) adapter = APIServerAdapter(PlatformConfig(enabled=True)) monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) @@ -379,7 +389,9 @@ class TestAdapterInit: assert captured["checkpoint_max_total_size_mb"] == 321 assert captured["checkpoint_max_file_size_mb"] == 4 - def test_create_agent_refreshes_max_iterations_from_runtime_config(self, monkeypatch): + def test_create_agent_refreshes_max_iterations_from_runtime_config( + self, monkeypatch + ): captured = {} class FakeAgent: @@ -396,14 +408,20 @@ class TestAdapterInit: }, ) monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5") - monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"agent": {"max_turns": 200}}) + monkeypatch.setattr( + "gateway.run._load_gateway_config", lambda: {"agent": {"max_turns": 200}} + ) monkeypatch.setattr( "gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda: {}), ) - monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None)) + monkeypatch.setattr( + "gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None) + ) monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 200) - monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set()) + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", lambda *_: set() + ) adapter = APIServerAdapter(PlatformConfig(enabled=True)) monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) @@ -435,15 +453,21 @@ class TestAdapterInit: "model": "anthropic/claude-haiku", # from the fallback entry }, ) - monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "primary/model") + monkeypatch.setattr( + "gateway.run._resolve_gateway_model", lambda: "primary/model" + ) monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}) monkeypatch.setattr( "gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda: {}), ) - monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None)) + monkeypatch.setattr( + "gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None) + ) monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 90) - monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set()) + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", lambda *_: set() + ) adapter = APIServerAdapter(PlatformConfig(enabled=True)) monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) @@ -473,15 +497,21 @@ class TestAdapterInit: "api_mode": "chat_completions", }, ) - monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "primary/model") + monkeypatch.setattr( + "gateway.run._resolve_gateway_model", lambda: "primary/model" + ) monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}) monkeypatch.setattr( "gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda: {}), ) - monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None)) + monkeypatch.setattr( + "gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None) + ) monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 90) - monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set()) + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", lambda *_: set() + ) adapter = APIServerAdapter(PlatformConfig(enabled=True)) monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) @@ -651,7 +681,9 @@ def _make_adapter(api_key: str = "", cors_origins=None) -> APIServerAdapter: def _create_app(adapter: APIServerAdapter) -> web.Application: """Create the aiohttp app from the adapter (without starting the full server).""" - mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None] + mws = [ + mw for mw in (cors_middleware, security_headers_middleware) if mw is not None + ] app = web.Application(middlewares=mws) app["api_server_adapter"] = adapter app.router.add_get("/health", adapter._handle_health) @@ -664,7 +696,9 @@ def _create_app(adapter: APIServerAdapter) -> web.Application: app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions) app.router.add_post("/v1/responses", adapter._handle_responses) app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response) - app.router.add_delete("/v1/responses/{response_id}", adapter._handle_delete_response) + app.router.add_delete( + "/v1/responses/{response_id}", adapter._handle_delete_response + ) app.router.add_post( "/api/platforms/{platform}/events", adapter._handle_platform_event_callback, @@ -745,9 +779,18 @@ class TestHealthEndpoint: async with TestClient(TestServer(app)) as cli: resp = await cli.get("/health") assert resp.status == 200 - assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; frame-ancestors 'none'" - assert resp.headers.get("Permissions-Policy") == "camera=(), microphone=(), geolocation=()" - assert resp.headers.get("Strict-Transport-Security") == "max-age=31536000; includeSubDomains" + assert ( + resp.headers.get("Content-Security-Policy") + == "default-src 'none'; frame-ancestors 'none'" + ) + assert ( + resp.headers.get("Permissions-Policy") + == "camera=(), microphone=(), geolocation=()" + ) + assert ( + resp.headers.get("Strict-Transport-Security") + == "max-age=31536000; includeSubDomains" + ) assert resp.headers.get("X-Content-Type-Options") == "nosniff" assert resp.headers.get("X-Frame-Options") == "DENY" assert resp.headers.get("X-XSS-Protection") == "0" @@ -819,17 +862,36 @@ class TestHealthEndpoint: class TestHealthDetailedEndpoint: + @pytest.fixture(autouse=True) + def mock_disk_usage(self): + from collections import namedtuple + + DiskUsage = namedtuple("usage", ["total", "used", "free"]) + mock_usage = DiskUsage( + total=100 * 1024 * 1024 * 1024, + used=10 * 1024 * 1024 * 1024, + free=90 * 1024 * 1024 * 1024, + ) + with patch("gateway.readiness.shutil.disk_usage", return_value=mock_usage): + yield + @pytest.mark.asyncio async def test_health_detailed_returns_ok(self, adapter): """GET /health/detailed returns status, platform, and runtime fields.""" app = _create_app(adapter) - with patch("gateway.status.read_runtime_status", return_value={ - "gateway_state": "running", - "platforms": {"telegram": {"state": "connected"}}, - "active_agents": 2, - "exit_reason": None, - "updated_at": "2026-04-14T00:00:00Z", - }), patch("gateway.run._resolve_gateway_model", return_value="test/model"): + with ( + patch( + "gateway.status.read_runtime_status", + return_value={ + "gateway_state": "running", + "platforms": {"telegram": {"state": "connected"}}, + "active_agents": 2, + "exit_reason": None, + "updated_at": "2026-04-14T00:00:00Z", + }, + ), + patch("gateway.run._resolve_gateway_model", return_value="test/model"), + ): async with TestClient(TestServer(app)) as cli: resp = await cli.get("/health/detailed") assert resp.status == 200 @@ -876,7 +938,10 @@ class TestHealthDetailedEndpoint: async def test_health_detailed_allows_authenticated_request(self, auth_adapter): app = _create_app(auth_adapter) headers = {"Authorization": f"Bearer {auth_adapter._api_key}"} - with patch("gateway.status.read_runtime_status", return_value={"gateway_state": "running"}): + with patch( + "gateway.status.read_runtime_status", + return_value={"gateway_state": "running"}, + ): async with TestClient(TestServer(app)) as cli: resp = await cli.get("/health/detailed", headers=headers) assert resp.status == 200 @@ -892,8 +957,16 @@ class TestHealthDetailedEndpoint: "config": {"status": "degraded", "detail": "invalid config"}, }, } - with patch("gateway.status.read_runtime_status", return_value={"gateway_state": "running"}), \ - patch("gateway.platforms.api_server.collect_runtime_readiness", return_value=expected): + with ( + patch( + "gateway.status.read_runtime_status", + return_value={"gateway_state": "running"}, + ), + patch( + "gateway.platforms.api_server.collect_runtime_readiness", + return_value=expected, + ), + ): async with TestClient(TestServer(app)) as cli: resp = await cli.get("/health/detailed") assert resp.status == 200 @@ -922,8 +995,13 @@ class TestHealthDetailedEndpoint: # Completed streams may remain attached for replay; they are not work. adapter._run_streams = {"done": object(), "failed": object()} - with patch("tools.process_registry.process_registry.completion_queue.qsize", return_value=4), \ - patch("tools.async_delegation.active_count", return_value=2): + with ( + patch( + "tools.process_registry.process_registry.completion_queue.qsize", + return_value=4, + ), + patch("tools.async_delegation.active_count", return_value=2), + ): assert adapter._readiness_work_counts() == (3, 4, 2) def test_readiness_work_counts_include_stopping_runs(self, adapter): @@ -968,7 +1046,10 @@ class TestModelsEndpoint: @pytest.mark.asyncio async def test_models_returns_profile_name(self): """When running under a named profile, /v1/models advertises the profile name.""" - with patch("gateway.platforms.api_server.APIServerAdapter._resolve_model_name", return_value="lucas"): + with patch( + "gateway.platforms.api_server.APIServerAdapter._resolve_model_name", + return_value="lucas", + ): adapter = _make_adapter() app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: @@ -991,7 +1072,9 @@ class TestModelsEndpoint: def test_resolve_model_name_default_profile(self): """Default profile falls back to 'hermes-agent'.""" - with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): + with patch( + "hermes_cli.profiles.get_active_profile_name", return_value="default" + ): assert APIServerAdapter._resolve_model_name("") == "hermes-agent" def test_resolve_model_name_named_profile(self): @@ -1042,10 +1125,18 @@ class TestCapabilitiesEndpoint: assert data["features"]["chat_completions"] is True assert data["features"]["run_status"] is True assert data["features"]["run_events_sse"] is True - assert data["features"]["session_continuity_header"] == "X-Hermes-Session-Id" + assert ( + data["features"]["session_continuity_header"] == "X-Hermes-Session-Id" + ) assert data["endpoints"]["run_status"]["path"] == "/v1/runs/{run_id}" - assert data["endpoints"]["skills"] == {"method": "GET", "path": "/v1/skills"} - assert data["endpoints"]["toolsets"] == {"method": "GET", "path": "/v1/toolsets"} + assert data["endpoints"]["skills"] == { + "method": "GET", + "path": "/v1/skills", + } + assert data["endpoints"]["toolsets"] == { + "method": "GET", + "path": "/v1/toolsets", + } @pytest.mark.asyncio async def test_capabilities_requires_auth_when_key_configured(self, auth_adapter): @@ -1072,8 +1163,16 @@ class TestSkillsEndpoint: @pytest.mark.asyncio async def test_skills_returns_list_envelope(self, adapter): fake_skills = [ - {"name": "github", "description": "GitHub workflow skill", "category": "github"}, - {"name": "ascii-art", "description": "ASCII art generation", "category": "creative"}, + { + "name": "github", + "description": "GitHub workflow skill", + "category": "github", + }, + { + "name": "ascii-art", + "description": "ASCII art generation", + "category": "creative", + }, ] with patch( "tools.skills_tool._find_all_skills", @@ -1125,21 +1224,26 @@ class TestToolsetsEndpoint: ("default", "Default Tools", "Core tools"), ("web", "Web Tools", "Search and extract"), ] - with patch( - "hermes_cli.tools_config._get_effective_configurable_toolsets", - return_value=fake_toolsets, - ), patch( - "hermes_cli.tools_config._get_platform_tools", - return_value={"default"}, - ), patch( - "hermes_cli.tools_config._toolset_has_keys", - return_value=True, - ), patch( - "toolsets.resolve_toolset", - side_effect=lambda name: { - "default": ["terminal", "read_file"], - "web": ["web_search"], - }[name], + with ( + patch( + "hermes_cli.tools_config._get_effective_configurable_toolsets", + return_value=fake_toolsets, + ), + patch( + "hermes_cli.tools_config._get_platform_tools", + return_value={"default"}, + ), + patch( + "hermes_cli.tools_config._toolset_has_keys", + return_value=True, + ), + patch( + "toolsets.resolve_toolset", + side_effect=lambda name: { + "default": ["terminal", "read_file"], + "web": ["web_search"], + }[name], + ), ): app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: @@ -1168,18 +1272,23 @@ class TestToolsetsEndpoint: raise RuntimeError("nope") return ["some_tool"] - with patch( - "hermes_cli.tools_config._get_effective_configurable_toolsets", - return_value=fake_toolsets, - ), patch( - "hermes_cli.tools_config._get_platform_tools", - return_value=set(), - ), patch( - "hermes_cli.tools_config._toolset_has_keys", - return_value=False, - ), patch( - "toolsets.resolve_toolset", - side_effect=_resolve, + with ( + patch( + "hermes_cli.tools_config._get_effective_configurable_toolsets", + return_value=fake_toolsets, + ), + patch( + "hermes_cli.tools_config._get_platform_tools", + return_value=set(), + ), + patch( + "hermes_cli.tools_config._toolset_has_keys", + return_value=False, + ), + patch( + "toolsets.resolve_toolset", + side_effect=_resolve, + ), ): app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: @@ -1192,12 +1301,15 @@ class TestToolsetsEndpoint: @pytest.mark.asyncio async def test_toolsets_requires_auth_when_key_configured(self, auth_adapter): - with patch( - "hermes_cli.tools_config._get_effective_configurable_toolsets", - return_value=[], - ), patch( - "hermes_cli.tools_config._get_platform_tools", - return_value=set(), + with ( + patch( + "hermes_cli.tools_config._get_effective_configurable_toolsets", + return_value=[], + ), + patch( + "hermes_cli.tools_config._get_platform_tools", + return_value=set(), + ), ): app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: @@ -1243,7 +1355,9 @@ class TestChatCompletionsEndpoint: async def test_empty_messages_returns_400(self, adapter): app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - resp = await cli.post("/v1/chat/completions", json={"model": "test", "messages": []}) + resp = await cli.post( + "/v1/chat/completions", json={"model": "test", "messages": []} + ) assert resp.status == 400 @pytest.mark.asyncio @@ -1251,6 +1365,7 @@ class TestChatCompletionsEndpoint: """stream=true returns SSE format with the full response.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): # Simulate streaming: invoke stream_delta_callback with tokens cb = kwargs.get("stream_delta_callback") @@ -1262,7 +1377,9 @@ class TestChatCompletionsEndpoint: {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) - with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent) as mock_run: + with patch.object( + adapter, "_run_agent", side_effect=_mock_run_agent + ) as mock_run: resp = await cli.post( "/v1/chat/completions", json={ @@ -1290,7 +1407,9 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( mock_result, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, @@ -1308,13 +1427,19 @@ class TestChatCompletionsEndpoint: assert "text/event-stream" not in resp.headers.get("Content-Type", "") data = await resp.json() assert data["object"] == "chat.completion" - assert data["choices"][0]["message"]["content"] == mock_result["final_response"] + assert ( + data["choices"][0]["message"]["content"] + == mock_result["final_response"] + ) @pytest.mark.asyncio - async def test_stream_task_done_callback_enqueues_eos_for_chat_completions(self, adapter): + async def test_stream_task_done_callback_enqueues_eos_for_chat_completions( + self, adapter + ): """Regression guard for #24451: completion callback must signal SSE EOS.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + class _FakeTask: def __init__(self): self.callbacks = [] @@ -1340,8 +1465,13 @@ class TestChatCompletionsEndpoint: ) ), ), - patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future), - patch.object(adapter, "_write_sse_chat_completion", new_callable=AsyncMock) as mock_write_sse, + patch( + "gateway.platforms.api_server.asyncio.ensure_future", + side_effect=_fake_ensure_future, + ), + patch.object( + adapter, "_write_sse_chat_completion", new_callable=AsyncMock + ) as mock_write_sse, ): mock_write_sse.return_value = web.Response(status=200, text="ok") resp = await cli.post( @@ -1368,6 +1498,7 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") if cb: @@ -1375,12 +1506,18 @@ class TestChatCompletionsEndpoint: await asyncio.sleep(0.65) cb("...done") return ( - {"final_response": "Working...done", "messages": [], "api_calls": 1}, + { + "final_response": "Working...done", + "messages": [], + "api_calls": 1, + }, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) with ( - patch.object(api_server_mod, "CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS", 0.01), + patch.object( + api_server_mod, "CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS", 0.01 + ), patch.object(adapter, "_run_agent", side_effect=_mock_run_agent), ): resp = await cli.post( @@ -1411,20 +1548,25 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") if cb: # Simulate: agent streams partial text, then fires None # (tool call box-close signal), then streams the final answer cb("Thinking") - cb(None) # mid-stream None from tool calls + cb(None) # mid-stream None from tool calls await asyncio.sleep(0.05) # simulate tool execution delay cb(" about it...") - cb(None) # another None (possible second tool round) + cb(None) # another None (possible second tool round) await asyncio.sleep(0.05) cb(" The answer is 42.") return ( - {"final_response": "Thinking about it... The answer is 42.", "messages": [], "api_calls": 3}, + { + "final_response": "Thinking about it... The answer is 42.", + "messages": [], + "api_calls": 3, + }, {"input_tokens": 20, "output_tokens": 15, "total_tokens": 35}, ) @@ -1433,7 +1575,9 @@ class TestChatCompletionsEndpoint: "/v1/chat/completions", json={ "model": "test", - "messages": [{"role": "user", "content": "What is the answer?"}], + "messages": [ + {"role": "user", "content": "What is the answer?"} + ], "stream": True, }, ) @@ -1453,6 +1597,7 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") ts_cb = kwargs.get("tool_start_callback") @@ -1463,7 +1608,11 @@ class TestChatCompletionsEndpoint: await asyncio.sleep(0.05) cb("Here are the files.") return ( - {"final_response": "Here are the files.", "messages": [], "api_calls": 1}, + { + "final_response": "Here are the files.", + "messages": [], + "api_calls": 1, + }, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) @@ -1491,17 +1640,21 @@ class TestChatCompletionsEndpoint: # The progress marker must NOT appear inside any # chat.completion.chunk delta.content field. import json as _json + for line in body.splitlines(): if line.startswith("data: ") and line.strip() != "data: [DONE]": try: - chunk = _json.loads(line[len("data: "):]) + chunk = _json.loads(line[len("data: ") :]) except _json.JSONDecodeError: continue if chunk.get("object") == "chat.completion.chunk": for choice in chunk.get("choices", []): content = choice.get("delta", {}).get("content", "") # Tool emoji markers must never leak into content - assert "ls -la" not in content or content == "Here are the files." + assert ( + "ls -la" not in content + or content == "Here are the files." + ) # Final content must also be present assert "Here are the files." in body @@ -1512,11 +1665,14 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") ts_cb = kwargs.get("tool_start_callback") if ts_cb: - ts_cb("call_internal_1", "_thinking", {"text": "some internal state"}) + ts_cb( + "call_internal_1", "_thinking", {"text": "some internal state"} + ) ts_cb("call_search_1", "web_search", {"query": "Python docs"}) if cb: await asyncio.sleep(0.05) @@ -1570,6 +1726,7 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") ts_cb = kwargs.get("tool_start_callback") @@ -1610,10 +1767,10 @@ class TestChatCompletionsEndpoint: for i, line in enumerate(lines): if line.strip() != "event: hermes.tool.progress": continue - for follow in lines[i + 1: i + 4]: + for follow in lines[i + 1 : i + 4]: if follow.startswith("data: "): try: - payload = _json.loads(follow[len("data: "):]) + payload = _json.loads(follow[len("data: ") :]) except _json.JSONDecodeError: break pairs.append((payload.get("status"), payload.get("toolCallId"))) @@ -1623,12 +1780,16 @@ class TestChatCompletionsEndpoint: # legacy + new emit), and each lifecycle pair must carry the # same toolCallId on every event — not just somewhere in the # aggregate. - assert len(pairs) == 2, f"expected 2 events (running+completed), got {pairs}" + assert len(pairs) == 2, ( + f"expected 2 events (running+completed), got {pairs}" + ) assert pairs[0] == ("running", "call_terminal_1"), pairs assert pairs[1] == ("completed", "call_terminal_1"), pairs @pytest.mark.asyncio - async def test_stream_tool_lifecycle_skips_internal_and_orphan_completes(self, adapter): + async def test_stream_tool_lifecycle_skips_internal_and_orphan_completes( + self, adapter + ): """Internal tools (``_thinking``-style) and ``completed`` events without a prior matching ``running`` must produce no lifecycle events on the wire — otherwise clients would see orphaned @@ -1637,6 +1798,7 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") ts_cb = kwargs.get("tool_start_callback") @@ -1700,8 +1862,13 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", json={ @@ -1717,7 +1884,10 @@ class TestChatCompletionsEndpoint: assert data["model"] == "hermes-agent" assert len(data["choices"]) == 1 assert data["choices"][0]["message"]["role"] == "assistant" - assert data["choices"][0]["message"]["content"] == "Hello! How can I help you today?" + assert ( + data["choices"][0]["message"]["content"] + == "Hello! How can I help you today?" + ) assert data["choices"][0]["finish_reason"] == "stop" assert "usage" in data @@ -1732,8 +1902,13 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", json={ @@ -1748,7 +1923,9 @@ class TestChatCompletionsEndpoint: assert resp.status == 200 # Check that _run_agent was called with the system prompt call_kwargs = mock_run.call_args - assert call_kwargs.kwargs.get("ephemeral_system_prompt") == "You are a pirate." + assert ( + call_kwargs.kwargs.get("ephemeral_system_prompt") == "You are a pirate." + ) assert call_kwargs.kwargs.get("user_message") == "Hello" @pytest.mark.asyncio @@ -1758,8 +1935,13 @@ class TestChatCompletionsEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", json={ @@ -1776,15 +1958,23 @@ class TestChatCompletionsEndpoint: call_kwargs = mock_run.call_args.kwargs assert call_kwargs["user_message"] == "Now add 1 more" assert len(call_kwargs["conversation_history"]) == 2 - assert call_kwargs["conversation_history"][0] == {"role": "user", "content": "1+1=?"} - assert call_kwargs["conversation_history"][1] == {"role": "assistant", "content": "2"} + assert call_kwargs["conversation_history"][0] == { + "role": "user", + "content": "1+1=?", + } + assert call_kwargs["conversation_history"][1] == { + "role": "assistant", + "content": "2", + } @pytest.mark.asyncio async def test_agent_error_returns_500(self, adapter): """Agent exception returns 500.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.side_effect = RuntimeError("Provider failed") resp = await cli.post( "/v1/chat/completions", @@ -1807,8 +1997,13 @@ class TestChatCompletionsEndpoint: session_ids = [] async with TestClient(TestServer(app)) as cli: # Turn 1: single user message - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) await cli.post( "/v1/chat/completions", json={ @@ -1819,8 +2014,13 @@ class TestChatCompletionsEndpoint: session_ids.append(mock_run.call_args.kwargs["session_id"]) # Turn 2: same first message, conversation grew - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) await cli.post( "/v1/chat/completions", json={ @@ -1834,8 +2034,12 @@ class TestChatCompletionsEndpoint: ) session_ids.append(mock_run.call_args.kwargs["session_id"]) - assert session_ids[0] == session_ids[1], "Session ID should be stable across turns" - assert session_ids[0].startswith("api-"), "Derived session IDs should have api- prefix" + assert session_ids[0] == session_ids[1], ( + "Session ID should be stable across turns" + ) + assert session_ids[0].startswith("api-"), ( + "Derived session IDs should have api- prefix" + ) @pytest.mark.asyncio async def test_different_conversations_get_different_session_ids(self, adapter): @@ -1846,8 +2050,13 @@ class TestChatCompletionsEndpoint: session_ids = [] async with TestClient(TestServer(app)) as cli: for first_msg in ["Hello", "Goodbye"]: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) await cli.post( "/v1/chat/completions", json={ @@ -1928,8 +2137,13 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={ @@ -1946,7 +2160,10 @@ class TestResponsesEndpoint: assert len(data["output"]) == 1 assert data["output"][0]["type"] == "message" assert data["output"][0]["content"][0]["type"] == "output_text" - assert data["output"][0]["content"][0]["text"] == "Paris is the capital of France." + assert ( + data["output"][0]["content"][0]["text"] + == "Paris is the capital of France." + ) @pytest.mark.asyncio async def test_successful_response_with_array_input(self, adapter): @@ -1955,8 +2172,13 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={ @@ -1981,8 +2203,13 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={ @@ -2008,8 +2235,13 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: # First request - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result_1, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result_1, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp1 = await cli.post( "/v1/responses", json={"model": "hermes-agent", "input": "What is 1+1?"}, @@ -2026,8 +2258,13 @@ class TestResponsesEndpoint: "api_calls": 1, } - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result_2, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result_2, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp2 = await cli.post( "/v1/responses", json={ @@ -2044,7 +2281,9 @@ class TestResponsesEndpoint: assert call_kwargs["user_message"] == "Now add 1 more" @pytest.mark.asyncio - async def test_previous_response_id_stores_full_agent_transcript_once(self, adapter): + async def test_previous_response_id_stores_full_agent_transcript_once( + self, adapter + ): """Chained Responses storage must not append result["messages"] twice.""" first_history = [ {"role": "user", "content": "What is 1+1?"}, @@ -2053,7 +2292,9 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( { "final_response": "2", @@ -2076,7 +2317,9 @@ class TestResponsesEndpoint: {"role": "user", "content": "Now add 1 more"}, {"role": "assistant", "content": "3"}, ] - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( { "final_response": "3", @@ -2100,7 +2343,9 @@ class TestResponsesEndpoint: stored_history = stored_second["conversation_history"] assert stored_history == second_history assert stored_history.count(first_history[0]) == 1 - assert stored_history.count({"role": "user", "content": "Now add 1 more"}) == 1 + assert ( + stored_history.count({"role": "user", "content": "Now add 1 more"}) == 1 + ) @pytest.mark.asyncio async def test_previous_response_id_stores_compressed_transcript_directly(self, adapter): @@ -2425,7 +2670,9 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( { "final_response": "new", @@ -2463,7 +2710,9 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: # First request — establishes a session - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = (mock_result, usage) resp1 = await cli.post( "/v1/responses", @@ -2475,7 +2724,9 @@ class TestResponsesEndpoint: response_id = data1["id"] # Second request — chains from the first - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = (mock_result, usage) resp2 = await cli.post( "/v1/responses", @@ -2512,8 +2763,13 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={ @@ -2535,7 +2791,9 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, @@ -2561,8 +2819,13 @@ class TestResponsesEndpoint: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: # First request with instructions - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp1 = await cli.post( "/v1/responses", json={ @@ -2576,8 +2839,13 @@ class TestResponsesEndpoint: resp_id = data1["id"] # Second request without instructions - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp2 = await cli.post( "/v1/responses", json={ @@ -2595,7 +2863,9 @@ class TestResponsesEndpoint: async def test_agent_error_returns_500(self, adapter): app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.side_effect = RuntimeError("Boom") resp = await cli.post( "/v1/responses", @@ -2609,7 +2879,9 @@ class TestResponsesEndpoint: raw_secret = "sk-responses-leak-1234567890" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( { "final_response": "", @@ -2629,7 +2901,10 @@ class TestResponsesEndpoint: body = json.dumps(data) assert raw_secret not in body assert "OPENAI_API_KEY=" in body - assert data["output"][0]["content"][0]["text"] != f"provider auth failed OPENAI_API_KEY={raw_secret}" + assert ( + data["output"][0]["content"][0]["text"] + != f"provider auth failed OPENAI_API_KEY={raw_secret}" + ) @pytest.mark.asyncio async def test_invalid_input_type_returns_400(self, adapter): @@ -2647,6 +2922,7 @@ class TestResponsesStreaming: async def test_stream_true_returns_responses_sse(self, adapter): app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") if cb: @@ -2685,7 +2961,9 @@ class TestResponsesStreaming: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, @@ -2703,13 +2981,16 @@ class TestResponsesStreaming: assert "text/event-stream" not in resp.headers.get("Content-Type", "") data = await resp.json() assert data["object"] == "response" - assert data["output"][0]["content"][0]["text"] == mock_result["final_response"] + assert ( + data["output"][0]["content"][0]["text"] == mock_result["final_response"] + ) @pytest.mark.asyncio async def test_stream_task_done_callback_enqueues_eos_for_responses(self, adapter): """Regression guard for #24451 on /v1/responses streaming path.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + class _FakeTask: def __init__(self): self.callbacks = [] @@ -2735,8 +3016,13 @@ class TestResponsesStreaming: ) ), ), - patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future), - patch.object(adapter, "_write_sse_responses", new_callable=AsyncMock) as mock_write_sse, + patch( + "gateway.platforms.api_server.asyncio.ensure_future", + side_effect=_fake_ensure_future, + ), + patch.object( + adapter, "_write_sse_responses", new_callable=AsyncMock + ) as mock_write_sse, ): mock_write_sse.return_value = web.Response(status=200, text="ok") resp = await cli.post( @@ -2755,6 +3041,7 @@ class TestResponsesStreaming: async def test_stream_emits_function_call_and_output_items(self, adapter): app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): start_cb = kwargs.get("tool_start_callback") complete_cb = kwargs.get("tool_complete_callback") @@ -2762,7 +3049,12 @@ class TestResponsesStreaming: if start_cb: start_cb("call_123", "read_file", {"path": "/tmp/test.txt"}) if complete_cb: - complete_cb("call_123", "read_file", {"path": "/tmp/test.txt"}, '{"content":"hello"}') + complete_cb( + "call_123", + "read_file", + {"path": "/tmp/test.txt"}, + '{"content":"hello"}', + ) if text_cb: text_cb("Done.") return ( @@ -2795,7 +3087,11 @@ class TestResponsesStreaming: with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent): resp = await cli.post( "/v1/responses", - json={"model": "hermes-agent", "input": "read the file", "stream": True}, + json={ + "model": "hermes-agent", + "input": "read the file", + "stream": True, + }, ) assert resp.status == 200 body = await resp.text() @@ -2806,32 +3102,44 @@ class TestResponsesStreaming: assert '"type": "function_call_output"' in body assert '"call_id": "call_123"' in body assert '"name": "read_file"' in body - assert '"output": [{"type": "input_text", "text": "{\\"content\\":\\"hello\\"}"}]' in body + assert ( + '"output": [{"type": "input_text", "text": "{\\"content\\":\\"hello\\"}"}]' + in body + ) @pytest.mark.asyncio async def test_streamed_response_is_stored_for_get(self, adapter): app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") if cb: cb("Stored response") return ( - {"final_response": "Stored response", "messages": [], "api_calls": 1}, + { + "final_response": "Stored response", + "messages": [], + "api_calls": 1, + }, {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, ) with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent): resp = await cli.post( "/v1/responses", - json={"model": "hermes-agent", "input": "store this", "stream": True}, + json={ + "model": "hermes-agent", + "input": "store this", + "stream": True, + }, ) body = await resp.text() response_id = None for line in body.splitlines(): if line.startswith("data: "): try: - payload = json.loads(line[len("data: "):]) + payload = json.loads(line[len("data: ") :]) except json.JSONDecodeError: continue if payload.get("type") == "response.completed": @@ -2847,7 +3155,9 @@ class TestResponsesStreaming: assert data["output"][-1]["content"][0]["text"] == "Stored response" @pytest.mark.asyncio - async def test_streamed_previous_response_id_stores_full_agent_transcript_once(self, adapter): + async def test_streamed_previous_response_id_stores_full_agent_transcript_once( + self, adapter + ): prior_history = [ {"role": "user", "content": "What is 1+1?"}, {"role": "assistant", "content": "2"}, @@ -2868,6 +3178,7 @@ class TestResponsesStreaming: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): cb = kwargs.get("stream_delta_callback") if cb: @@ -2898,7 +3209,7 @@ class TestResponsesStreaming: for line in body.splitlines(): if line.startswith("data: "): try: - payload = json.loads(line[len("data: "):]) + payload = json.loads(line[len("data: ") :]) except json.JSONDecodeError: continue if payload.get("type") == "response.completed": @@ -2906,7 +3217,9 @@ class TestResponsesStreaming: break assert response_id - stored_history = adapter._response_store.get(response_id)["conversation_history"] + stored_history = adapter._response_store.get(response_id)[ + "conversation_history" + ] assert stored_history == expected_history assert stored_history.count(prior_history[0]) == 1 assert stored_history.count({"role": "user", "content": "Now add 1 more"}) == 1 @@ -2953,7 +3266,9 @@ class TestResponsesStreaming: agent_task = asyncio.ensure_future(_agent_coro()) response_id = f"resp_{uuid.uuid4().hex[:28]}" - with patch.object(api_mod.web, "StreamResponse", return_value=_FakeStreamResponse()): + with patch.object( + api_mod.web, "StreamResponse", return_value=_FakeStreamResponse() + ): with pytest.raises(asyncio.CancelledError): await adapter._write_sse_responses( request=fake_request, @@ -3017,13 +3332,17 @@ class TestResponsesStreaming: async def _agent_coro(): await asyncio.sleep(0.01) - return ({"final_response": "", "messages": [], "api_calls": 0}, - {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + return ( + {"final_response": "", "messages": [], "api_calls": 0}, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) agent_task = asyncio.ensure_future(_agent_coro()) response_id = f"resp_{uuid.uuid4().hex[:28]}" - with patch.object(api_mod.web, "StreamResponse", return_value=_DisconnectingStreamResponse()): + with patch.object( + api_mod.web, "StreamResponse", return_value=_DisconnectingStreamResponse() + ): await adapter._write_sse_responses( request=fake_request, response_id=response_id, @@ -3099,6 +3418,7 @@ class TestConfigIntegration: monkeypatch.setenv("API_SERVER_ENABLED", "true") monkeypatch.setenv("API_SERVER_KEY", "sk-mykey") from gateway.config import load_gateway_config + config = load_gateway_config() assert Platform.API_SERVER in config.platforms assert config.platforms[Platform.API_SERVER].enabled is True @@ -3106,12 +3426,14 @@ class TestConfigIntegration: def test_env_override_enabled_without_key_does_not_load(self, monkeypatch): monkeypatch.setenv("API_SERVER_ENABLED", "true") from gateway.config import load_gateway_config + config = load_gateway_config() assert Platform.API_SERVER not in config.platforms def test_env_override_with_key(self, monkeypatch): monkeypatch.setenv("API_SERVER_KEY", "sk-mykey") from gateway.config import load_gateway_config + config = load_gateway_config() assert Platform.API_SERVER in config.platforms assert config.platforms[Platform.API_SERVER].extra.get("key") == "sk-mykey" @@ -3122,6 +3444,7 @@ class TestConfigIntegration: monkeypatch.setenv("API_SERVER_PORT", "9999") monkeypatch.setenv("API_SERVER_HOST", "0.0.0.0") from gateway.config import load_gateway_config + config = load_gateway_config() assert config.platforms[Platform.API_SERVER].extra.get("port") == 9999 assert config.platforms[Platform.API_SERVER].extra.get("host") == "0.0.0.0" @@ -3134,6 +3457,7 @@ class TestConfigIntegration: "http://localhost:3000, http://127.0.0.1:3000", ) from gateway.config import load_gateway_config + config = load_gateway_config() assert config.platforms[Platform.API_SERVER].extra.get("cors_origins") == [ "http://localhost:3000", @@ -3142,7 +3466,9 @@ class TestConfigIntegration: def test_api_server_in_connected_platforms(self): config = GatewayConfig() - config.platforms[Platform.API_SERVER] = PlatformConfig(enabled=True, extra={"key": "sk-mykey"}) + config.platforms[Platform.API_SERVER] = PlatformConfig( + enabled=True, extra={"key": "opensslrandhex32strongkey"} + ) connected = config.get_connected_platforms() assert Platform.API_SERVER in connected @@ -3165,8 +3491,13 @@ class TestMultipleSystemMessages: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", json={ @@ -3290,8 +3621,13 @@ class TestGetResponse: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: # Create a response first - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) resp = await cli.post( "/v1/responses", json={"model": "hermes-agent", "input": "Hi"}, @@ -3337,8 +3673,13 @@ class TestDeleteResponse: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={"model": "hermes-agent", "input": "Hi"}, @@ -3414,8 +3755,13 @@ class TestToolCallsInOutput: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={"model": "hermes-agent", "input": "What is 6*7?"}, @@ -3444,8 +3790,13 @@ class TestToolCallsInOutput: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={"model": "hermes-agent", "input": "Hello"}, @@ -3471,7 +3822,9 @@ class TestUsageCounting: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = (mock_result, usage) resp = await cli.post( "/v1/responses", @@ -3492,7 +3845,9 @@ class TestUsageCounting: app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = (mock_result, usage) resp = await cli.post( "/v1/chat/completions", @@ -3522,16 +3877,24 @@ class TestTruncation: # Pre-seed a stored response with a long history long_history = [{"role": "user", "content": f"msg {i}"} for i in range(150)] - adapter._response_store.put("resp_prev", { - "response": {"id": "resp_prev", "object": "response"}, - "conversation_history": long_history, - "instructions": None, - }) + adapter._response_store.put( + "resp_prev", + { + "response": {"id": "resp_prev", "object": "response"}, + "conversation_history": long_history, + "instructions": None, + }, + ) app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={ @@ -3642,16 +4005,24 @@ class TestTruncation: mock_result = {"final_response": "OK", "messages": [], "api_calls": 1} long_history = [{"role": "user", "content": f"msg {i}"} for i in range(150)] - adapter._response_store.put("resp_prev2", { - "response": {"id": "resp_prev2", "object": "response"}, - "conversation_history": long_history, - "instructions": None, - }) + adapter._response_store.put( + "resp_prev2", + { + "response": {"id": "resp_prev2", "object": "response"}, + "conversation_history": long_history, + "instructions": None, + }, + ) app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", json={ @@ -3678,7 +4049,9 @@ class TestChatCompletionsAgentIncomplete: error envelope (no usable text). Issue #22496.""" @pytest.mark.asyncio - async def test_truncation_with_partial_text_uses_length_finish_reason(self, adapter): + async def test_truncation_with_partial_text_uses_length_finish_reason( + self, adapter + ): """Partial text + truncation marker → finish_reason='length', 200 OK, plus hermes extras + headers.""" mock_result = { @@ -3691,16 +4064,27 @@ class TestChatCompletionsAgentIncomplete: } app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "tell me everything"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "tell me everything"}], + }, ) assert resp.status == 200 data = await resp.json() assert data["choices"][0]["finish_reason"] == "length" - assert data["choices"][0]["message"]["content"] == "Here is part one of the answer" + assert ( + data["choices"][0]["message"]["content"] + == "Here is part one of the answer" + ) assert data["hermes"]["partial"] is True assert data["hermes"]["completed"] is False assert data["hermes"]["error_code"] == "output_truncated" @@ -3721,11 +4105,19 @@ class TestChatCompletionsAgentIncomplete: } app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hello"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hello"}], + }, ) assert resp.status == 502 @@ -3755,11 +4147,19 @@ class TestChatCompletionsAgentIncomplete: } app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "x"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "x"}], + }, ) # Hard fail: SDK clients will raise on this status assert resp.status == 502 @@ -3784,11 +4184,19 @@ class TestChatCompletionsAgentIncomplete: } app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 200 data = await resp.json() @@ -3869,7 +4277,10 @@ class TestCORS: async with TestClient(TestServer(app)) as cli: resp = await cli.get("/health", headers={"Origin": "http://localhost:3000"}) assert resp.status == 200 - assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000" + assert ( + resp.headers.get("Access-Control-Allow-Origin") + == "http://localhost:3000" + ) assert "POST" in resp.headers.get("Access-Control-Allow-Methods", "") assert "DELETE" in resp.headers.get("Access-Control-Allow-Methods", "") @@ -3887,7 +4298,9 @@ class TestCORS: }, ) assert resp.status == 200 - assert "Idempotency-Key" in resp.headers.get("Access-Control-Allow-Headers", "") + assert "Idempotency-Key" in resp.headers.get( + "Access-Control-Allow-Headers", "" + ) @pytest.mark.asyncio async def test_cors_sets_vary_origin_header(self): @@ -3913,9 +4326,13 @@ class TestCORS: }, ) assert resp.status == 200 - assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000" - assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "") - + assert ( + resp.headers.get("Access-Control-Allow-Origin") + == "http://localhost:3000" + ) + assert "Authorization" in resp.headers.get( + "Access-Control-Allow-Headers", "" + ) @pytest.mark.asyncio async def test_cors_preflight_sets_max_age(self): @@ -3932,6 +4349,8 @@ class TestCORS: ) assert resp.status == 200 assert resp.headers.get("Access-Control-Max-Age") == "600" + + # --------------------------------------------------------------------------- # Conversation parameter # --------------------------------------------------------------------------- @@ -3943,15 +4362,20 @@ class TestConversationParameter: """First request with a conversation name works (new conversation).""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( {"final_response": "Hello!", "messages": [], "api_calls": 1}, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) - resp = await cli.post("/v1/responses", json={ - "input": "hi", - "conversation": "my-chat", - }) + resp = await cli.post( + "/v1/responses", + json={ + "input": "hi", + "conversation": "my-chat", + }, + ) assert resp.status == 200 data = await resp.json() assert data["status"] == "completed" @@ -3963,36 +4387,56 @@ class TestConversationParameter: """Second request with same conversation name chains to first.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( - {"final_response": "First response", "messages": [], "api_calls": 1}, + { + "final_response": "First response", + "messages": [], + "api_calls": 1, + }, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) # First request - resp1 = await cli.post("/v1/responses", json={ - "input": "hello", - "conversation": "test-conv", - }) + resp1 = await cli.post( + "/v1/responses", + json={ + "input": "hello", + "conversation": "test-conv", + }, + ) assert resp1.status == 200 data1 = await resp1.json() resp1_id = data1["id"] # Second request — should chain mock_run.return_value = ( - {"final_response": "Second response", "messages": [], "api_calls": 1}, + { + "final_response": "Second response", + "messages": [], + "api_calls": 1, + }, {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30}, ) - resp2 = await cli.post("/v1/responses", json={ - "input": "follow up", - "conversation": "test-conv", - }) + resp2 = await cli.post( + "/v1/responses", + json={ + "input": "follow up", + "conversation": "test-conv", + }, + ) assert resp2.status == 200 # The second call should have received conversation history from the first assert mock_run.call_count == 2 second_call_kwargs = mock_run.call_args_list[1] - history = second_call_kwargs.kwargs.get("conversation_history", - second_call_kwargs[1].get("conversation_history", []) if len(second_call_kwargs) > 1 else []) + history = second_call_kwargs.kwargs.get( + "conversation_history", + second_call_kwargs[1].get("conversation_history", []) + if len(second_call_kwargs) > 1 + else [], + ) # History should be non-empty (contains messages from first response) assert len(history) > 0 @@ -4001,11 +4445,14 @@ class TestConversationParameter: """Cannot use both conversation and previous_response_id.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - resp = await cli.post("/v1/responses", json={ - "input": "hi", - "conversation": "my-chat", - "previous_response_id": "resp_abc123", - }) + resp = await cli.post( + "/v1/responses", + json={ + "input": "hi", + "conversation": "my-chat", + "previous_response_id": "resp_abc123", + }, + ) assert resp.status == 400 data = await resp.json() assert "Cannot use both" in data["error"]["message"] @@ -4015,41 +4462,58 @@ class TestConversationParameter: """Different conversation names have independent histories.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( {"final_response": "Response A", "messages": [], "api_calls": 1}, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) # Conversation A - await cli.post("/v1/responses", json={"input": "conv-a msg", "conversation": "conv-a"}) + await cli.post( + "/v1/responses", + json={"input": "conv-a msg", "conversation": "conv-a"}, + ) # Conversation B mock_run.return_value = ( {"final_response": "Response B", "messages": [], "api_calls": 1}, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) - await cli.post("/v1/responses", json={"input": "conv-b msg", "conversation": "conv-b"}) + await cli.post( + "/v1/responses", + json={"input": "conv-b msg", "conversation": "conv-b"}, + ) # They should have different response IDs in the mapping - assert adapter._response_store.get_conversation("conv-a") != adapter._response_store.get_conversation("conv-b") + assert adapter._response_store.get_conversation( + "conv-a" + ) != adapter._response_store.get_conversation("conv-b") @pytest.mark.asyncio async def test_conversation_store_false_no_mapping(self, adapter): """If store=false, conversation mapping is not updated.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( {"final_response": "Ephemeral", "messages": [], "api_calls": 1}, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) - resp = await cli.post("/v1/responses", json={ - "input": "hi", - "conversation": "ephemeral-chat", - "store": False, - }) + resp = await cli.post( + "/v1/responses", + json={ + "input": "hi", + "conversation": "ephemeral-chat", + "store": False, + }, + ) assert resp.status == 200 # Conversation mapping should NOT be set since store=false - assert adapter._response_store.get_conversation("ephemeral-chat") is None + assert ( + adapter._response_store.get_conversation("ephemeral-chat") is None + ) @pytest.mark.asyncio async def test_conversation_reuse_after_eviction_no_404(self, adapter): @@ -4057,16 +4521,21 @@ class TestConversationParameter: adapter._response_store = ResponseStore(max_size=1) app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( {"final_response": "First", "messages": [], "api_calls": 1}, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) # Create conversation -> resp stored - resp1 = await cli.post("/v1/responses", json={ - "input": "hello", - "conversation": "my-chat", - }) + resp1 = await cli.post( + "/v1/responses", + json={ + "input": "hello", + "conversation": "my-chat", + }, + ) assert resp1.status == 200 # Evict by adding another response @@ -4084,10 +4553,13 @@ class TestConversationParameter: {"final_response": "Restarted", "messages": [], "api_calls": 1}, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, ) - resp3 = await cli.post("/v1/responses", json={ - "input": "hello again", - "conversation": "my-chat", - }) + resp3 = await cli.post( + "/v1/responses", + json={ + "input": "hello again", + "conversation": "my-chat", + }, + ) assert resp3.status == 200 @@ -4103,11 +4575,19 @@ class TestSessionIdHeader: mock_result = {"final_response": "Hello!", "messages": [], "api_calls": 1} app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "Hi"}], + }, ) assert resp.status == 200 assert resp.headers.get("X-Hermes-Session-Id") is not None @@ -4124,13 +4604,24 @@ class TestSessionIdHeader: auth_adapter._session_db = mock_db app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - headers={"X-Hermes-Session-Id": "my-session-123", "Authorization": "Bearer sk-secret"}, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Continue"}]}, + headers={ + "X-Hermes-Session-Id": "my-session-123", + "Authorization": "Bearer sk-secret", + }, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "Continue"}], + }, ) assert resp.status == 200 @@ -4145,12 +4636,20 @@ class TestSessionIdHeader: (session snapshot / request dump) and escape the sessions dir.""" app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: for bad in ("../../../../etc/pwned", "/abs/path", "..\\win"): resp = await cli.post( "/v1/chat/completions", - headers={"X-Hermes-Session-Id": bad, "Authorization": "Bearer sk-secret"}, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + headers={ + "X-Hermes-Session-Id": bad, + "Authorization": "Bearer sk-secret", + }, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 400, f"{bad!r} should be rejected" # The agent is never invoked for a rejected ID. @@ -4169,12 +4668,20 @@ class TestSessionIdHeader: auth_adapter._session_db = mock_db app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - headers={"X-Hermes-Session-Id": "existing-session", "Authorization": "Bearer sk-secret"}, + headers={ + "X-Hermes-Session-Id": "existing-session", + "Authorization": "Bearer sk-secret", + }, # Request body has different history — should be ignored json={ "model": "hermes-agent", @@ -4200,14 +4707,29 @@ class TestSessionIdHeader: auth_adapter._session_db = None app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run, \ - patch("hermes_state.SessionDB", side_effect=Exception("DB unavailable")): - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with ( + patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run, + patch( + "hermes_state.SessionDB", side_effect=Exception("DB unavailable") + ), + ): + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", - headers={"X-Hermes-Session-Id": "some-session", "Authorization": "Bearer sk-secret"}, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]}, + headers={ + "X-Hermes-Session-Id": "some-session", + "Authorization": "Bearer sk-secret", + }, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "Hi"}], + }, ) assert resp.status == 200 @@ -4235,15 +4757,23 @@ class TestSessionKeyHeader: mock_result = {"final_response": "ok", "messages": [], "api_calls": 1} app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", headers={ "X-Hermes-Session-Key": "webui:user-42", "Authorization": "Bearer sk-secret", }, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 200 assert resp.headers.get("X-Hermes-Session-Key") == "webui:user-42" @@ -4259,8 +4789,13 @@ class TestSessionKeyHeader: auth_adapter._session_db = mock_db app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", headers={ @@ -4268,7 +4803,10 @@ class TestSessionKeyHeader: "X-Hermes-Session-Id": "transcript-xyz", "Authorization": "Bearer sk-secret", }, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 200 assert resp.headers.get("X-Hermes-Session-Key") == "channel-abc" @@ -4283,12 +4821,20 @@ class TestSessionKeyHeader: mock_result = {"final_response": "ok", "messages": [], "api_calls": 1} app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/chat/completions", headers={"Authorization": "Bearer sk-secret"}, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 200 assert "X-Hermes-Session-Key" not in resp.headers @@ -4303,7 +4849,10 @@ class TestSessionKeyHeader: resp = await cli.post( "/v1/chat/completions", headers={"X-Hermes-Session-Key": "whatever"}, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 403 @@ -4331,8 +4880,14 @@ class TestSessionKeyHeader: async with TestClient(TestServer(app)) as cli: resp = await cli.post( "/v1/chat/completions", - headers={"X-Hermes-Session-Key": "x" * 1000, "Authorization": "Bearer sk-secret"}, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + headers={ + "X-Hermes-Session-Key": "x" * 1000, + "Authorization": "Bearer sk-secret", + }, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 400 @@ -4344,7 +4899,10 @@ class TestSessionKeyHeader: def _fake_create_agent(**kwargs): captured_kwargs.update(kwargs) mock_agent = MagicMock() - mock_agent.run_conversation.return_value = {"final_response": "ok", "messages": []} + mock_agent.run_conversation.return_value = { + "final_response": "ok", + "messages": [], + } mock_agent.session_prompt_tokens = 0 mock_agent.session_completion_tokens = 0 mock_agent.session_total_tokens = 0 @@ -4352,18 +4910,26 @@ class TestSessionKeyHeader: app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_create_agent", side_effect=_fake_create_agent): + with patch.object( + auth_adapter, "_create_agent", side_effect=_fake_create_agent + ): resp = await cli.post( "/v1/chat/completions", headers={ "X-Hermes-Session-Key": "agent:main:webui:dm:user-7", "Authorization": "Bearer sk-secret", }, - json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]}, + json={ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status == 200 # _create_agent must be called with gateway_session_key threaded through - assert captured_kwargs.get("gateway_session_key") == "agent:main:webui:dm:user-7" + assert ( + captured_kwargs.get("gateway_session_key") + == "agent:main:webui:dm:user-7" + ) @pytest.mark.asyncio async def test_responses_endpoint_accepts_session_key(self, auth_adapter): @@ -4371,8 +4937,13 @@ class TestSessionKeyHeader: mock_result = {"final_response": "ok", "messages": [], "api_calls": 1} app = _create_app(auth_adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run: - mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + with patch.object( + auth_adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = ( + mock_result, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) resp = await cli.post( "/v1/responses", headers={ @@ -4423,7 +4994,8 @@ def _patch_create_agent_runtime(monkeypatch, captured: dict, fake_agent_cls): monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "global/model") monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}) monkeypatch.setattr( - "gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda model="": {}) + "gateway.run.GatewayRunner._load_reasoning_config", + staticmethod(lambda model="": {}), ) monkeypatch.setattr( "gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None) @@ -4434,7 +5006,9 @@ def _patch_create_agent_runtime(monkeypatch, captured: dict, fake_agent_cls): class TestModelRoutesParsing: def test_valid_routes_are_parsed(self): - routes = {"minimax-m2": {"model": "minimax/minimax-m1", "provider": "openrouter"}} + routes = { + "minimax-m2": {"model": "minimax/minimax-m1", "provider": "openrouter"} + } adapter = _make_routing_adapter(routes) assert adapter._model_routes == routes @@ -4447,13 +5021,16 @@ class TestModelRoutesParsing: assert adapter._model_routes == {} def test_route_with_non_dict_value_is_dropped(self): - adapter = _make_routing_adapter({"bad": "gpt-5", "good": {"model": "openai/gpt-5"}}) + adapter = _make_routing_adapter({ + "bad": "gpt-5", + "good": {"model": "openai/gpt-5"}, + }) assert set(adapter._model_routes) == {"good"} def test_unknown_route_keys_are_stripped(self): - adapter = _make_routing_adapter( - {"a": {"model": "m", "provider": "p", "evil_extra": "x"}} - ) + adapter = _make_routing_adapter({ + "a": {"model": "m", "provider": "p", "evil_extra": "x"} + }) assert adapter._model_routes["a"] == {"model": "m", "provider": "p"} def test_resolve_route_lookup(self): @@ -4504,23 +5081,31 @@ class TestModelRoutesModelsEndpoint: class TestModelRoutesHandlers: @pytest.mark.asyncio async def test_chat_completions_passes_route_to_run_agent(self): - routes = {"minimax-m2": {"model": "minimax/minimax-m1", "provider": "openrouter"}} + routes = { + "minimax-m2": {"model": "minimax/minimax-m1", "provider": "openrouter"} + } adapter = _make_routing_adapter(routes) app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( {"final_response": "hi", "messages": [], "api_calls": 1}, {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10}, ) - resp = await cli.post("/v1/chat/completions", json={ - "model": "minimax-m2", - "messages": [{"role": "user", "content": "hello"}], - }) + resp = await cli.post( + "/v1/chat/completions", + json={ + "model": "minimax-m2", + "messages": [{"role": "user", "content": "hello"}], + }, + ) assert resp.status == 200 kwargs = mock_run.call_args.kwargs assert kwargs.get("route") == { - "model": "minimax/minimax-m1", "provider": "openrouter", + "model": "minimax/minimax-m1", + "provider": "openrouter", } @pytest.mark.asyncio @@ -4528,15 +5113,20 @@ class TestModelRoutesHandlers: adapter = _make_routing_adapter({"minimax-m2": {"model": "minimax/minimax-m1"}}) app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( {"final_response": "hi", "messages": [], "api_calls": 1}, {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10}, ) - resp = await cli.post("/v1/chat/completions", json={ - "model": "unknown-model", - "messages": [{"role": "user", "content": "hello"}], - }) + resp = await cli.post( + "/v1/chat/completions", + json={ + "model": "unknown-model", + "messages": [{"role": "user", "content": "hello"}], + }, + ) assert resp.status == 200 assert mock_run.call_args.kwargs.get("route") is None @@ -4546,18 +5136,24 @@ class TestModelRoutesHandlers: adapter = _make_routing_adapter(routes) app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + with patch.object( + adapter, "_run_agent", new_callable=AsyncMock + ) as mock_run: mock_run.return_value = ( {"final_response": "hi", "messages": [], "api_calls": 1}, {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10}, ) - resp = await cli.post("/v1/responses", json={ - "model": "xiaozhi", - "input": "hello", - }) + resp = await cli.post( + "/v1/responses", + json={ + "model": "xiaozhi", + "input": "hello", + }, + ) assert resp.status == 200 assert mock_run.call_args.kwargs.get("route") == { - "model": "minimax/minimax-m1", "provider": "openrouter", + "model": "minimax/minimax-m1", + "provider": "openrouter", } @@ -4570,13 +5166,13 @@ class TestModelRoutesAgentCreation: captured.update(kwargs) _patch_create_agent_runtime(monkeypatch, captured, FakeAgent) - adapter = _make_routing_adapter( - {"alias": { + adapter = _make_routing_adapter({ + "alias": { "model": "minimax/minimax-m1", "api_key": "sk-route", "base_url": "https://route.example/v1", - }} - ) + } + }) monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) monkeypatch.setattr(adapter, "_session_model_override_for", lambda *_: None) @@ -4606,9 +5202,9 @@ class TestModelRoutesAgentCreation: "api_mode": "chat_completions", }, ) - adapter = _make_routing_adapter( - {"alias": {"model": "other/model", "provider": "otherprov"}} - ) + adapter = _make_routing_adapter({ + "alias": {"model": "other/model", "provider": "otherprov"} + }) monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) monkeypatch.setattr(adapter, "_session_model_override_for", lambda *_: None) @@ -4644,7 +5240,9 @@ class TestModelRoutesAgentCreation: captured.update(kwargs) _patch_create_agent_runtime(monkeypatch, captured, FakeAgent) - adapter = _make_routing_adapter({"alias": {"model": "route/model", "api_key": "sk-route"}}) + adapter = _make_routing_adapter({ + "alias": {"model": "route/model", "api_key": "sk-route"} + }) monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) monkeypatch.setattr( adapter, diff --git a/tests/gateway/test_platform_connected_checkers.py b/tests/gateway/test_platform_connected_checkers.py index ad760c9e8374..ddab17c600b5 100644 --- a/tests/gateway/test_platform_connected_checkers.py +++ b/tests/gateway/test_platform_connected_checkers.py @@ -8,7 +8,11 @@ from unittest.mock import MagicMock import pytest -from gateway.config import Platform, _PLATFORM_CONNECTED_CHECKERS, _BUILTIN_PLATFORM_VALUES +from gateway.config import ( + Platform, + _PLATFORM_CONNECTED_CHECKERS, + _BUILTIN_PLATFORM_VALUES, +) def test_all_builtins_have_checker_or_generic_token_path(): @@ -21,14 +25,17 @@ def test_all_builtins_have_checker_or_generic_token_path(): a built-in just because nobody added it to the checker dict. """ # Platforms covered by the generic token/api_key branch - generic_token_values = {p.value for p in { - Platform.TELEGRAM, - Platform.DISCORD, - Platform.SLACK, - Platform.MATRIX, - Platform.MATTERMOST, - Platform.HOMEASSISTANT, - }} + generic_token_values = { + p.value + for p in { + Platform.TELEGRAM, + Platform.DISCORD, + Platform.SLACK, + Platform.MATRIX, + Platform.MATTERMOST, + Platform.HOMEASSISTANT, + } + } # Platforms with a bespoke checker checker_values = {p.value for p in set(_PLATFORM_CONNECTED_CHECKERS.keys())} @@ -42,6 +49,7 @@ def test_all_builtins_have_checker_or_generic_token_path(): try: from hermes_cli.plugins import discover_plugins from gateway.platform_registry import platform_registry + discover_plugins() for _entry in platform_registry.all_entries(): if _entry.is_connected is not None or _entry.validate_config is not None: @@ -66,7 +74,9 @@ def test_all_builtins_have_checker_or_generic_token_path(): ) -@pytest.mark.parametrize("platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items())) +@pytest.mark.parametrize( + "platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items()) +) def test_checker_handles_minimal_config(platform, checker): """Each bespoke checker must not crash on a minimal PlatformConfig.""" mock_config = MagicMock() @@ -80,7 +90,9 @@ def test_checker_handles_minimal_config(platform, checker): assert isinstance(result, bool) -@pytest.mark.parametrize("platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items())) +@pytest.mark.parametrize( + "platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items()) +) def test_checker_returns_true_when_configured(platform, checker, monkeypatch): """Each bespoke checker must return True when the config looks valid.""" mock_config = MagicMock() @@ -99,7 +111,7 @@ def test_checker_returns_true_when_configured(platform, checker, monkeypatch): monkeypatch.setenv("TWILIO_ACCOUNT_SID", "ACtest") mock_config.extra = {} elif platform == Platform.API_SERVER: - mock_config.extra = {"key": "sk-mykey"} + mock_config.extra = {"key": "opensslrandhex32strongkey"} elif platform in { Platform.WEBHOOK, Platform.WHATSAPP, @@ -127,4 +139,28 @@ def test_checker_returns_true_when_configured(platform, checker, monkeypatch): pytest.skip(f"No synthetic config defined for {platform.value}") result = checker(mock_config) - assert result is True, f"{platform.value} checker should return True with valid-looking config" + assert result is True, ( + f"{platform.value} checker should return True with valid-looking config" + ) + + +def test_api_server_checker_key_validity(): + """Test Platform.API_SERVER checker with missing, placeholder, short, and strong keys.""" + checker = _PLATFORM_CONNECTED_CHECKERS[Platform.API_SERVER] + + # Missing + cfg = MagicMock() + cfg.extra = {} + assert checker(cfg) is False + + # Placeholder + cfg.extra = {"key": "dummy"} + assert checker(cfg) is False + + # Too short + cfg.extra = {"key": "shortkey"} + assert checker(cfg) is False + + # Strong key (>=16 chars) + cfg.extra = {"key": "opensslrandhex32strongkey"} + assert checker(cfg) is True