Source code for smarter.apps.plugin.manifest.models.websearch_plugin.spec

"""
Smarter API Manifest - WebsearchPlugin.spec.

A WebsearchPlugin gives the LLM access to the open web, through one tool that performs two
operations, like the web search and web fetch tools of Claude itself:

- **search** the web for a query, via a web search API, and return ranked results, each
  with a title, URL and snippet, for the LLM to cite.
- **fetch** a web page by URL, and return its main content as Markdown.

``spec.websearchData`` configures each operation. Include a ``search`` section to enable
search, and a ``fetch`` section to enable fetch. At least one is required.

.. code-block:: yaml

    websearchData:
      search:
        provider: brave            # brave or tavily
        apiKey: brave_search_key   # the name of a Smarter Secret containing the api key
        maxResults: 5
        safeSearch: moderate
        country: us
        language: en
        freshness: null            # day, week, month or year
      fetch:
        maxCharacters: 20000
        respectRobotsTxt: true
      allowedDomains: []           # if not empty, only these domains may be searched and read
      blockedDomains: []           # these domains are never searched nor read
      timeout: 15
      cacheTtl: 3600

.. note::

    **Experimental.** The WebsearchPlugin was designed and coded by Claude Code (Anthropic's
    Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
    be documented.
"""

import os
import re
from typing import ClassVar, Literal, Optional

from pydantic import Field, field_validator, model_validator

from smarter.apps.plugin.const import PluginCacheTtl
from smarter.apps.plugin.manifest.models.common.plugin.spec import SAMPluginCommonSpec
from smarter.lib.manifest.exceptions import SAMValidationError
from smarter.lib.manifest.models import SmarterBasePydanticModel

from .const import (
    DEFAULT_FETCH_CHARACTERS,
    DEFAULT_MAX_RESULTS,
    DEFAULT_TIMEOUT,
    MANIFEST_KIND,
    MAX_CACHE_TTL,
    MAX_FETCH_CHARACTERS,
    MAX_RESULTS,
    MAX_TIMEOUT,
    MIN_FETCH_CHARACTERS,
)
from .enum import WebsearchFreshness, WebsearchProvider, WebsearchSafeSearch
from .policy import DomainPolicy, WebsearchPolicyError, normalize_domains

filename = os.path.splitext(os.path.basename(__file__))[0]
MODULE_IDENTIFIER = f"{MANIFEST_KIND}.{filename}"

COUNTRY_PATTERN = re.compile(r"^[a-z]{2}$")
LANGUAGE_PATTERN = re.compile(r"^[a-z]{2}$")


[docs] class WebsearchSearch(SmarterBasePydanticModel): """Smarter API - WebsearchPlugin.spec.websearchData.search: web search, via a web search API.""" class_identifier: ClassVar[str] = f"{MODULE_IDENTIFIER}.search" provider: Literal[tuple(WebsearchProvider.all())] = Field( # type: ignore[valid-type] ..., description=f"{class_identifier}.provider[str]: the web search API. One of: {WebsearchProvider.all()}", ) apiKey: str = Field( ..., description=( f"{class_identifier}.apiKey[str]: the name of the Smarter Secret that contains the web search API's " "api key. The api key itself is never included in the manifest." ), ) maxResults: int = Field( default=DEFAULT_MAX_RESULTS, ge=1, le=MAX_RESULTS, description=f"{class_identifier}.maxResults[int]: the maximum number of results per search, 1 to {MAX_RESULTS}.", ) safeSearch: Literal[tuple(WebsearchSafeSearch.all())] = Field( # type: ignore[valid-type] default=WebsearchSafeSearch.MODERATE.value, description=f"{class_identifier}.safeSearch[str]: adult content filtering. One of: {WebsearchSafeSearch.all()}", ) country: Optional[str] = Field( default=None, description=f"{class_identifier}.country[str]: an ISO 3166-1 alpha-2 country code, e.g. us, to localize results.", ) language: Optional[str] = Field( default=None, description=f"{class_identifier}.language[str]: an ISO 639-1 language code, e.g. en, to localize results.", ) freshness: Optional[Literal[tuple(WebsearchFreshness.all())]] = Field( # type: ignore[valid-type] default=None, description=( f"{class_identifier}.freshness[str]: by default, restrict results to those published within the last " f"{WebsearchFreshness.all()}. The LLM can override this per search." ), )
[docs] @field_validator("provider", mode="before") @classmethod def validate_provider(cls, v: str) -> str: """Validate the provider.""" v = str(v).strip().lower() if v not in WebsearchProvider.all(): raise SAMValidationError(f"{cls.class_identifier}.provider must be one of {WebsearchProvider.all()}: {v}") return v
[docs] @field_validator("apiKey") @classmethod def validate_api_key(cls, v: str) -> str: """Validate that the api key is the name of a Secret, not empty.""" if not isinstance(v, str) or not v.strip(): raise SAMValidationError(f"{cls.class_identifier}.apiKey must be the name of a Smarter Secret.") return v.strip()
[docs] @field_validator("country") @classmethod def validate_country(cls, v: Optional[str]) -> Optional[str]: """Validate the country code.""" if v is None: return None v = str(v).strip().lower() if not COUNTRY_PATTERN.match(v): raise SAMValidationError(f"{cls.class_identifier}.country must be a two letter country code: {v}") return v
[docs] @field_validator("language") @classmethod def validate_language(cls, v: Optional[str]) -> Optional[str]: """Validate the language code.""" if v is None: return None v = str(v).strip().lower() if not LANGUAGE_PATTERN.match(v): raise SAMValidationError(f"{cls.class_identifier}.language must be a two letter language code: {v}") return v
[docs] @field_validator("freshness", mode="before") @classmethod def validate_freshness(cls, v: Optional[str]) -> Optional[str]: """Validate the default freshness.""" if v is None: return None v = str(v).strip().lower() if v not in WebsearchFreshness.all(): raise SAMValidationError(f"{cls.class_identifier}.freshness must be one of {WebsearchFreshness.all()}: {v}") return v
[docs] class WebsearchFetch(SmarterBasePydanticModel): """Smarter API - WebsearchPlugin.spec.websearchData.fetch: reading web pages by URL.""" class_identifier: ClassVar[str] = f"{MODULE_IDENTIFIER}.fetch" maxCharacters: int = Field( default=DEFAULT_FETCH_CHARACTERS, ge=MIN_FETCH_CHARACTERS, le=MAX_FETCH_CHARACTERS, description=( f"{class_identifier}.maxCharacters[int]: the maximum number of characters of a web page to return, " f"{MIN_FETCH_CHARACTERS} to {MAX_FETCH_CHARACTERS}. Longer pages are truncated." ), ) respectRobotsTxt: bool = Field( default=True, description=f"{class_identifier}.respectRobotsTxt[bool]: whether to obey websites' robots.txt files.", )
[docs] class WebsearchData(SmarterBasePydanticModel): """Smarter API - WebsearchPlugin.spec.websearchData.""" class_identifier: ClassVar[str] = MODULE_IDENTIFIER search: Optional[WebsearchSearch] = Field( default=None, description=f"{class_identifier}.search[obj]: enables web search. Requires a web search API key.", ) fetch: Optional[WebsearchFetch] = Field( default=None, description=f"{class_identifier}.fetch[obj]: enables reading web pages by URL.", ) allowedDomains: Optional[list[str]] = Field( default=None, description=( f"{class_identifier}.allowedDomains[list]: if not empty, only these domains, and their subdomains, may " "be searched and read." ), ) blockedDomains: Optional[list[str]] = Field( default=None, description=f"{class_identifier}.blockedDomains[list]: these domains, and their subdomains, are never searched nor read.", ) timeout: int = Field( default=DEFAULT_TIMEOUT, ge=1, le=MAX_TIMEOUT, description=f"{class_identifier}.timeout[int]: seconds to wait for each web request, 1 to {MAX_TIMEOUT}.", ) cacheTtl: int = Field( default=PluginCacheTtl.WEBSEARCH, ge=0, le=MAX_CACHE_TTL, description=f"{class_identifier}.cacheTtl[int]: seconds to cache search results and web pages. 0 disables caching.", )
[docs] @field_validator("allowedDomains", "blockedDomains") @classmethod def validate_domains(cls, v: Optional[list[str]]) -> Optional[list[str]]: """Validate and normalize domain names.""" if v is None: return None try: return list(normalize_domains(v)) except WebsearchPolicyError as e: raise SAMValidationError(f"{cls.class_identifier}: {e}") from e
[docs] @model_validator(mode="after") def validate_websearch_data(self) -> "WebsearchData": """Validate that at least one operation is enabled.""" if not self.search and not self.fetch: raise SAMValidationError( f"{self.class_identifier}: at least one of 'search' or 'fetch' is required, to enable web search or " "reading web pages." ) return self
@property def domain_policy(self) -> DomainPolicy: """The domain policy.""" return DomainPolicy.create(allowed=self.allowedDomains, blocked=self.blockedDomains)
[docs] class SAMWebsearchPluginSpec(SAMPluginCommonSpec): """Smarter API Manifest - WebsearchPlugin.spec.""" class_identifier: ClassVar[str] = MODULE_IDENTIFIER websearchData: WebsearchData = Field( ..., description=f"{class_identifier}.websearchData[obj]: the web search and web fetch configuration of the {MANIFEST_KIND}.", )