From 16992be743164cb7ce81c289d791cdcec14d5918 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 15 Jul 2026 21:16:05 +0800 Subject: [PATCH] core/config+agent: OpenAI provider_tools for hosted Responses tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge opaque tool dicts (web_search, file_search, …) into POST /responses; local ToolRegistry unchanged. --- CLAUDE.md | 1 + doc/plyngent.example.toml | 9 +++++- src/plyngent/agent/responses_bridge.py | 36 ++++++++++++++++------- src/plyngent/agent/responses_client.py | 29 +++++++++++++----- src/plyngent/config/models.py | 11 ++++++- src/plyngent/runtime/client_factory.py | 5 +++- tests/test_agent/test_responses_bridge.py | 17 +++++++++++ tests/test_runtime/test_client_factory.py | 13 ++++++++ 8 files changed, 101 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 88c7512..12403ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite ` - **`ChatClient`** Protocol for `chat_completions` (agent history stays chat-shaped). - **OpenAI Responses integration**: `ResponsesChatClient` adapts platform `OpenAIClient.responses` to `ChatClient`; selected automatically for `OpenAIProvider` in `create_client`. Compat/DeepSeek stay on chat completions. +- **Provider-side tools**: `OpenAIProvider.provider_tools` (list of dicts, e.g. `{type="web_search"}`) merged into Responses `tools` alongside local function tools; never executed by `ToolRegistry`. - **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. - **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`. - **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools). diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 4e7bdeb..d6b3180 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -31,11 +31,18 @@ access_key_or_token = "sk-replace-me" [providers.openai_compat.models] "gpt-4o-mini" = { text = true, cost_factor = 1.0 } -# --- OpenAI preset (same client shape; tag is "openai") --- +# --- OpenAI platform (Responses API; tag is "openai") --- # [providers.openai] # preset = "openai" # access_key_or_token = "sk-replace-me" # url = "https://api.openai.com/v1" +# # Hosted/provider-side tools (OpenAI runs them; not local ToolRegistry). +# # Examples: web_search, file_search (+ vector_store_ids), code_interpreter, mcp. +# provider_tools = [ +# { type = "web_search" }, +# # { type = "file_search", vector_store_ids = ["vs_xxx"] }, +# # { type = "code_interpreter", container = { type = "auto" } }, +# ] # # [providers.openai.models] # "gpt-4o-mini" = { text = true } diff --git a/src/plyngent/agent/responses_bridge.py b/src/plyngent/agent/responses_bridge.py index 67280ed..0ce76aa 100644 --- a/src/plyngent/agent/responses_bridge.py +++ b/src/plyngent/agent/responses_bridge.py @@ -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 diff --git a/src/plyngent/agent/responses_client.py b/src/plyngent/agent/responses_client.py index 86e4dae..20e3dfe 100644 --- a/src/plyngent/agent/responses_client.py +++ b/src/plyngent/agent/responses_client.py @@ -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) diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index b8508e7..d844da4 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -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"): diff --git a/src/plyngent/runtime/client_factory.py b/src/plyngent/runtime/client_factory.py index 9c61814..90ed1fe 100644 --- a/src/plyngent/runtime/client_factory.py +++ b/src/plyngent/runtime/client_factory.py @@ -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): diff --git a/tests/test_agent/test_responses_bridge.py b/tests/test_agent/test_responses_bridge.py index e5a65ad..cb3e280 100644 --- a/tests/test_agent/test_responses_bridge.py +++ b/tests/test_agent/test_responses_bridge.py @@ -117,6 +117,23 @@ def test_chat_param_to_responses_kwargs() -> None: assert len(kwargs["tools"]) == 1 +def test_provider_tools_merged_after_local_functions() -> None: + param = ChatCompletionsParam( + model="gpt-test", + messages=[UserChatMessage(content="hi")], + tools=[ToolFunctionItem(function=ToolFunction(name="local_tool", parameters={"type": "object"}))], + ) + kwargs = chat_param_to_responses_kwargs( + param, + provider_tools=[{"type": "web_search"}, {"type": "file_search", "vector_store_ids": ["vs_1"]}], + ) + tools = kwargs["tools"] + assert len(tools) == 3 + assert tools[0].name == "local_tool" + assert tools[1]["type"] == "web_search" + assert tools[2]["vector_store_ids"] == ["vs_1"] + + @pytest.mark.asyncio async def test_responses_chat_client_non_stream(monkeypatch: pytest.MonkeyPatch) -> None: from plyngent.lmproto.openai.client import OpenAIClient diff --git a/tests/test_runtime/test_client_factory.py b/tests/test_runtime/test_client_factory.py index c7e84ea..8eb4558 100644 --- a/tests/test_runtime/test_client_factory.py +++ b/tests/test_runtime/test_client_factory.py @@ -24,6 +24,19 @@ def test_openai_provider_defaults_base_url() -> None: client = create_client(provider) assert isinstance(client, ResponsesChatClient) assert hasattr(client, "chat_completions") + assert client._provider_tools == [] + + +def test_openai_provider_tools_passed_to_wrapper() -> None: + from plyngent.agent.responses_client import ResponsesChatClient + + provider = OpenAIProvider( + access_key_or_token="sk-test", + provider_tools=[{"type": "web_search"}], + ) + client = create_client(provider) + assert isinstance(client, ResponsesChatClient) + assert client._provider_tools == [{"type": "web_search"}] def test_openai_compatible_requires_url() -> None: