omnigent-ai--omnigent
137 行
4.7 KiB
Python
137 行
4.7 KiB
Python
"""Built-in tool: Keenable web search.
|
|
|
|
Uses Keenable's agent-optimized search endpoint (``POST /v1/search``) to
|
|
return a list of grounded results (title, URL, description). Good for
|
|
non-OpenAI models (Anthropic, Llama, Databricks-hosted, etc.) that cannot
|
|
use OpenAI's native ``web_search_preview``.
|
|
|
|
Unlike the other backends, Keenable works **without an API key**: with no
|
|
``api_key`` in the spec it calls the keyless public endpoint
|
|
(``/v1/search/public``), so it runs out of the box. Supplying an ``api_key``
|
|
switches to the authenticated endpoint (``/v1/search``) and lifts rate limits.
|
|
|
|
Configured in the agent spec::
|
|
|
|
tools:
|
|
builtins:
|
|
- name: web_search
|
|
search_provider: keenable
|
|
# api_key is optional — omit it to use the keyless free tier:
|
|
# api_key: ${KEENABLE_API_KEY}
|
|
# max_results: 5 # 1-20 (default 5)
|
|
|
|
See https://docs.keenable.ai
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
|
|
# Any: Keenable's JSON response is a heterogeneous dict with string keys
|
|
# and mixed value types (str, list, dict, None).
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_DEFAULT_KEENABLE_URL = "https://api.keenable.ai"
|
|
|
|
# Default number of results when the spec does not set ``max_results``.
|
|
# Keenable returns a ranked list; we slice client-side to this many.
|
|
_DEFAULT_MAX_RESULTS: int = 5
|
|
|
|
# Identifies this integration to Keenable via the ``X-Keenable-Title`` header
|
|
# so traffic from the Omnigent provider is attributable.
|
|
_CLIENT_TITLE = "Omnigent"
|
|
|
|
|
|
def _keenable_base_url() -> str:
|
|
"""Resolve the Keenable base URL; ``OMNIGENT_KEENABLE_BASE_URL`` overrides for tests."""
|
|
return os.environ.get("OMNIGENT_KEENABLE_BASE_URL", _DEFAULT_KEENABLE_URL).rstrip("/")
|
|
|
|
|
|
def _resolve_max_results(config: dict[str, str]) -> int:
|
|
"""
|
|
Read ``max_results`` from spec config, clamped to a 1-20 range.
|
|
|
|
:param config: Spec-level config; ``max_results`` may be a str or int.
|
|
:returns: A valid result count, or the default on missing/invalid input.
|
|
"""
|
|
raw = config.get("max_results")
|
|
if raw is None:
|
|
return _DEFAULT_MAX_RESULTS
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError):
|
|
return _DEFAULT_MAX_RESULTS
|
|
return max(1, min(value, 20))
|
|
|
|
|
|
def _search_keenable(
|
|
query: str,
|
|
config: dict[str, str],
|
|
) -> str:
|
|
"""
|
|
Call the Keenable web search API and format the results.
|
|
|
|
Keyless by default: with no ``api_key`` the public endpoint is used.
|
|
With an ``api_key`` the authenticated endpoint is used and the key is
|
|
sent in the ``X-API-Key`` header.
|
|
|
|
:param query: The search query string.
|
|
:param config: Spec-level config; checks ``api_key`` and ``max_results``
|
|
(both optional).
|
|
:returns: Formatted results or an error message.
|
|
"""
|
|
api_key = (config.get("api_key") or "").strip()
|
|
# Keyed endpoint with a key; keyless public endpoint without one.
|
|
path = "/v1/search" if api_key else "/v1/search/public"
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Keenable-Title": _CLIENT_TITLE,
|
|
}
|
|
if api_key:
|
|
headers["X-API-Key"] = api_key
|
|
try:
|
|
resp = httpx.post(
|
|
f"{_keenable_base_url()}{path}",
|
|
headers=headers,
|
|
json={"query": query, "mode": "pro"},
|
|
timeout=30.0,
|
|
)
|
|
resp.raise_for_status()
|
|
except httpx.HTTPStatusError as exc:
|
|
return f"Keenable search error: HTTP {exc.response.status_code}"
|
|
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
return f"Keenable search error: {exc}"
|
|
|
|
return _format_results(resp.json(), _resolve_max_results(config))
|
|
|
|
|
|
def _format_results(data: dict[str, Any], max_results: int) -> str:
|
|
"""
|
|
Format Keenable's ``/v1/search`` JSON response into readable text.
|
|
|
|
Keenable returns ``{"results": [{"title", "url", "description", ...}]}``.
|
|
The list is sliced to ``max_results`` and rendered as numbered entries.
|
|
|
|
:param data: The parsed JSON response from Keenable.
|
|
:param max_results: Maximum number of results to render.
|
|
:returns: Numbered results, or a "no results" message.
|
|
"""
|
|
results = data.get("results") if isinstance(data, dict) else None
|
|
if not isinstance(results, list) or not results:
|
|
return "No results found."
|
|
|
|
formatted: list[str] = []
|
|
for i, item in enumerate(results[:max_results]):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
title = item.get("title", "")
|
|
url = item.get("url", "")
|
|
snippet = item.get("description") or ""
|
|
formatted.append(f"{i + 1}. {title}\n {url}\n {snippet}")
|
|
return "\n\n".join(formatted) if formatted else "No results found."
|