fix(llm): serialize all provider tools for the Responses API + log server-side tool execution#5865
Conversation
a565f20 to
f18cc82
Compare
to_responses_fnc_ctx hardcoded openai.tools.OpenAITool, so the xAI LLM (which reuses openai's responses serializer) had its WebSearch / XSearch tools silently dropped: they subclass XAITool, not OpenAITool, and never reached the API. Require the caller to pass its plugin's ProviderTool subclass via `provider_tool_type` instead, which also removes the core->plugin import of livekit.plugins.openai. The openai responses LLM defaults it to OpenAITool; the xAI LLM overrides it to XAITool.
Emit an info log when a provider-executed tool (e.g. xAI web_search / x_search, file_search) completes, including the issued query and the provider result carried on the completed output item.
f18cc82 to
289d880
Compare
| class DictProviderTool(ProviderTool): | ||
| """A provider tool whose schema serializes to a plain dict (e.g. openai, xAI).""" | ||
|
|
||
| @abstractmethod | ||
| def to_dict(self) -> dict[str, Any]: ... |
There was a problem hiding this comment.
It's tricky to have an abstract class for that, because it really depends on the provider.
Is there a way to avoid it?
| schema = llm.utils.build_legacy_openai_schema(tool, internally_tagged=True) | ||
| schemas.append(schema) | ||
| elif isinstance(tool, openai.tools.OpenAITool): | ||
| elif isinstance(tool, provider_tool_type): |
There was a problem hiding this comment.
Maybe we should use hasattr?
I'm not sure, but the reasoning is that for some providers, they only require the API name to mention the tool. It’s not even a dict; it’s literally just "web_search"."
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
I removed DictProviderTool and replaced it with hasattr(tool, "to_dict")
Provider tool classes now subclass ProviderTool directly and declare their
own abstract to_dict(). The responses serializer recognizes server-side
provider tools via isinstance(ProviderTool) + hasattr("to_dict") instead of
a caller-supplied provider_tool_type, removing the per-plugin override.
01356cc to
8ec259b
Compare
…isinstance check The responses serializer again takes a provider_tool_type so each plugin specifies which ProviderTool subclass to recognize as a server-side tool, keeping the core helper free of plugin imports.
cae62eb to
d34f2e5
Compare
| format: Literal["openai.responses"], | ||
| *, | ||
| strict: bool = True, | ||
| provider_tool_type: type[ProviderTool], |
There was a problem hiding this comment.
No, this adds the provider_tool_type type argument so we can check is_instance against either OpenAIProviderTool or XAIProviderTool so we can avoid adding provider tools from other providers, before the is_instance hardcoded the OpenAIProviderTool type which meant XAIProviderTools were never sent.
#5865 added a *required* keyword-only `provider_tool_type` to to_responses_fnc_ctx, but the dispatcher in `ToolContext.parse_function_tools` just forwards `**kwargs` blindly: elif format == "openai.responses": return _provider_format.openai.to_responses_fnc_ctx(self, **kwargs) So any caller that goes through `parse_function_tools("openai.responses")` without explicitly threading provider_tool_type hits a TypeError. #5884 landed exactly such a caller (the new `test_serialized_tool_order_is_sorted`), and main CI has been red on `tests/test_tools.py` since both PRs landed. Make `provider_tool_type` optional with a `None` default and gate the provider-tool isinstance branch on its presence. Behavior is unchanged for legitimate callers (the openai.responses plugin always passes `provider_tool_type=self._llm._provider_tool_type`); the `None` path just emits function-tool schemas, which is the right thing for generic serialization where no provider-tool subtype is in scope. Verified: - `uv run pytest tests/test_tools.py` -> 49 passed (was 1 failed) - `uv run ruff check` / `ruff format --check` -> clean - `uv run mypy -p livekit.agents.llm` -> clean Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
While wiring the basic voice agent up to xAI (Grok) with its
WebSearch/XSearchprovider tools, the tools never reached the API — Grok answered from memory instead of searching. This PR fixes the root cause and adds visibility into server-side tool execution.Changes
fix(llm): let the caller specify the provider tool type for responsesto_responses_fnc_ctxhardcodedopenai.tools.OpenAITool, so the xAI LLM (which reuses openai's Responses serializer) had itsWebSearch/XSearchtools silently dropped — they subclassXAITool, notOpenAITool, and never reached the API.provider_tool_type, removing the core→plugin import oflivekit.plugins.openai. The openai Responses LLM defaults it toOpenAITool; the xAI LLM overrides the_provider_tool_typeclass attribute toXAITool.DictProviderToolbase (aProviderTooldeclaringto_dict()). The dict-based plugin tool bases (OpenAITool,XAITool,AnthropicTool,MistralTool) now extend it, so theto_dictcontract is declared once instead of in every plugin, and the serializer is typed without a Protocol ortype: ignore. (Gemini keepsProviderTool+to_tool_config, since its schema is a typedtypes.Tool.)feat(openai): log server-side provider tool execution in responses LLMEmits an info log when the Responses API runs a tool server-side, e.g. xAI
web_searchandx_search(which decomposes intocustom_tool_callsubcalls likex_keyword_search), including the issued query and result. Detection is grounded in openai'sResponseOutputItemdiscriminated union (the authoritative parser): onlymessage,reasoning,function_call, andfunction_call_outputare produced/consumed by the agent itself — every other union member is a server-side tool, so the denylist is provably complete.Test plan
xai.responses.LLM: provider tools now reach the API, searches execute, andprovider tool executedlogs appear on completion.make lintpasses on touched files; runtime check confirms all four dict-based plugin tools subclassDictProviderTooland serialize correctly.