fix(agent): alias Perplexity-reserved Responses tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user