diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 693826920cbf..bb3bc905278c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3748,7 +3748,21 @@ def _get_cached_client( # can detect stale entries later. bound_loop = current_loop with _client_cache_lock: - if cache_key not in _client_cache: + existing = _client_cache.get(cache_key) + if existing is not None: + existing_client, _existing_default, existing_loop = existing + existing_loop_ok = not async_mode or ( + existing_loop is not None + and existing_loop is bound_loop + and not existing_loop.is_closed() + ) + if existing_loop_ok: + client, default_model, _ = existing + else: + _force_close_async_httpx(existing_client) + del _client_cache[cache_key] + existing = None + if existing is None: # Safety belt: if the cache has grown beyond the max, evict # the oldest entries (FIFO — dict preserves insertion order). while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE: @@ -3756,8 +3770,6 @@ def _get_cached_client( _force_close_async_httpx(evict_entry[0]) del _client_cache[evict_key] _client_cache[cache_key] = (client, default_model, bound_loop) - else: - client, default_model, _ = _client_cache[cache_key] return client, model or default_model diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 6daceba04a9b..e62ab858fc1d 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -608,7 +608,8 @@ agent: # - A preset like "hermes-cli" or "hermes-telegram" (curated tool set) # - A list of individual toolsets to compose your own (see list below) # -# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams, google_chat +# Supported platform keys include: cli, telegram, discord, whatsapp, slack, signal, +# api_server, web, mobile_chat, qqbot, yuanbao, cron, and plugin platforms. # # Examples: # @@ -629,6 +630,15 @@ agent: # platform_toolsets: # discord: [web, vision, skills, todo] # +# # API server client surfaces selected per request with X-Platform: +# # no header -> api_server +# # X-Platform: web -> web +# # X-Platform: mobile_chat -> mobile_chat +# platform_toolsets: +# api_server: [hermes-api-server] +# web: [web, vision, skills, todo, memory, session_search] +# mobile_chat: [] +# # If not set, defaults are: # cli: hermes-cli (everything + cronjob management) # telegram: hermes-telegram (terminal, file, web, vision, image, tts, browser, skills, todo, cronjob, messaging) @@ -636,6 +646,9 @@ agent: # whatsapp: hermes-whatsapp (same as telegram) # slack: hermes-slack (same as telegram) # signal: hermes-signal (same as telegram) +# api_server: hermes-api-server (full HTTP agent tool surface) +# web: hermes-web (browser-facing client surface, no terminal/file tools) +# mobile_chat: hermes-mobile-chat (lightweight API chat, no tools by default) # homeassistant: hermes-homeassistant (same as telegram) # qqbot: hermes-qqbot (same as telegram) # teams: hermes-teams (same as telegram) @@ -648,6 +661,9 @@ platform_toolsets: whatsapp: [hermes-whatsapp] slack: [hermes-slack] signal: [hermes-signal] + api_server: [hermes-api-server] + web: [hermes-web] + mobile_chat: [hermes-mobile-chat] homeassistant: [hermes-homeassistant] qqbot: [hermes-qqbot] yuanbao: [hermes-yuanbao] @@ -702,6 +718,9 @@ platform_toolsets: # hermes-discord - Same as hermes-telegram # hermes-whatsapp - Same as hermes-telegram # hermes-slack - Same as hermes-telegram +# hermes-api-server - Full API-server tool surface, excluding interactive UI tools +# hermes-web - Web/API client surface without local execution or file-system tools +# hermes-mobile-chat - No-tool lightweight chat surface for constrained clients # # COMPOSITE: # debugging - terminal + web + file diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 357ecbd47851..3b9f4e93105e 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -61,6 +61,45 @@ CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0 MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array +API_PLATFORM_HEADER = "X-Platform" +API_PLATFORM_ALT_HEADER = "X-Hermes-Platform" +API_PLATFORM_RESPONSE_HEADER = "X-Hermes-Platform" +API_SELECTABLE_PLATFORMS = frozenset({"api_server", "web", "mobile_chat"}) + + +def _known_api_platforms() -> set[str]: + """Return API-server client surfaces accepted by the platform header. + + The API server intentionally does not accept every Hermes platform key here. + Messaging/CLI platform profiles may include tools or UX assumptions that are + unsafe or nonsensical for an HTTP API client. Keep this list explicit until + Hermes has a first-class registry flag for API-selectable surfaces. + """ + try: + from hermes_cli.platforms import get_all_platforms + platforms = set(get_all_platforms().keys()) + except Exception: + platforms = {"api_server"} + return platforms.intersection(API_SELECTABLE_PLATFORMS) + + +def _normalize_api_platform(raw_value: Any) -> str: + """Normalize and validate an API client platform/surface selector. + + A missing selector preserves the historical API-server behavior. An + explicit but unknown or non-API selector is rejected instead of silently + falling back to the full API-server tool surface; that is safer for + constrained clients such as browser/mobile chat UIs where a typo should not + accidentally grant terminal/file tools. + """ + value = str(raw_value or "").strip().lower() + if not value: + return "api_server" + if re.search(r"[\r\n\x00]", value): + raise ValueError("Invalid API platform selector") + if value not in _known_api_platforms(): + raise ValueError(f"Unknown API platform selector: {value}") + return value def _coerce_port(value: Any, default: int = DEFAULT_PORT) -> int: @@ -404,7 +443,14 @@ def __len__(self) -> int: _CORS_HEADERS = { "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key", + "Access-Control-Allow-Headers": ( + "Authorization, Content-Type, Idempotency-Key, " + "X-Platform, X-Hermes-Platform, " + "X-Hermes-Session-Id, X-Hermes-Session-Key" + ), + "Access-Control-Expose-Headers": ( + "X-Hermes-Platform, X-Hermes-Session-Id, X-Hermes-Session-Key" + ), } @@ -795,6 +841,16 @@ def _ensure_session_db(self): # Agent creation helper # ------------------------------------------------------------------ + def _request_platform_or_error(self, request: "web.Request") -> tuple[Optional[str], Optional["web.Response"]]: + """Resolve the requested API client platform/surface from headers.""" + raw_platform = request.headers.get(API_PLATFORM_HEADER) + if raw_platform is None: + raw_platform = request.headers.get(API_PLATFORM_ALT_HEADER) + try: + return _normalize_api_platform(raw_platform), None + except ValueError as exc: + return None, web.json_response(_openai_error(str(exc)), status=400) + def _create_agent( self, ephemeral_system_prompt: Optional[str] = None, @@ -804,14 +860,15 @@ def _create_agent( tool_start_callback=None, tool_complete_callback=None, gateway_session_key: Optional[str] = None, + api_platform: str = "api_server", ) -> Any: """ Create an AIAgent instance using the gateway's runtime config. Uses _resolve_runtime_agent_kwargs() to pick up model, api_key, base_url, etc. from config.yaml / env vars. Toolsets are resolved - from config.yaml platform_toolsets.api_server (same as all other - gateway platforms), falling back to the hermes-api-server default. + from config.yaml platform_toolsets. (same as all other + gateway platforms), falling back to that platform's default toolset. ``gateway_session_key`` is a stable per-channel identifier supplied by the client (via ``X-Hermes-Session-Key``). Unlike ``session_id`` @@ -829,7 +886,8 @@ def _create_agent( model = _resolve_gateway_model() user_config = _load_gateway_config() - enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server")) + selected_platform = _normalize_api_platform(api_platform) + enabled_toolsets = sorted(_get_platform_tools(user_config, selected_platform)) max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) @@ -846,7 +904,7 @@ def _create_agent( ephemeral_system_prompt=ephemeral_system_prompt or None, enabled_toolsets=enabled_toolsets, session_id=session_id, - platform="api_server", + platform=selected_platform, stream_delta_callback=stream_delta_callback, tool_progress_callback=tool_progress_callback, tool_start_callback=tool_start_callback, @@ -951,6 +1009,10 @@ async def _handle_capabilities(self, request: "web.Request") -> "web.Response": "approval_events": True, "session_continuity_header": "X-Hermes-Session-Id", "session_key_header": "X-Hermes-Session-Key", + "api_platform_selection": True, + "api_platform_headers": [API_PLATFORM_HEADER, API_PLATFORM_ALT_HEADER], + "api_platform_response_header": API_PLATFORM_RESPONSE_HEADER, + "api_platforms": sorted(_known_api_platforms()), "cors": bool(self._cors_origins), }, "endpoints": { @@ -973,6 +1035,10 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons if auth_err: return auth_err + api_platform, platform_err = self._request_platform_or_error(request) + if platform_err is not None: + return platform_err + # Parse request body try: body = await request.json() @@ -1167,12 +1233,14 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul tool_complete_callback=_on_tool_complete, agent_ref=agent_ref, gateway_session_key=gateway_session_key, + api_platform=api_platform, )) return await self._write_sse_chat_completion( request, completion_id, model_name, created, _stream_q, agent_task, agent_ref, session_id=session_id, gateway_session_key=gateway_session_key, + api_platform=api_platform, ) # Non-streaming: run the agent (with optional Idempotency-Key) @@ -1183,11 +1251,17 @@ async def _compute_completion(): ephemeral_system_prompt=system_prompt, session_id=session_id, gateway_session_key=gateway_session_key, + api_platform=api_platform, ) idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: - fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"]) + fp_body = dict(body) + fp_body["__hermes_api_platform"] = api_platform + fp = _make_request_fingerprint( + fp_body, + keys=["model", "messages", "tools", "tool_choice", "stream", "__hermes_api_platform"], + ) try: result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion) except Exception as e: @@ -1224,6 +1298,7 @@ async def _compute_completion(): response_headers = { "X-Hermes-Session-Id": result.get("session_id", session_id), + API_PLATFORM_RESPONSE_HEADER: api_platform, } if gateway_session_key: response_headers["X-Hermes-Session-Key"] = gateway_session_key @@ -1288,7 +1363,7 @@ async def _compute_completion(): async def _write_sse_chat_completion( self, request: "web.Request", completion_id: str, model: str, created: int, stream_q, agent_task, agent_ref=None, session_id: str = None, - gateway_session_key: str = None, + gateway_session_key: str = None, api_platform: str = "api_server", ) -> "web.StreamResponse": """Write real streaming SSE from agent's stream_delta_callback queue. @@ -1311,6 +1386,7 @@ async def _write_sse_chat_completion( sse_headers.update(cors) if session_id: sse_headers["X-Hermes-Session-Id"] = session_id + sse_headers[API_PLATFORM_RESPONSE_HEADER] = api_platform if gateway_session_key: sse_headers["X-Hermes-Session-Key"] = gateway_session_key response = web.StreamResponse(status=200, headers=sse_headers) @@ -1453,6 +1529,7 @@ async def _write_sse_responses( store: bool, session_id: str, gateway_session_key: Optional[str] = None, + api_platform: str = "api_server", ) -> "web.StreamResponse": """Write an SSE stream for POST /v1/responses (OpenAI Responses API). @@ -1495,6 +1572,7 @@ async def _write_sse_responses( sse_headers.update(cors) if session_id: sse_headers["X-Hermes-Session-Id"] = session_id + sse_headers[API_PLATFORM_RESPONSE_HEADER] = api_platform if gateway_session_key: sse_headers["X-Hermes-Session-Key"] = gateway_session_key response = web.StreamResponse(status=200, headers=sse_headers) @@ -2039,6 +2117,10 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response": if auth_err: return auth_err + api_platform, platform_err = self._request_platform_or_error(request) + if platform_err is not None: + return platform_err + # Long-term memory scope header (see chat_completions for details). gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: @@ -2196,6 +2278,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul tool_complete_callback=_on_tool_complete, agent_ref=agent_ref, gateway_session_key=gateway_session_key, + api_platform=api_platform, )) response_id = f"resp_{uuid.uuid4().hex[:28]}" @@ -2217,6 +2300,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul store=store, session_id=session_id, gateway_session_key=gateway_session_key, + api_platform=api_platform, ) async def _compute_response(): @@ -2226,13 +2310,19 @@ async def _compute_response(): ephemeral_system_prompt=instructions, session_id=session_id, gateway_session_key=gateway_session_key, + api_platform=api_platform, ) idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: + fp_body = dict(body) + fp_body["__hermes_api_platform"] = api_platform fp = _make_request_fingerprint( - body, - keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"], + fp_body, + keys=[ + "input", "instructions", "previous_response_id", "conversation", + "model", "tools", "__hermes_api_platform", + ], ) try: result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response) @@ -2305,7 +2395,10 @@ async def _compute_response(): if conversation: self._response_store.set_conversation(conversation, response_id) - response_headers = {"X-Hermes-Session-Id": session_id} + response_headers = { + "X-Hermes-Session-Id": session_id, + API_PLATFORM_RESPONSE_HEADER: api_platform, + } if gateway_session_key: response_headers["X-Hermes-Session-Key"] = gateway_session_key return web.json_response(response_data, headers=response_headers) @@ -2684,6 +2777,7 @@ async def _run_agent( tool_complete_callback=None, agent_ref: Optional[list] = None, gateway_session_key: Optional[str] = None, + api_platform: str = "api_server", ) -> tuple: """ Create an agent and run a conversation in a thread executor. @@ -2707,6 +2801,7 @@ def _run(): tool_start_callback=tool_start_callback, tool_complete_callback=tool_complete_callback, gateway_session_key=gateway_session_key, + api_platform=api_platform, ) if agent_ref is not None: agent_ref[0] = agent @@ -2806,6 +2901,10 @@ async def _handle_runs(self, request: "web.Request") -> "web.Response": if auth_err: return auth_err + api_platform, platform_err = self._request_platform_or_error(request) + if platform_err is not None: + return platform_err + # Long-term memory scope header (see chat_completions for details). gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: @@ -2911,6 +3010,7 @@ def _text_cb(delta: Optional[str]) -> None: created_at=created_at, session_id=session_id, model=body.get("model", self._model_name), + api_platform=api_platform, ) async def _run_and_close(): @@ -2922,6 +3022,7 @@ async def _run_and_close(): stream_delta_callback=_text_cb, tool_progress_callback=event_cb, gateway_session_key=gateway_session_key, + api_platform=api_platform, ) self._active_run_agents[run_id] = agent @@ -3087,9 +3188,9 @@ def _run_sync(): if hasattr(task, "add_done_callback"): task.add_done_callback(self._background_tasks.discard) - response_headers = ( - {"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {} - ) + response_headers = {API_PLATFORM_RESPONSE_HEADER: api_platform} + if gateway_session_key: + response_headers["X-Hermes-Session-Key"] = gateway_session_key return web.json_response( {"run_id": run_id, "status": "started"}, status=202, diff --git a/gateway/run.py b/gateway/run.py index d685e9849aa8..29510098c1d9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8235,7 +8235,7 @@ def _check_slash_access( if not canonical_cmd: return None - policy = _policy_for_source(self.config, source) + policy = _policy_for_source(getattr(self, "config", {}), source) if not policy.enabled or policy.can_run(source.user_id, canonical_cmd): return None logger.info( @@ -10074,7 +10074,21 @@ async def _deliver_media_from_response( _, cleaned = adapter.extract_images(response) local_files, _ = adapter.extract_local_files(cleaned) - _thread_meta = self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event)) + thread_metadata = getattr( + self, + "_thread_metadata_for_source", + lambda source, reply_to_message_id=None: GatewayRunner._thread_metadata_for_source( + None, + source, + reply_to_message_id, + ), + ) + reply_anchor_for_event = getattr( + self, + "_reply_anchor_for_event", + lambda _event: None, + ) + _thread_meta = thread_metadata(event.source, reply_anchor_for_event(event)) from gateway.platforms.base import should_send_media_as_audio diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 46907592d173..c2d7875811c0 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -397,6 +397,7 @@ def _matches_current_profile(command: str) -> bool: # Try /proc first (works in Docker without procps installed), # fall back to ps -A eww. _found_via_proc = False + _proc_read_error = False if os.path.isdir("/proc"): try: my_pid = os.getpid() @@ -414,12 +415,13 @@ def _matches_current_profile(command: str) -> bool: ): _append_unique_pid(pids, pid, exclude_pids) except (OSError, PermissionError): + _proc_read_error = True continue _found_via_proc = True except Exception: pass - if not _found_via_proc: + if not _found_via_proc or (not pids and not _proc_read_error): result = subprocess.run( ["ps", "-A", "eww", "-o", "pid=,command="], capture_output=True, diff --git a/hermes_cli/platforms.py b/hermes_cli/platforms.py index e341b734ee10..3dcdf51c1d96 100644 --- a/hermes_cli/platforms.py +++ b/hermes_cli/platforms.py @@ -39,6 +39,8 @@ class PlatformInfo(NamedTuple): ("yuanbao", PlatformInfo(label="🤖 Yuanbao", default_toolset="hermes-yuanbao")), ("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")), ("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")), + ("web", PlatformInfo(label="🌐 Web API Client", default_toolset="hermes-web")), + ("mobile_chat", PlatformInfo(label="📱 Mobile Chat", default_toolset="hermes-mobile-chat")), ("cron", PlatformInfo(label="⏰ Cron", default_toolset="hermes-cron")), ]) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 96b3d4e3be5e..a06a2f822a55 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -96,6 +96,13 @@ "discord_admin": {"discord"}, } +# Platforms in this set must only receive the toolsets explicitly selected for +# that platform. They do not inherit default-enabled plugin toolsets or global +# MCP servers. This keeps constrained API-client surfaces such as mobile chat +# genuinely lightweight by default while still allowing the server operator to +# opt in specific plugin/MCP toolsets via platform_toolsets.. +_NO_DEFAULT_AUGMENTED_TOOL_PLATFORMS: Set[str] = {"mobile_chat"} + def _toolset_allowed_for_platform(ts_key: str, platform: str) -> bool: """Return True if ``ts_key`` is configurable on ``platform``. @@ -986,6 +993,7 @@ def _get_platform_tools( platform_toolsets = config.get("platform_toolsets") or {} toolset_names = platform_toolsets.get(platform) + inherit_default_augmented_toolsets = platform not in _NO_DEFAULT_AUGMENTED_TOOL_PLATFORMS if toolset_names is None or not isinstance(toolset_names, list): plat_info = PLATFORMS.get(platform) @@ -1131,6 +1139,9 @@ def _get_platform_tools( elif pts in _DEFAULT_OFF_TOOLSETS: # Opt-in plugin toolset — stay off until user picks it continue + elif not inherit_default_augmented_toolsets: + # Constrained platform — no default plugin inheritance + continue elif pts not in known_for_platform: # New plugin not yet seen by hermes tools — default enabled enabled_toolsets.add(pts) @@ -1164,7 +1175,7 @@ def _get_platform_tools( else: explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers) - if include_default_mcp_servers: + if include_default_mcp_servers and inherit_default_augmented_toolsets: if explicit_mcp_servers or "no_mcp" in toolset_names: enabled_toolsets.update(explicit_mcp_servers) else: diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 76b14e317934..d92c652d13cd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -221,6 +221,13 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None runner._emit_gateway_run_progress = AsyncMock() + # These e2e tests exercise command dispatch/reset behavior, not the + # destructive-slash confirmation gate. Real GatewayRunner instances read + # this from config.yaml; object.__new__ fixtures must pin it explicitly so + # /new reaches _handle_reset_command instead of stopping at a confirm prompt. + runner._read_user_config = MagicMock( + return_value={"approvals": {"destructive_slash_confirm": False}} + ) runner.pairing_store = MagicMock() runner.pairing_store._is_rate_limited = MagicMock(return_value=False) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 9e00a3758712..47a726bbb807 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -201,6 +201,18 @@ async def compute(): class TestAdapterInit: + def test_cors_allow_headers_include_api_platform_and_session_headers(self): + allow_headers = _CORS_HEADERS["Access-Control-Allow-Headers"] + assert "X-Platform" in allow_headers + assert "X-Hermes-Platform" in allow_headers + assert "X-Hermes-Session-Id" in allow_headers + assert "X-Hermes-Session-Key" in allow_headers + + expose_headers = _CORS_HEADERS["Access-Control-Expose-Headers"] + assert "X-Hermes-Platform" in expose_headers + assert "X-Hermes-Session-Id" in expose_headers + assert "X-Hermes-Session-Key" in expose_headers + def test_default_config(self): config = PlatformConfig(enabled=True) adapter = APIServerAdapter(config) @@ -361,6 +373,7 @@ def _create_app(adapter: APIServerAdapter) -> web.Application: 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_post("/v1/runs", adapter._handle_runs) return app @@ -595,6 +608,14 @@ async def test_capabilities_advertises_plugin_safe_contract(self, adapter): 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"]["api_platform_selection"] is True + assert "X-Platform" in data["features"]["api_platform_headers"] + assert data["features"]["api_platform_response_header"] == "X-Hermes-Platform" + assert "api_server" in data["features"]["api_platforms"] + assert "web" in data["features"]["api_platforms"] + assert "mobile_chat" in data["features"]["api_platforms"] + assert "cli" not in data["features"]["api_platforms"] + assert "telegram" not in data["features"]["api_platforms"] assert data["endpoints"]["run_status"]["path"] == "/v1/runs/{run_id}" @pytest.mark.asyncio @@ -648,6 +669,79 @@ async def test_empty_messages_returns_400(self, adapter): resp = await cli.post("/v1/chat/completions", json={"model": "test", "messages": []}) assert resp.status == 400 + @pytest.mark.asyncio + async def test_chat_completions_passes_requested_platform(self, adapter): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): + assert kwargs["api_platform"] == "mobile_chat" + return ( + {"final_response": "ok", "messages": [], "api_calls": 1, "session_id": "sid-1"}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + + with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent): + resp = await cli.post( + "/v1/chat/completions", + headers={"X-Platform": "mobile_chat"}, + json={"model": "test", "messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status == 200 + assert resp.headers.get("X-Hermes-Platform") == "mobile_chat" + data = await resp.json() + assert data["choices"][0]["message"]["content"] == "ok" + + @pytest.mark.asyncio + async def test_unknown_platform_returns_400(self, adapter): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/v1/chat/completions", + headers={"X-Platform": "not-a-platform"}, + json={"model": "test", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 400 + data = await resp.json() + assert "Unknown API platform selector" in data["error"]["message"] + + @pytest.mark.asyncio + async def test_idempotency_key_is_scoped_by_requested_platform(self, adapter, monkeypatch): + import gateway.platforms.api_server as api_server_mod + + monkeypatch.setattr(api_server_mod, "_idem_cache", _IdempotencyCache()) + app = _create_app(adapter) + body = {"model": "test", "messages": [{"role": "user", "content": "hi"}]} + + async def _mock_run_agent(**kwargs): + platform = kwargs["api_platform"] + return ( + {"final_response": platform, "messages": [], "api_calls": 1, "session_id": f"sid-{platform}"}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.side_effect = _mock_run_agent + first = await cli.post( + "/v1/chat/completions", + headers={"Idempotency-Key": "same-key", "X-Platform": "mobile_chat"}, + json=body, + ) + second = await cli.post( + "/v1/chat/completions", + headers={"Idempotency-Key": "same-key", "X-Platform": "web"}, + json=body, + ) + first_data = await first.json() + second_data = await second.json() + + assert first.status == 200 + assert second.status == 200 + assert first_data["choices"][0]["message"]["content"] == "mobile_chat" + assert second_data["choices"][0]["message"]["content"] == "web" + assert mock_run.await_count == 2 + @pytest.mark.asyncio async def test_stream_true_returns_sse(self, adapter): """stream=true returns SSE format with the full response.""" @@ -1253,6 +1347,7 @@ async def test_successful_response_with_string_input(self, adapter): mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) resp = await cli.post( "/v1/responses", + headers={"X-Platform": "web"}, json={ "model": "hermes-agent", "input": "What is the capital of France?", @@ -1260,6 +1355,8 @@ async def test_successful_response_with_string_input(self, adapter): ) assert resp.status == 200 + assert resp.headers.get("X-Hermes-Platform") == "web" + assert mock_run.call_args.kwargs["api_platform"] == "web" data = await resp.json() assert data["object"] == "response" assert data["id"].startswith("resp_") @@ -1295,6 +1392,43 @@ async def test_successful_response_with_array_input(self, adapter): assert call_kwargs["user_message"] == "What is 2+2?" assert len(call_kwargs["conversation_history"]) == 1 + @pytest.mark.asyncio + async def test_responses_idempotency_key_is_scoped_by_requested_platform(self, adapter, monkeypatch): + import gateway.platforms.api_server as api_server_mod + + monkeypatch.setattr(api_server_mod, "_idem_cache", _IdempotencyCache()) + app = _create_app(adapter) + body = {"model": "hermes-agent", "input": "Hello"} + + async def _mock_run_agent(**kwargs): + platform = kwargs["api_platform"] + return ( + {"final_response": platform, "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.side_effect = _mock_run_agent + first = await cli.post( + "/v1/responses", + headers={"Idempotency-Key": "same-key", "X-Platform": "mobile_chat"}, + json=body, + ) + second = await cli.post( + "/v1/responses", + headers={"Idempotency-Key": "same-key", "X-Platform": "web"}, + json=body, + ) + first_data = await first.json() + second_data = await second.json() + + assert first.status == 200 + assert second.status == 200 + assert first_data["output"][0]["content"][0]["text"] == "mobile_chat" + assert second_data["output"][0]["content"][0]["text"] == "web" + assert mock_run.await_count == 2 + @pytest.mark.asyncio async def test_instructions_as_ephemeral_prompt(self, adapter): """The instructions field maps to ephemeral_system_prompt.""" diff --git a/tests/gateway/test_api_server_runs.py b/tests/gateway/test_api_server_runs.py index bdb00d74a7ba..8f70a10975cb 100644 --- a/tests/gateway/test_api_server_runs.py +++ b/tests/gateway/test_api_server_runs.py @@ -153,6 +153,38 @@ async def test_start_empty_input_returns_400(self, adapter): resp = await cli.post("/v1/runs", json={"input": ""}) assert resp.status == 400 + @pytest.mark.asyncio + async def test_start_passes_requested_platform(self, adapter): + app = _create_runs_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_create_agent") as mock_create: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "done"} + mock_agent.session_prompt_tokens = 0 + mock_agent.session_completion_tokens = 0 + mock_agent.session_total_tokens = 0 + mock_create.return_value = mock_agent + + resp = await cli.post( + "/v1/runs", + headers={"X-Platform": "mobile_chat"}, + json={"input": "hello"}, + ) + assert resp.status == 202 + assert resp.headers.get("X-Hermes-Platform") == "mobile_chat" + data = await resp.json() + assert data["status"] == "started" + + for _ in range(20): + if mock_create.called: + break + await asyncio.sleep(0.01) + + assert mock_create.call_args.kwargs["api_platform"] == "mobile_chat" + status_resp = await cli.get(f"/v1/runs/{data['run_id']}") + status = await status_resp.json() + assert status["api_platform"] == "mobile_chat" + @pytest.mark.asyncio async def test_start_invalid_history_does_not_allocate_run(self, adapter): app = _create_runs_app(adapter) diff --git a/tests/gateway/test_api_server_toolset.py b/tests/gateway/test_api_server_toolset.py index 943d867e6132..ad21edc3e8dd 100644 --- a/tests/gateway/test_api_server_toolset.py +++ b/tests/gateway/test_api_server_toolset.py @@ -59,6 +59,45 @@ def test_toolset_excludes_text_to_speech(self): tools = resolve_toolset("hermes-api-server") assert "text_to_speech" not in tools + def test_web_toolset_excludes_local_execution_tools(self): + tools = resolve_toolset("hermes-web") + assert "web_search" in tools + assert "vision_analyze" in tools + assert "terminal" not in tools + assert "execute_code" not in tools + assert "read_file" not in tools + + def test_mobile_chat_toolset_is_no_tool_default(self): + assert resolve_toolset("hermes-mobile-chat") == [] + + def test_mobile_chat_platform_does_not_inherit_default_plugins_or_mcp(self, monkeypatch): + from hermes_cli import tools_config + + monkeypatch.setattr(tools_config, "_get_plugin_toolset_keys", lambda: {"plugin_default"}) + config = { + "mcp_servers": { + "global_mcp": {"enabled": True}, + } + } + + assert tools_config._get_platform_tools(config, "mobile_chat") == set() + + def test_mobile_chat_platform_allows_explicit_plugin_and_mcp(self, monkeypatch): + from hermes_cli import tools_config + + monkeypatch.setattr(tools_config, "_get_plugin_toolset_keys", lambda: {"plugin_default"}) + config = { + "platform_toolsets": { + "mobile_chat": ["plugin_default", "explicit_mcp"], + }, + "mcp_servers": { + "explicit_mcp": {"enabled": True}, + "global_mcp": {"enabled": True}, + }, + } + + assert tools_config._get_platform_tools(config, "mobile_chat") == {"plugin_default", "explicit_mcp"} + class TestApiServerPlatformConfig: def test_platforms_dict_includes_api_server(self): @@ -66,6 +105,11 @@ def test_platforms_dict_includes_api_server(self): assert "api_server" in PLATFORMS assert PLATFORMS["api_server"]["default_toolset"] == "hermes-api-server" + def test_platforms_dict_includes_api_client_surfaces(self): + from hermes_cli.tools_config import PLATFORMS + assert PLATFORMS["web"]["default_toolset"] == "hermes-web" + assert PLATFORMS["mobile_chat"]["default_toolset"] == "hermes-mobile-chat" + class TestApiServerAdapterToolset: @patch("gateway.platforms.api_server.AIOHTTP_AVAILABLE", True) @@ -127,3 +171,49 @@ def test_create_agent_respects_config_override(self): call_kwargs = mock_agent_cls.call_args toolsets = call_kwargs.kwargs.get("enabled_toolsets") assert sorted(toolsets) == ["terminal", "web"] + + @patch("gateway.platforms.api_server.AIOHTTP_AVAILABLE", True) + def test_create_agent_respects_requested_mobile_chat_platform(self): + """API clients can request a server-known platform before AIAgent construction.""" + from gateway.platforms.api_server import APIServerAdapter + from gateway.config import PlatformConfig + + adapter = APIServerAdapter(PlatformConfig()) + + with patch("gateway.run._resolve_runtime_agent_kwargs") as mock_kwargs, \ + patch("gateway.run._resolve_gateway_model") as mock_model, \ + patch("gateway.run._load_gateway_config") as mock_config, \ + patch("run_agent.AIAgent") as mock_agent_cls: + + mock_kwargs.return_value = {"api_key": "***", "base_url": None, + "provider": None, "api_mode": None, + "command": None, "args": []} + mock_model.return_value = "test/model" + mock_config.return_value = {"platform_toolsets": {"mobile_chat": []}} + mock_agent_cls.return_value = MagicMock() + + adapter._create_agent(api_platform="mobile_chat") + + mock_agent_cls.assert_called_once() + call_kwargs = mock_agent_cls.call_args + assert call_kwargs.kwargs.get("platform") == "mobile_chat" + assert call_kwargs.kwargs.get("enabled_toolsets") == [] + + def test_normalize_api_platform_accepts_known_platforms_only(self): + from gateway.platforms.api_server import _normalize_api_platform + + assert _normalize_api_platform(None) == "api_server" + assert _normalize_api_platform(" mobile_chat ") == "mobile_chat" + assert _normalize_api_platform("web") == "web" + + with pytest.raises(ValueError): + _normalize_api_platform("cli") + + with pytest.raises(ValueError): + _normalize_api_platform("telegram") + + with pytest.raises(ValueError): + _normalize_api_platform("terminal") + + with pytest.raises(ValueError): + _normalize_api_platform("mobile_chat\nX-Evil: yes") diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c53e34b757e1..227980d63d5e 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -4,6 +4,8 @@ from unittest.mock import patch from gateway.config import ( + DEFAULT_STREAMING_BUFFER_THRESHOLD, + DEFAULT_STREAMING_EDIT_INTERVAL, GatewayConfig, HomeChannel, Platform, @@ -176,8 +178,8 @@ def test_from_dict_malformed_numeric_values_fall_back_to_defaults(self): "fresh_final_after_seconds": "oops", } ) - assert restored.edit_interval == 1.0 - assert restored.buffer_threshold == 40 + assert restored.edit_interval == DEFAULT_STREAMING_EDIT_INTERVAL + assert restored.buffer_threshold == DEFAULT_STREAMING_BUFFER_THRESHOLD assert restored.fresh_final_after_seconds == 60.0 @@ -314,6 +316,8 @@ def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("API_SERVER_ENABLED", raising=False) + monkeypatch.delenv("API_SERVER_KEY", raising=False) config = load_gateway_config() diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index 0ef37deb3ee4..2e8fa4dfbead 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -130,7 +130,10 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen adapter.send_document.assert_awaited_once_with( chat_id="chat-1", file_path="/tmp/speech.flac", - metadata={"thread_id": "topic-1"}, + metadata={ + "thread_id": "topic-1", + "telegram_dm_topic_reply_fallback": True, + }, ) adapter.send_voice.assert_not_awaited() @@ -159,7 +162,10 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc adapter.send_document.assert_awaited_once_with( chat_id="chat-1", file_path="/tmp/speech.ogg", - metadata={"thread_id": "topic-1"}, + metadata={ + "thread_id": "topic-1", + "telegram_dm_topic_reply_fallback": True, + }, ) adapter.send_voice.assert_not_awaited() @@ -190,6 +196,9 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender( adapter.send_voice.assert_awaited_once_with( chat_id="chat-1", audio_path="/tmp/speech.mp3", - metadata={"thread_id": "topic-1"}, + metadata={ + "thread_id": "topic-1", + "telegram_dm_topic_reply_fallback": True, + }, ) adapter.send_document.assert_not_awaited() diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index d6debebae599..d5c9efc8cad0 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -129,7 +129,7 @@ async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): @pytest.mark.asyncio async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch): - """When tool_progress is not in config, defaults to 'all' then cycles to verbose.""" + """When tool_progress is not in config, uses platform default then cycles.""" hermes_home = tmp_path / "hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" @@ -143,17 +143,17 @@ async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeyp runner = _make_runner() result = await runner._handle_verbose_command(_make_event()) - # Telegram default is "all" (high tier) → cycles to verbose - assert "VERBOSE" in result + # Telegram default is "new" → cycles to all. + assert "ALL" in result saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose" + assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all" @pytest.mark.asyncio async def test_per_platform_isolation(self, tmp_path, monkeypatch): """Cycling /verbose on Telegram doesn't change Slack's setting. Without a global tool_progress, each platform uses its built-in - default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default). + default: Telegram = 'new', Slack = 'off' (quiet Slack default). """ hermes_home = tmp_path / "hermes" hermes_home.mkdir() @@ -178,8 +178,8 @@ async def test_per_platform_isolation(self, tmp_path, monkeypatch): saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) platforms = saved["display"]["platforms"] - # Telegram: all -> verbose (high tier default = all) - assert platforms["telegram"]["tool_progress"] == "verbose" + # Telegram: new -> all + assert platforms["telegram"]["tool_progress"] == "all" # Slack: off -> new (first /verbose cycle from quiet default) assert platforms["slack"]["tool_progress"] == "new" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 4d177f92b385..7ffbd733c91e 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1856,17 +1856,16 @@ def test_plugin_route_requires_auth(self): def test_plugin_route_allows_auth(self): """Plugin API routes should work with a valid session token. - Use ``/api/plugins/example/hello`` from the example-dashboard plugin — - a stable, side-effect-free GET that's always loaded in tests. With a - valid token the handler should run (200); without one the middleware - should 401 before the handler is reached. + Use the kanban board route because the kanban plugin is always loaded + in tests. With a valid token the handler should run (200); without one + the middleware should 401 before the handler is reached. """ # Without auth: middleware blocks before reaching the handler. - resp = self.client.get("/api/plugins/example/hello") + resp = self.client.get("/api/plugins/kanban/board") assert resp.status_code == 401 # With auth: handler runs. - resp = self.auth_client.get("/api/plugins/example/hello") + resp = self.auth_client.get("/api/plugins/kanban/board") assert resp.status_code == 200 def test_plugin_post_requires_auth(self): diff --git a/tests/run_agent/test_async_httpx_del_neuter.py b/tests/run_agent/test_async_httpx_del_neuter.py index e616ea23acb2..66156082f1a6 100644 --- a/tests/run_agent/test_async_httpx_del_neuter.py +++ b/tests/run_agent/test_async_httpx_del_neuter.py @@ -103,7 +103,7 @@ def test_removes_stale_entries(self): mock_client._client = MagicMock() mock_client._client.is_closed = False - key = ("test_stale", True, "", "", "", (), False) + key = ("test_stale", True, "", "", "", (), False, "") with _client_cache_lock: _client_cache[key] = (mock_client, "test-model", loop) @@ -127,7 +127,7 @@ def test_keeps_live_entries(self): loop = asyncio.new_event_loop() # NOT closed mock_client = MagicMock() - key = ("test_live", True, "", "", "", (), False) + key = ("test_live", True, "", "", "", (), False, "") with _client_cache_lock: _client_cache[key] = (mock_client, "test-model", loop) @@ -149,7 +149,7 @@ def test_keeps_entries_without_loop(self): ) mock_client = MagicMock() - key = ("test_sync", False, "", "", "", (), False) + key = ("test_sync", False, "", "", "", (), False, "") with _client_cache_lock: _client_cache[key] = (mock_client, "test-model", None) @@ -182,7 +182,7 @@ def test_same_key_replaces_stale_loop_entry(self): _get_cached_client, ) - key = ("test_replace", True, "", "", "", (), False) + key = ("test_replace", True, "", "", "", (), False, "") # Simulate a stale entry from a closed loop old_loop = asyncio.new_event_loop() @@ -217,7 +217,7 @@ def test_different_loops_do_not_grow_cache(self): _client_cache_lock, ) - key = ("test_no_grow", True, "", "", "", (), False) + key = ("test_no_grow", True, "", "", "", (), False, "") loops = [] try: @@ -269,7 +269,7 @@ def test_max_cache_size_eviction(self): mock_client = MagicMock() mock_client._client = MagicMock() mock_client._client.is_closed = False - key = (f"evict_test_{i}", False, "", "", "", (), False) + key = (f"evict_test_{i}", False, "", "", "", (), False, "") with _client_cache_lock: # Inline the eviction logic (same as _get_cached_client) while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE: @@ -281,9 +281,9 @@ def test_max_cache_size_eviction(self): assert len(_client_cache) <= _CLIENT_CACHE_MAX_SIZE, \ f"Cache size {len(_client_cache)} exceeds max {_CLIENT_CACHE_MAX_SIZE}" # The earliest entries should have been evicted - assert ("evict_test_0", False, "", "", "", (), False) not in _client_cache + assert ("evict_test_0", False, "", "", "", (), False, "") not in _client_cache # The latest entries should be present - assert (f"evict_test_{_CLIENT_CACHE_MAX_SIZE + 4}", False, "", "", "", (), False) in _client_cache + assert (f"evict_test_{_CLIENT_CACHE_MAX_SIZE + 4}", False, "", "", "", (), False, "") in _client_cache finally: with _client_cache_lock: _client_cache.clear() diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py index fce3772de8ec..c14647d5fb33 100644 --- a/tests/tools/test_vision_native_fast_path.py +++ b/tests/tools/test_vision_native_fast_path.py @@ -157,8 +157,12 @@ def test_vision_capable_main_model_uses_fast_path(self, tmp_path, monkeypatch): from agent.auxiliary_client import set_runtime_main, clear_runtime_main set_runtime_main("openrouter", "anthropic/claude-opus-4.6") try: - coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) - result = asyncio.get_event_loop().run_until_complete(coro) + with patch( + "hermes_cli.config.load_config", + return_value={"agent": {"image_input_mode": "native"}}, + ): + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) finally: clear_runtime_main() diff --git a/tools/process_registry.py b/tools/process_registry.py index 260ba4739fdf..33124cd76ab8 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -585,7 +585,7 @@ def spawn_local( try: if not _IS_WINDOWS: try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # windows-footgun: ok — POSIX-only branch guarded by _IS_WINDOWS above except (ProcessLookupError, PermissionError, OSError): proc.kill() else: diff --git a/toolsets.py b/toolsets.py index 5e34a0548c87..8a7a4b6be2cb 100644 --- a/toolsets.py +++ b/toolsets.py @@ -368,6 +368,23 @@ ], "includes": [] }, + + "hermes-web": { + "description": "Browser-facing API client surface — conversational and web-capable, without local execution or file-system tools", + "tools": [ + "web_search", "web_extract", + "vision_analyze", + "skills_list", "skill_view", + "todo", "memory", "session_search", + ], + "includes": [] + }, + + "hermes-mobile-chat": { + "description": "Lightweight mobile/API chat surface — no tools by default; configure platform_toolsets.mobile_chat to opt in server-controlled tools", + "tools": [], + "includes": [] + }, "hermes-cli": { "description": "Full interactive CLI toolset - all default tools plus cronjob management", @@ -519,7 +536,7 @@ "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook", "hermes-yuanbao"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook", "hermes-web", "hermes-mobile-chat", "hermes-yuanbao"] } }