From 2cfeb505bdd2992b37bc33901ee6e73b66ce6940 Mon Sep 17 00:00:00 2001 From: Andrew Johnson <7144201+acjsec@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:43:09 -0700 Subject: [PATCH] fix(agent): alias Perplexity-reserved Responses tools --- agent/transports/codex.py | 30 ++++++- .../agent/transports/test_codex_transport.py | 80 ++++++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 2053843f85..985c498d09 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -65,14 +65,21 @@ def _merge_extra_headers(kwargs: dict[str, Any], **headers: str) -> None: # (incomplete hang / HTTP 400); it goes on the wire under this alias. _XAI_CLIENT_WEB_SEARCH_ALIAS = "hermes_web_search" -# OpenCode /v1/responses rejects client tools using these names (HTTP 400 -# "custom function name 'X' is reserved"); xAI reserves ``tool_search`` for -# Grok's native Tool Search. Aliased as hermes_. +# Responses providers reject client functions whose names collide with native +# tools (HTTP 400 "custom function name 'X' is reserved"). Alias them as +# hermes_ and map them back before local dispatch. # OpenCode's /v1/responses endpoints (Zen and Go, including custom providers pointing at opencode.ai) # reserve certain function names server-side and reject client tools that use them with HTTP 400 ("custom # function name 'X' is reserved"). Same treatment as the xAI web_search collision: rename on the wire # (hermes_), map back in normalize_response so Hermes dispatch is unaffected. See #85589. _OPENCODE_RESERVED_TOOL_NAMES = ("web_search", "search_files") +_PERPLEXITY_RESERVED_TOOL_NAMES = ( + "web_search", + "search_files", + "fetch_url", + "people_search", + "finance_search", +) _XAI_RESERVED_TOOL_NAMES = ("tool_search",) _RESERVED_TOOL_ALIAS_PREFIX = "hermes_" @@ -80,7 +87,7 @@ _RESERVED_TOOL_ALIAS_PREFIX = "hermes_" # built a request; real requests carry request-local ``_last_wire_aliases``. _LEGACY_ALIAS_FALLBACK = { f"{_RESERVED_TOOL_ALIAS_PREFIX}{name}": name - for name in (*_OPENCODE_RESERVED_TOOL_NAMES, *_XAI_RESERVED_TOOL_NAMES) + for name in (*_OPENCODE_RESERVED_TOOL_NAMES, *_PERPLEXITY_RESERVED_TOOL_NAMES, *_XAI_RESERVED_TOOL_NAMES) } _LEGACY_ALIAS_FALLBACK[_XAI_CLIENT_WEB_SEARCH_ALIAS] = "web_search" @@ -102,6 +109,16 @@ def _is_opencode_responses_backend(params: dict[str, Any]) -> bool: return False +def _is_perplexity_responses_backend(params: dict[str, Any]) -> bool: + """True for Perplexity's Responses-compatible Agent API endpoint.""" + try: + from utils import base_url_hostname + + return base_url_hostname(str(params.get("base_url") or "")).lower() == "api.perplexity.ai" + except Exception: + return False + + def _alias_reserved_tools( response_tools: list[dict[str, Any]], reserved_names: tuple[str, ...], name_of: Callable[[dict], Any] = lambda t: t.get("name"), @@ -178,6 +195,11 @@ def _alias_wire_tools(response_tools: Any, params: dict[str, Any], is_xai_respon if response_tools and _is_opencode_responses_backend(params): response_tools, _oc_aliases = _alias_reserved_tools(response_tools, _OPENCODE_RESERVED_TOOL_NAMES) wire_aliases.update(_oc_aliases) + # Perplexity's Agent API reserves the same names as server-side tools. + # Keep Hermes's client-side functions available under wire aliases. + if response_tools and _is_perplexity_responses_backend(params): + response_tools, _pplx_aliases = _alias_reserved_tools(response_tools, _PERPLEXITY_RESERVED_TOOL_NAMES) + wire_aliases.update(_pplx_aliases) # xAI server-side web search vs Hermes web providers. grok models on xAI's /v1/responses surface have a # *native*, server-executed web search. A client-side function literally named ``web_search`` collides # with that engine: declared as a plain ``function`` rather than ``{"type": "web_search"}``, the search diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 03b7ded78c..e8c01f93cc 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -1018,10 +1018,9 @@ class TestCodexBuildKwargs: assert "reasoning" not in kw, f"{model} must not receive reasoning" -class TestOpencodeReservedToolAliases: - """OpenCode /v1/responses reserves web_search / search_files as function - names (HTTP 400 "custom function name 'X' is reserved", #85589). The - transport aliases them on the wire and maps them back on dispatch.""" +class TestResponsesReservedToolAliases: + """Responses providers may reserve web_search / search_files as function + names. The transport aliases them on the wire and maps them back.""" @pytest.fixture def transport(self): @@ -1042,6 +1041,12 @@ class TestOpencodeReservedToolAliases: "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}}, ] + _PERPLEXITY_ONLY_TOOLS = [ + {"type": "function", "function": { + "name": name, "description": f"Client {name}.", + "parameters": {"type": "object", "properties": {}}}} + for name in ("fetch_url", "people_search", "finance_search") + ] def _names(self, kw): return [t.get("name") for t in kw.get("tools", []) if t.get("type") == "function"] @@ -1100,6 +1105,73 @@ class TestOpencodeReservedToolAliases: assert "web_search" in names assert "hermes_search_files" not in names + def test_perplexity_agent_api_aliases_reserved_names(self, transport, monkeypatch): + kw = transport.build_kwargs( + model="perplexity/sonar", + messages=[{"role": "user", "content": "hi"}], + tools=list(self._TOOLS) + list(self._PERPLEXITY_ONLY_TOOLS), + provider="custom", + base_url="https://api.perplexity.ai/v1", + ) + names = self._names(kw) + assert "hermes_search_files" in names + assert "hermes_web_search" in names + assert "search_files" not in names + assert "web_search" not in names + assert "hermes_fetch_url" in names + assert "hermes_people_search" in names + assert "hermes_finance_search" in names + assert "fetch_url" not in names + assert "people_search" not in names + assert "finance_search" not in names + assert "read_file" in names + assert transport._last_wire_aliases == { + "hermes_search_files": "search_files", + "hermes_web_search": "web_search", + "hermes_fetch_url": "fetch_url", + "hermes_people_search": "people_search", + "hermes_finance_search": "finance_search", + } + + msg = SimpleNamespace( + content=None, + reasoning=None, + tool_calls=[SimpleNamespace( + id="call_1", call_id="call_1", response_item_id="fc_1", + function=SimpleNamespace( + name="hermes_search_files", + arguments='{"pattern":"README"}', + ), + )], + codex_reasoning_items=None, + codex_message_items=None, + reasoning_details=None, + ) + monkeypatch.setattr( + "agent.codex_responses_adapter._normalize_codex_response", + lambda resp, issuer_kind=None: (msg, "tool_calls"), + ) + normalized = transport.normalize_response(SimpleNamespace(output=[], status="completed")) + assert [tc.name for tc in normalized.tool_calls] == ["search_files"] + + def test_perplexity_lookalike_host_keeps_original_names(self, transport): + for base_url in ( + "https://sub.api.perplexity.ai/v1", + "https://api.perplexity.ai.example.com/v1", + "https://example.com/api.perplexity.ai/v1?host=api.perplexity.ai", + ): + kw = transport.build_kwargs( + model="perplexity/sonar", + messages=[{"role": "user", "content": "hi"}], + tools=list(self._TOOLS), + provider="custom", + base_url=base_url, + ) + names = self._names(kw) + assert "search_files" in names + assert "web_search" in names + assert "hermes_search_files" not in names + def test_normalize_maps_reserved_aliases_back(self, transport, monkeypatch): msg = SimpleNamespace( content=None,