mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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).
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user