core/config+agent: OpenAI provider_tools for hosted Responses tools

Merge opaque tool dicts (web_search, file_search, …) into POST /responses; local ToolRegistry unchanged.
This commit is contained in:
2026-07-15 21:16:05 +08:00
parent 1f75cde3dd
commit 16992be743
8 changed files with 101 additions and 20 deletions
+26 -10
View File
@@ -263,12 +263,31 @@ def usage_chunk_from_response(response: Response, *, model: str) -> ChatCompleti
)
def chat_param_to_responses_kwargs(param: ChatCompletionsParam) -> dict[str, Any]:
"""Build keyword args for :class:`ResponsesCreateParam` from a chat param."""
instructions, input_items = chat_messages_to_responses_input(param.messages)
tools = tool_items_to_response_tools(
param.tools if param.tools is not UNSET else None,
def _merge_response_tools(
param: ChatCompletionsParam,
provider_tools: Sequence[dict[str, Any]] | None,
) -> list[ResponseFunctionTool | dict[str, Any]]:
tools: list[ResponseFunctionTool | dict[str, Any]] = list(
tool_items_to_response_tools(param.tools if param.tools is not UNSET else None)
)
if provider_tools:
tools.extend(dict(item) for item in provider_tools if item.get("type"))
return tools
def chat_param_to_responses_kwargs(
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build keyword args for :class:`ResponsesCreateParam` from a chat param.
*provider_tools* are hosted tools (web_search, file_search, …) as opaque
dicts; they are merged after local function tools and never executed by
:class:`~plyngent.agent.tools.ToolRegistry`.
"""
instructions, input_items = chat_messages_to_responses_input(param.messages)
tools = _merge_response_tools(param, provider_tools)
kwargs: dict[str, Any] = {
"model": param.model,
"input": input_items or "",
@@ -288,9 +307,6 @@ def chat_param_to_responses_kwargs(param: ChatCompletionsParam) -> dict[str, Any
kwargs["max_output_tokens"] = param.max_tokens
if param.parallel_tool_calls is not UNSET:
kwargs["parallel_tool_calls"] = param.parallel_tool_calls
if param.tool_choice is not UNSET:
# "auto"/"none"/"required" strings pass through; structured choices left as-is if str.
choice = param.tool_choice
if isinstance(choice, str):
kwargs["tool_choice"] = choice
if param.tool_choice is not UNSET and isinstance(param.tool_choice, str):
kwargs["tool_choice"] = param.tool_choice
return kwargs
+22 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, overload
from msgspec import UNSET
@@ -17,7 +17,7 @@ from plyngent.agent.responses_bridge import (
from plyngent.lmproto.openai.model import ResponsesCreateParam
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai.model import Response
@@ -32,13 +32,21 @@ class ResponsesChatClient:
"""Present OpenAI Responses as :class:`~plyngent.agent.client.ChatClient`.
History and tool results remain chat-completions-shaped; only the HTTP call
uses ``POST /responses``.
uses ``POST /responses``. Optional *provider_tools* are hosted tools merged
into the request (not local registry handlers).
"""
_client: OpenAIClient
_provider_tools: list[dict[str, Any]]
def __init__(self, client: OpenAIClient) -> None:
def __init__(
self,
client: OpenAIClient,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> None:
self._client = client
self._provider_tools = [dict(t) for t in provider_tools] if provider_tools else []
async def models(self) -> list[str]:
return await self._client.models()
@@ -56,7 +64,10 @@ class ResponsesChatClient:
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
kwargs = chat_param_to_responses_kwargs(param)
kwargs = chat_param_to_responses_kwargs(
param,
provider_tools=self._provider_tools or None,
)
create = ResponsesCreateParam(**kwargs)
if stream:
return self._stream_as_chat_chunks(create, model=param.model)
@@ -101,6 +112,10 @@ class ResponsesChatClient:
yield usage
def wrap_openai_for_agent(client: OpenAIClient) -> ResponsesChatClient:
def wrap_openai_for_agent(
client: OpenAIClient,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> ResponsesChatClient:
"""Wrap a platform OpenAI client so the agent uses Responses by default."""
return ResponsesChatClient(client)
return ResponsesChatClient(client, provider_tools=provider_tools)
+10 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
from msgspec import Struct, field
@@ -52,7 +54,14 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
class OpenAIProvider(ProviderConfig, tag="openai"):
"""OpenAI API provider."""
"""OpenAI platform provider (agent uses Responses API).
``provider_tools`` are hosted/provider-side tools (e.g. web_search) passed
through to ``POST /responses`` as opaque dicts. They are **not** local
``ToolRegistry`` handlers and are ignored by non-OpenAI clients.
"""
provider_tools: list[dict[str, Any]] = field(default_factory=list)
class OpenAICompatibleProvider(ProviderConfig, tag="openai-compatible"):
+4 -1
View File
@@ -65,7 +65,10 @@ def create_client(provider: Provider) -> ProtocolClient:
if isinstance(provider, OpenAIProvider):
from plyngent.agent.responses_client import wrap_openai_for_agent
return wrap_openai_for_agent(OpenAIClient(provider_to_openai_config(provider)))
return wrap_openai_for_agent(
OpenAIClient(provider_to_openai_config(provider)),
provider_tools=provider.provider_tools or None,
)
if isinstance(provider, OpenAICompatibleProvider):
return OpenAICompatibleClient(provider_to_openai_config(provider))
if isinstance(provider, DeepseekProvider):