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
+1
View File
@@ -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).
+8 -1
View File
@@ -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 }
+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):
+17
View File
@@ -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
+13
View File
@@ -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: