mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 14:14:57 +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).
|
- **`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.
|
- **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.
|
- **`@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`.
|
- **`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).
|
- **`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]
|
[providers.openai_compat.models]
|
||||||
"gpt-4o-mini" = { text = true, cost_factor = 1.0 }
|
"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]
|
# [providers.openai]
|
||||||
# preset = "openai"
|
# preset = "openai"
|
||||||
# access_key_or_token = "sk-replace-me"
|
# access_key_or_token = "sk-replace-me"
|
||||||
# url = "https://api.openai.com/v1"
|
# 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]
|
# [providers.openai.models]
|
||||||
# "gpt-4o-mini" = { text = true }
|
# "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]:
|
def _merge_response_tools(
|
||||||
"""Build keyword args for :class:`ResponsesCreateParam` from a chat param."""
|
param: ChatCompletionsParam,
|
||||||
instructions, input_items = chat_messages_to_responses_input(param.messages)
|
provider_tools: Sequence[dict[str, Any]] | None,
|
||||||
tools = tool_items_to_response_tools(
|
) -> list[ResponseFunctionTool | dict[str, Any]]:
|
||||||
param.tools if param.tools is not UNSET else None,
|
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] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": param.model,
|
"model": param.model,
|
||||||
"input": input_items or "",
|
"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
|
kwargs["max_output_tokens"] = param.max_tokens
|
||||||
if param.parallel_tool_calls is not UNSET:
|
if param.parallel_tool_calls is not UNSET:
|
||||||
kwargs["parallel_tool_calls"] = param.parallel_tool_calls
|
kwargs["parallel_tool_calls"] = param.parallel_tool_calls
|
||||||
if param.tool_choice is not UNSET:
|
if param.tool_choice is not UNSET and isinstance(param.tool_choice, str):
|
||||||
# "auto"/"none"/"required" strings pass through; structured choices left as-is if str.
|
kwargs["tool_choice"] = param.tool_choice
|
||||||
choice = param.tool_choice
|
|
||||||
if isinstance(choice, str):
|
|
||||||
kwargs["tool_choice"] = choice
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Literal, overload
|
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||||
|
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ from plyngent.agent.responses_bridge import (
|
|||||||
from plyngent.lmproto.openai.model import ResponsesCreateParam
|
from plyngent.lmproto.openai.model import ResponsesCreateParam
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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.client import OpenAIClient
|
||||||
from plyngent.lmproto.openai.model import Response
|
from plyngent.lmproto.openai.model import Response
|
||||||
@@ -32,13 +32,21 @@ class ResponsesChatClient:
|
|||||||
"""Present OpenAI Responses as :class:`~plyngent.agent.client.ChatClient`.
|
"""Present OpenAI Responses as :class:`~plyngent.agent.client.ChatClient`.
|
||||||
|
|
||||||
History and tool results remain chat-completions-shaped; only the HTTP call
|
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
|
_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._client = client
|
||||||
|
self._provider_tools = [dict(t) for t in provider_tools] if provider_tools else []
|
||||||
|
|
||||||
async def models(self) -> list[str]:
|
async def models(self) -> list[str]:
|
||||||
return await self._client.models()
|
return await self._client.models()
|
||||||
@@ -56,7 +64,10 @@ class ResponsesChatClient:
|
|||||||
async def chat_completions(
|
async def chat_completions(
|
||||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
) -> 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)
|
create = ResponsesCreateParam(**kwargs)
|
||||||
if stream:
|
if stream:
|
||||||
return self._stream_as_chat_chunks(create, model=param.model)
|
return self._stream_as_chat_chunks(create, model=param.model)
|
||||||
@@ -101,6 +112,10 @@ class ResponsesChatClient:
|
|||||||
yield usage
|
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."""
|
"""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
|
from msgspec import Struct, field
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +54,14 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIProvider(ProviderConfig, tag="openai"):
|
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"):
|
class OpenAICompatibleProvider(ProviderConfig, tag="openai-compatible"):
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ def create_client(provider: Provider) -> ProtocolClient:
|
|||||||
if isinstance(provider, OpenAIProvider):
|
if isinstance(provider, OpenAIProvider):
|
||||||
from plyngent.agent.responses_client import wrap_openai_for_agent
|
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):
|
if isinstance(provider, OpenAICompatibleProvider):
|
||||||
return OpenAICompatibleClient(provider_to_openai_config(provider))
|
return OpenAICompatibleClient(provider_to_openai_config(provider))
|
||||||
if isinstance(provider, DeepseekProvider):
|
if isinstance(provider, DeepseekProvider):
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ def test_chat_param_to_responses_kwargs() -> None:
|
|||||||
assert len(kwargs["tools"]) == 1
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_responses_chat_client_non_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_responses_chat_client_non_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
from plyngent.lmproto.openai.client import OpenAIClient
|
from plyngent.lmproto.openai.client import OpenAIClient
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ def test_openai_provider_defaults_base_url() -> None:
|
|||||||
client = create_client(provider)
|
client = create_client(provider)
|
||||||
assert isinstance(client, ResponsesChatClient)
|
assert isinstance(client, ResponsesChatClient)
|
||||||
assert hasattr(client, "chat_completions")
|
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:
|
def test_openai_compatible_requires_url() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user