fix(agent): alias Perplexity-reserved Responses tools

This commit is contained in:
Andrew Johnson
2026-09-05 09:43:09 -07:00
committed by Teknium
parent 48381196a5
commit 2cfeb505bd
2 changed files with 102 additions and 8 deletions

View File

@@ -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_<name>.
# Responses providers reject client functions whose names collide with native
# tools (HTTP 400 "custom function name 'X' is reserved"). Alias them as
# hermes_<name> 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_<name>), 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

View File

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