fix(xai): reject FileSearch plus collections_search or file_search - #6998
Conversation
5b426c1 to
a4203f6
Compare
| _FILE_SEARCH_RESERVED_FUNCTION_NAMES = frozenset({"collections_search", "file_search"}) | ||
|
|
||
|
|
||
| def _raise_if_file_search_reserved_name_conflict(tools: Sequence[Tool | Toolset]) -> None: |
There was a problem hiding this comment.
the input is already a flat tool list, flat = ToolContext(tools).flatten() is redudent?
def _create_tools_update_event(self, tools: list[llm.Tool]) -> dict[str, Any]:
_raise_if_file_search_reserved_name_conflict(tools)There was a problem hiding this comment.
The tools list at _create_tools_update_event is already flat, so ToolContext.flatten() was redundant. Dropped it. The helper still uses get_fnc_tool_names so a reserved function nested in a Toolset is caught.
| return result | ||
|
|
||
|
|
||
| _FILE_SEARCH_RESERVED_FUNCTION_NAMES = frozenset({"collections_search", "file_search"}) |
There was a problem hiding this comment.
could you include the reserved function names for other tools, e.g. https://docs.x.ai/developers/tools/x-search, https://docs.x.ai/developers/tools/collections-search, https://docs.x.ai/developers/tools/web-search
Measured against wss://api.x.ai/v1/realtime (grok-voice-latest), one session per row:
session.update with the tool list the plugin builds, then response.create.
| xAI tool | client function name | first response.create |
reserved? |
|---|---|---|---|
WebSearch |
web_search |
server_error / internal_error |
yes |
WebSearch |
browse_page |
server_error / internal_error |
yes |
WebSearch |
view_image |
completed | no |
WebSearch |
harmless_control |
completed | no |
XSearch |
x_keyword_search |
server_error / internal_error |
yes |
XSearch |
x_semantic_search |
server_error / internal_error |
yes |
XSearch |
x_user_search |
server_error / internal_error |
yes |
XSearch |
x_thread_fetch |
server_error / internal_error |
yes |
XSearch |
x_search |
completed | no |
XSearch |
harmless_control |
completed | no |
FileSearch |
collections_search |
server_error / internal_error |
yes |
FileSearch |
file_search |
completed | no |
FileSearch |
view_document |
completed | no |
FileSearch |
harmless_control |
completed | no |
A name is reserved only by the tool thlly:
| tools enabled | client function name | result |
|---|---|---|
| none | collections_search |
completed |
| none | web_search |
completed |
| none | browse_page |
completed |
WebSearch |
collections_search |
completed |
FileSearch |
web_search |
completed |
So the reserved set is seven names, keyed to the tool that is actually enabled:
| xAI tool | reserved function names |
|---|---|
WebSearch |
web_search, browse_page |
XSearch |
x_keyword_search, x_semantic_search, x_user_search, x_thread_fetch |
FileSearch |
collections_search |
There was a problem hiding this comment.
Updated to the names you measured, keyed to the tool that’s actually enabled:
WebSearch: web_search, browse_page
XSearch: x_keyword_search, x_semantic_search, x_user_search, x_thread_fetch
FileSearch: collections_search
Also reserving file_search on FileSearch as the OpenAI-compatible alias, even though your session completed.
Mixing an xAI provider tool with a client function of a name that tool already uses makes grok-voice-latest return server_error on the first response.create. Fail before session.update; reserved names are keyed to the tool that is actually enabled.
a4203f6 to
7fa3c27
Compare
A client function of that name plus FileSearch still 500s grok-voice-latest on the first response.create.
| def _raise_if_xai_tool_reserved_name_conflict(tools: Sequence[Tool | Toolset]) -> None: | ||
| reserved: set[str] = set() | ||
| owners: dict[str, str] = {} | ||
| for tool in tools: | ||
| for tool_cls, names in _XAI_TOOL_RESERVED_FUNCTION_NAMES.items(): | ||
| if isinstance(tool, tool_cls): | ||
| reserved |= names | ||
| for name in names: | ||
| owners[name] = tool_cls.__name__ | ||
| if not reserved: | ||
| return | ||
| for name in get_fnc_tool_names(tools): | ||
| if name in reserved: | ||
| raise ValueError( | ||
| f"xAI {owners[name]} already uses the function name {name!r}. " | ||
| "Rename or remove the function; mixing the provider tool with a " | ||
| "client function of a reserved name makes grok-voice-latest " | ||
| "return server_error/internal_error on the first response.create." | ||
| ) |
There was a problem hiding this comment.
A ClassVar on each class removes the table, the union, and the owners dict:
class XAITool(ProviderTool):
"""Base class for xAI server-side provider tools."""
# function names the server answers to once this tool is enabled. Measured against
# grok-voice-latest: a client function of the same name makes the first
# response.create return server_error.
_reserved_function_names: ClassVar[frozenset[str]] = frozenset()
@abstractmethod
def to_dict(self) -> dict[str, Any]: ...class WebSearch(XAITool):
_reserved_function_names: ClassVar[frozenset[str]] = frozenset({"web_search", "browse_page"})
class XSearch(XAITool):
_reserved_function_names: ClassVar[frozenset[str]] = frozenset(
{"x_keyword_search", "x_semantic_search", "x_user_search", "x_thread_fetch"}
)
class FileSearch(XAITool):
_reserved_function_names: ClassVar[frozenset[str]] = frozenset(
{"collections_search", "file_search"}
)then _raise_if_xai_tool_reserved_name_conflict could be rewrite as
| def _raise_if_xai_tool_reserved_name_conflict(tools: Sequence[Tool | Toolset]) -> None: | |
| reserved: set[str] = set() | |
| owners: dict[str, str] = {} | |
| for tool in tools: | |
| for tool_cls, names in _XAI_TOOL_RESERVED_FUNCTION_NAMES.items(): | |
| if isinstance(tool, tool_cls): | |
| reserved |= names | |
| for name in names: | |
| owners[name] = tool_cls.__name__ | |
| if not reserved: | |
| return | |
| for name in get_fnc_tool_names(tools): | |
| if name in reserved: | |
| raise ValueError( | |
| f"xAI {owners[name]} already uses the function name {name!r}. " | |
| "Rename or remove the function; mixing the provider tool with a " | |
| "client function of a reserved name makes grok-voice-latest " | |
| "return server_error/internal_error on the first response.create." | |
| ) | |
| def _raise_if_xai_tool_reserved_name_conflict(tools: Sequence[Tool]) -> None: | |
| """Reject a client function whose name an enabled xAI provider tool already answers to.""" | |
| fnc_names = { | |
| tool.info.name for tool in tools if isinstance(tool, (FunctionTool, RawFunctionTool)) | |
| } | |
| for tool in tools: | |
| if not isinstance(tool, XAITool): | |
| continue | |
| if conflicts := sorted(tool._reserved_function_names & fnc_names): | |
| names = ", ".join(repr(name) for name in conflicts) | |
| raise ValueError( | |
| f"xAI {type(tool).__name__} already uses the function name(s) {names}. " | |
| "Rename or remove them; a client function that shadows a provider tool's " | |
| "own name makes grok-voice-latest return server_error/internal_error on " | |
| "the first response.create." | |
| ) |
There was a problem hiding this comment.
Done — reserved names live on each tool as a ClassVar, and the helper intersects those with client function names on the already-flat list.
A ClassVar on WebSearch, XSearch, and FileSearch replaces the side table so the session.update check intersects enabled tools with client function names.
Summary
session.updatewhenFileSearchis mixed with a client function namedcollections_searchorfile_search(including names nested in a Toolset).collections_searchto OpenAI-compatfile_search. Mixing FileSearch with a client function of either name makes grok-voice-latest returnserver_error/internal_error/param=nullon the firstresponse.create; hello still works; the socket stays up.Test plan
@function_tool,name=override, andraw_schemauv run pytest tests/test_realtime/test_xai_realtime_model.py --unitMade with Cursor