|
3 | 3 | import uuid |
4 | 4 | from dataclasses import dataclass as std_dataclass |
5 | 5 | from dataclasses import field |
| 6 | +from urllib.parse import urlsplit |
6 | 7 |
|
7 | 8 | import aiohttp |
8 | 9 | from pydantic import Field |
|
24 | 25 | "web_search_exa", |
25 | 26 | "exa_get_contents", |
26 | 27 | "web_search_anysearch", |
| 28 | + "web_search_deepseek", |
| 29 | + "deepseek_fetch_web_page", |
27 | 30 | ] |
28 | 31 | _TAVILY_WEB_SEARCH_TOOL_CONFIG = { |
29 | 32 | "provider_settings.web_search": True, |
|
53 | 56 | "provider_settings.web_search": True, |
54 | 57 | "provider_settings.websearch_provider": "anysearch", |
55 | 58 | } |
| 59 | +_DEEPSEEK_WEB_SEARCH_TOOL_CONFIG = { |
| 60 | + "provider_settings.web_search": True, |
| 61 | + "provider_settings.websearch_provider": "deepseek", |
| 62 | +} |
56 | 63 |
|
57 | 64 |
|
58 | 65 | @std_dataclass |
@@ -117,6 +124,7 @@ async def get(self, provider_settings: dict) -> str: |
117 | 124 | _FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl") |
118 | 125 | _EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa") |
119 | 126 | _ANYSEARCH_KEY_ROTATOR = _KeyRotator("websearch_anysearch_key", "AnySearch") |
| 127 | +_DEEPSEEK_KEY_ROTATOR = _KeyRotator("websearch_deepseek_key", "DeepSeek") |
120 | 128 |
|
121 | 129 |
|
122 | 130 | def normalize_legacy_web_search_config(cfg) -> None: |
@@ -1442,11 +1450,225 @@ async def call(self, context, **kwargs) -> ToolExecResult: |
1442 | 1450 | return _search_result_payload(results) |
1443 | 1451 |
|
1444 | 1452 |
|
| 1453 | +@builtin_tool(config=_DEEPSEEK_WEB_SEARCH_TOOL_CONFIG) |
| 1454 | +@pydantic_dataclass |
| 1455 | +class DeepSeekWebSearchTool(FunctionTool[AstrAgentContext]): |
| 1456 | + name: str = "web_search_deepseek" |
| 1457 | + description: str = ( |
| 1458 | + "Search the web using DeepSeek and return summaries with source references. " |
| 1459 | + "Use deepseek_fetch_web_page for full page content when needed." |
| 1460 | + ) |
| 1461 | + parameters: dict = Field( |
| 1462 | + default_factory=lambda: { |
| 1463 | + "type": "object", |
| 1464 | + "properties": { |
| 1465 | + "query": { |
| 1466 | + "type": "string", |
| 1467 | + "minLength": 1, |
| 1468 | + "maxLength": 128, |
| 1469 | + "description": ( |
| 1470 | + "Search query, 1-128 Unicode characters including filters, " |
| 1471 | + "not all whitespace. Filters can be combined. Examples:\n" |
| 1472 | + "Find DeepSeek API references on GitHub or Hugging Face -> " |
| 1473 | + 'DeepSeek "API reference" site:github.com OR site:huggingface.co\n' |
| 1474 | + "Find Chinese or English DeepSeek news published after 2026-01-01 " |
| 1475 | + "and before 2027-01-01 -> " |
| 1476 | + "DeepSeek news lang:zh,en after:2026-01-01 before:2027-01-01\n" |
| 1477 | + "Find DeepSeek information excluding ads, example.com and the phrase " |
| 1478 | + '"release notes" -> DeepSeek -Ads -site:example.com -"release notes"\n' |
| 1479 | + "Date filters accept YYYY or YYYY-MM-DD." |
| 1480 | + ), |
| 1481 | + }, |
| 1482 | + "count": { |
| 1483 | + "type": "integer", |
| 1484 | + "minimum": 1, |
| 1485 | + "maximum": 50, |
| 1486 | + "default": 10, |
| 1487 | + "description": "Optional. Number of results, 1-50. Default is 10.", |
| 1488 | + }, |
| 1489 | + "ori_question": { |
| 1490 | + "type": "string", |
| 1491 | + "minLength": 1, |
| 1492 | + "maxLength": 128, |
| 1493 | + "description": "Optional. Original user question, 1-128 Unicode characters.", |
| 1494 | + }, |
| 1495 | + "need_content": { |
| 1496 | + "type": "boolean", |
| 1497 | + "default": False, |
| 1498 | + "description": "Optional. Include full page content in results. Default is false.", |
| 1499 | + }, |
| 1500 | + }, |
| 1501 | + "required": ["query"], |
| 1502 | + } |
| 1503 | + ) |
| 1504 | + |
| 1505 | + async def call(self, context, **kwargs) -> ToolExecResult: |
| 1506 | + """Search DeepSeek and normalize summaries and optional page content. |
| 1507 | +
|
| 1508 | + Args: |
| 1509 | + context: Current agent execution context. |
| 1510 | + **kwargs: Search query and optional count, ori_question, need_content. |
| 1511 | +
|
| 1512 | + Returns: |
| 1513 | + Search results with citation indices, or an input/configuration error. |
| 1514 | +
|
| 1515 | + Raises: |
| 1516 | + RuntimeError: If the DeepSeek API returns an HTTP error. |
| 1517 | + """ |
| 1518 | + _, provider_settings, _ = _get_runtime(context) |
| 1519 | + if not provider_settings.get("websearch_deepseek_key", []): |
| 1520 | + return "Error: DeepSeek API key is not configured in AstrBot." |
| 1521 | + |
| 1522 | + query = kwargs.get("query") |
| 1523 | + if ( |
| 1524 | + not isinstance(query, str) |
| 1525 | + or not query.strip() |
| 1526 | + or not 1 <= len(query) <= 128 |
| 1527 | + ): |
| 1528 | + return ( |
| 1529 | + "Error: query must be a non-blank string of 1-128 Unicode characters." |
| 1530 | + ) |
| 1531 | + count = kwargs.get("count", 10) |
| 1532 | + if type(count) is not int or not 1 <= count <= 50: |
| 1533 | + return "Error: count must be an integer between 1 and 50." |
| 1534 | + ori_question = kwargs.get("ori_question") |
| 1535 | + if ori_question is not None and ( |
| 1536 | + not isinstance(ori_question, str) or not 1 <= len(ori_question) <= 128 |
| 1537 | + ): |
| 1538 | + return "Error: ori_question must be a string of 1-128 Unicode characters." |
| 1539 | + need_content = kwargs.get("need_content", False) |
| 1540 | + if not isinstance(need_content, bool): |
| 1541 | + return "Error: need_content must be a boolean." |
| 1542 | + |
| 1543 | + payload = {"query": query, "count": count, "need_content": need_content} |
| 1544 | + if ori_question is not None: |
| 1545 | + payload["ori_question"] = ori_question |
| 1546 | + key = await _DEEPSEEK_KEY_ROTATOR.get(provider_settings) |
| 1547 | + headers = { |
| 1548 | + "Authorization": f"Bearer {key}", |
| 1549 | + "Content-Type": "application/json", |
| 1550 | + } |
| 1551 | + # Account-level limits and safety refusals cannot be resolved by rotating keys. |
| 1552 | + async with aiohttp.ClientSession(trust_env=True) as session: |
| 1553 | + async with session.post( |
| 1554 | + "https://api.deepseek.com/v1/tools/search", |
| 1555 | + json=payload, |
| 1556 | + headers=headers, |
| 1557 | + ) as response: |
| 1558 | + if response.status != 200: |
| 1559 | + reason = await response.text() |
| 1560 | + raise RuntimeError( |
| 1561 | + f"DeepSeek web search failed: {reason}, status: {response.status}" |
| 1562 | + ) |
| 1563 | + data = await response.json() |
| 1564 | + |
| 1565 | + results = [] |
| 1566 | + for item in data.get("results", []): |
| 1567 | + snippet = item.get("summary") or item.get("snippet") or "" |
| 1568 | + if need_content and item.get("content"): |
| 1569 | + snippet = "\n\n".join(filter(None, [snippet, item["content"]])) |
| 1570 | + results.append( |
| 1571 | + SearchResult( |
| 1572 | + title=item.get("title", ""), |
| 1573 | + url=item.get("url", ""), |
| 1574 | + snippet=snippet, |
| 1575 | + favicon=item.get("site_icon"), |
| 1576 | + ) |
| 1577 | + ) |
| 1578 | + if not results: |
| 1579 | + return "Error: DeepSeek web search does not return any results." |
| 1580 | + return _search_result_payload(results) |
| 1581 | + |
| 1582 | + |
| 1583 | +@builtin_tool(config=_DEEPSEEK_WEB_SEARCH_TOOL_CONFIG) |
| 1584 | +@pydantic_dataclass |
| 1585 | +class DeepSeekFetchWebPageTool(FunctionTool[AstrAgentContext]): |
| 1586 | + name: str = "deepseek_fetch_web_page" |
| 1587 | + description: str = ( |
| 1588 | + "Fetch a single web page using DeepSeek and return its title and Markdown content. " |
| 1589 | + "Use this to read a known URL, including a page found through web search." |
| 1590 | + ) |
| 1591 | + parameters: dict = Field( |
| 1592 | + default_factory=lambda: { |
| 1593 | + "type": "object", |
| 1594 | + "properties": { |
| 1595 | + "url": { |
| 1596 | + "type": "string", |
| 1597 | + "minLength": 1, |
| 1598 | + "maxLength": 8192, |
| 1599 | + "description": "Required. HTTP or HTTPS URL with a host, no embedded credentials, and at most 8192 characters.", |
| 1600 | + }, |
| 1601 | + }, |
| 1602 | + "required": ["url"], |
| 1603 | + } |
| 1604 | + ) |
| 1605 | + |
| 1606 | + async def call(self, context, **kwargs) -> ToolExecResult: |
| 1607 | + """Fetch Markdown content from the DeepSeek Fetch API. |
| 1608 | +
|
| 1609 | + Args: |
| 1610 | + context: Current agent execution context. |
| 1611 | + **kwargs: The HTTP or HTTPS page URL to fetch. |
| 1612 | +
|
| 1613 | + Returns: |
| 1614 | + The page URL, title and Markdown content, or an input/configuration error. |
| 1615 | +
|
| 1616 | + Raises: |
| 1617 | + RuntimeError: If the DeepSeek API returns an HTTP error. |
| 1618 | + """ |
| 1619 | + _, provider_settings, _ = _get_runtime(context) |
| 1620 | + if not provider_settings.get("websearch_deepseek_key", []): |
| 1621 | + return "Error: DeepSeek API key is not configured in AstrBot." |
| 1622 | + |
| 1623 | + url = kwargs.get("url") |
| 1624 | + if not isinstance(url, str) or not 1 <= len(url) <= 8192: |
| 1625 | + return "Error: url must be a string of 1-8192 characters." |
| 1626 | + try: |
| 1627 | + parsed = urlsplit(url) |
| 1628 | + # Port syntax and range are validated only when this property is read. |
| 1629 | + _ = parsed.port |
| 1630 | + valid_url = ( |
| 1631 | + parsed.scheme in {"http", "https"} |
| 1632 | + and bool(parsed.hostname) |
| 1633 | + and parsed.username is None |
| 1634 | + and parsed.password is None |
| 1635 | + and not any(char.isspace() for char in url) |
| 1636 | + ) |
| 1637 | + except ValueError: |
| 1638 | + valid_url = False |
| 1639 | + if not valid_url: |
| 1640 | + return "Error: url must be an HTTP or HTTPS URL with a host and no embedded credentials." |
| 1641 | + |
| 1642 | + key = await _DEEPSEEK_KEY_ROTATOR.get(provider_settings) |
| 1643 | + headers = { |
| 1644 | + "Authorization": f"Bearer {key}", |
| 1645 | + "Content-Type": "application/json", |
| 1646 | + } |
| 1647 | + async with aiohttp.ClientSession(trust_env=True) as session: |
| 1648 | + async with session.post( |
| 1649 | + "https://api.deepseek.com/v1/tools/fetch", |
| 1650 | + json={"url": url}, |
| 1651 | + headers=headers, |
| 1652 | + ) as response: |
| 1653 | + if response.status != 200: |
| 1654 | + reason = await response.text() |
| 1655 | + raise RuntimeError( |
| 1656 | + f"DeepSeek fetch failed: {reason}, status: {response.status}" |
| 1657 | + ) |
| 1658 | + data = await response.json() |
| 1659 | + |
| 1660 | + if not data.get("content"): |
| 1661 | + return "Error: DeepSeek fetch does not return any content." |
| 1662 | + return f"URL: {url}\nTitle: {data.get('title', '')}\nContent: {data['content']}" |
| 1663 | + |
| 1664 | + |
1445 | 1665 | __all__ = [ |
1446 | 1666 | "AnySearchWebSearchTool", |
1447 | 1667 | "BaiduWebSearchTool", |
1448 | 1668 | "BochaWebSearchTool", |
1449 | 1669 | "BraveWebSearchTool", |
| 1670 | + "DeepSeekFetchWebPageTool", |
| 1671 | + "DeepSeekWebSearchTool", |
1450 | 1672 | "ExaGetContentsTool", |
1451 | 1673 | "ExaWebSearchTool", |
1452 | 1674 | "TavilyExtractWebPageTool", |
|
0 commit comments