mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/tools+config: search through the DeepSeek Search API
This commit is contained in:
@@ -7,7 +7,7 @@ from dataclasses import replace
|
||||
from plyngent.config.models import DeepseekProvider
|
||||
from plyngent.config.routing import (
|
||||
EffectiveProvider,
|
||||
deepseek_search_base_url,
|
||||
deepseek_api_base_url,
|
||||
local_web_search,
|
||||
resolve_effective_provider,
|
||||
wants_web_search,
|
||||
@@ -46,8 +46,14 @@ def test_chat_and_responses_conventions_use_the_local_tool() -> None:
|
||||
assert local_web_search(_effective(convention="anthropic")) is False
|
||||
|
||||
|
||||
def test_without_provider_tools_there_is_no_search() -> None:
|
||||
assert local_web_search(_effective(convention="responses", tools=[])) is False
|
||||
def test_provider_tools_only_gate_the_hosted_search() -> None:
|
||||
# ``search_sources`` owns the local tool; ``provider_tools`` owns the hosted
|
||||
# one. Turning hosted tools off must not take local search with it.
|
||||
assert local_web_search(_effective(convention="responses", tools=[])) is True
|
||||
assert local_web_search(_effective(convention="anthropic", tools=[])) is True
|
||||
# With hosted tools on, the Anthropic surface keeps the hosted search and
|
||||
# the local tool stays out of the way.
|
||||
assert local_web_search(_effective(convention="anthropic")) is False
|
||||
|
||||
|
||||
def test_without_search_sources_there_is_no_local_tool() -> None:
|
||||
@@ -58,9 +64,17 @@ def test_without_search_sources_there_is_no_local_tool() -> None:
|
||||
assert _effective(convention="responses").search_sources == ["deepseek", "bing"]
|
||||
|
||||
|
||||
def test_search_base_url_prefers_an_anthropic_endpoint() -> None:
|
||||
assert deepseek_search_base_url(_effective(convention="responses")) == "https://api.deepseek.com/anthropic"
|
||||
def test_api_base_url_drops_the_convention_and_version_paths() -> None:
|
||||
# The Search API appends ``/v1/tools/search`` itself, so neither the
|
||||
# Anthropic convention's ``/anthropic`` nor an OpenAI-compatible ``/v1``
|
||||
# may survive in the base.
|
||||
assert deepseek_api_base_url(_effective(convention="responses")) == "https://api.deepseek.com"
|
||||
assert deepseek_api_base_url(_effective(convention="anthropic")) == "https://api.deepseek.com"
|
||||
assert (
|
||||
deepseek_search_base_url(_effective(convention="responses", url="https://gateway.example/anthropic"))
|
||||
== "https://gateway.example/anthropic"
|
||||
deepseek_api_base_url(_effective(convention="anthropic", url="https://gateway.example/api/anthropic"))
|
||||
== "https://gateway.example/api"
|
||||
)
|
||||
assert (
|
||||
deepseek_api_base_url(_effective(convention="responses", url="https://gateway.example/v1"))
|
||||
== "https://gateway.example"
|
||||
)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""DeepSeek server-side search backend (Anthropic-compatible endpoint)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
from plyngent.lmproto.deepseek.anthropic.search import (
|
||||
DeepseekSearchClient,
|
||||
SearchHit,
|
||||
hits_from_content,
|
||||
)
|
||||
|
||||
|
||||
def test_hits_from_content_dedupes_and_skips_noise() -> None:
|
||||
content = [
|
||||
{"type": "thinking", "thinking": "search"},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": [
|
||||
{"type": "web_search_result", "title": "First", "url": "https://a.example/x"},
|
||||
{"type": "web_search_result", "title": "First again", "url": "https://a.example/x"},
|
||||
{"type": "web_search_result", "url": "https://b.example/y"},
|
||||
{"type": "web_search_result", "title": "no url"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_2",
|
||||
"content": [{"type": "web_search_tool_result_error", "error_code": "max_uses_exceeded"}],
|
||||
},
|
||||
]
|
||||
hits = hits_from_content(content)
|
||||
assert hits == [
|
||||
SearchHit(title="First", url="https://a.example/x"),
|
||||
SearchHit(title="", url="https://b.example/y"),
|
||||
]
|
||||
assert hits_from_content("not a list") == []
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code: int, body: bytes) -> None:
|
||||
self.status_code = status_code
|
||||
self.content = body
|
||||
|
||||
|
||||
def _search_body() -> bytes:
|
||||
return msgspec.json.encode(
|
||||
{
|
||||
"id": "msg_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "server_tool_use",
|
||||
"id": "call_00_1",
|
||||
"name": "web_search",
|
||||
"input": {"query": "DeepSeek-V4"},
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_00_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"title": "DeepSeek-V4 preview",
|
||||
"url": "https://api-docs.deepseek.com/news/news260424/",
|
||||
"encrypted_content": "opaque",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"usage": {"input_tokens": 9932, "output_tokens": 377},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sends_the_hosted_tool_and_reads_its_hits(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
sent["path"] = path
|
||||
sent.update(kwargs)
|
||||
return _Resp(200, _search_body())
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
hits = await client.search("DeepSeek-V4", model="deepseek-flash")
|
||||
|
||||
assert hits == [SearchHit(title="DeepSeek-V4 preview", url="https://api-docs.deepseek.com/news/news260424/")]
|
||||
assert sent["path"] == "/messages"
|
||||
assert sent["stream"] is False
|
||||
data = sent["data"]
|
||||
assert isinstance(data, bytes)
|
||||
payload = msgspec.json.decode(data, type=dict[str, object])
|
||||
assert payload["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
|
||||
# A search turn stops as soon as it has searched; the answer is not needed.
|
||||
assert payload["max_tokens"] == 512
|
||||
assert payload["messages"] == [{"role": "user", "content": "DeepSeek-V4"}]
|
||||
headers = sent["headers"]
|
||||
assert isinstance(headers, dict)
|
||||
assert headers["x-api-key"] == "sk-test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_raises_with_the_endpoint_detail(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(422, b'{"error": {"message": "tools[0]: unknown variant"}}')
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="unknown variant"):
|
||||
_ = await client.search("DeepSeek-V4", model="deepseek-flash")
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
from plyngent.tools.net.bing import parse_bing_rss
|
||||
|
||||
DOCUMENT = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent.tools import ToolTag
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
from plyngent.tools.net.search import (
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
build_web_search_tool,
|
||||
@@ -93,25 +91,21 @@ async def test_handler_without_sources_says_so() -> None:
|
||||
assert "no source configured" in await definition.handler(query="q")
|
||||
|
||||
|
||||
def test_deepseek_backend_passes_the_model_through() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_backend_asks_the_search_api() -> None:
|
||||
from plyngent.tools.net.search import DeepseekSearchBackend
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.seen: list[tuple[str, str]] = []
|
||||
self.queries: list[str] = []
|
||||
|
||||
async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]:
|
||||
self.seen.append((query, model))
|
||||
async def search(self, query: str) -> list[SearchHit]:
|
||||
self.queries.append(query)
|
||||
return _hits(("T", "https://example.com"))
|
||||
|
||||
client: Any = _Client()
|
||||
backend = DeepseekSearchBackend(client, model="deepseek-flash")
|
||||
client = _Client()
|
||||
backend = DeepseekSearchBackend(client) # type: ignore[arg-type] — structural stub
|
||||
|
||||
async def run() -> list[SearchHit]:
|
||||
return list(await backend.search("q"))
|
||||
|
||||
import asyncio
|
||||
|
||||
hits = asyncio.run(run())
|
||||
hits = await backend.search("q")
|
||||
assert [hit.url for hit in hits] == ["https://example.com"]
|
||||
assert client.seen == [("q", "deepseek-flash")]
|
||||
assert client.queries == ["q"]
|
||||
|
||||
Reference in New Issue
Block a user