diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0512e04..b446576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: exit 1 fi - - name: Test real web, Maps, News, and Shopping responses + - name: Test real web, Maps, News, Shopping, Hotels, Flights, and Travel Explore responses run: uv run pytest -m live package: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2291f98..eb99a72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,7 +67,7 @@ jobs: exit 1 fi - - name: Test real web, Maps, News, and Shopping responses + - name: Test real web, Maps, News, Shopping, Hotels, Flights, and Travel Explore responses run: uv run pytest -m live build: diff --git a/README.md b/README.md index dc0cc39..b1b396b 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,13 @@ [![Python versions](https://img.shields.io/pypi/pyversions/serpapi-hermes-plugin.svg)](https://pypi.org/project/serpapi-hermes-plugin/) [![License: MIT](https://img.shields.io/pypi/l/serpapi-hermes-plugin.svg)](https://github.com/serpapi/serpapi-hermes-plugin/blob/main/LICENSE) -Give [Hermes Agent](https://hermes-agent.nousresearch.com/) fast, fresh search -results from the web, Google Maps, Google News, and Google Shopping with -[SerpApi](https://serpapi.com/). +Give [Hermes Agent](https://hermes-agent.nousresearch.com/) fresh web, local, news, shopping, hotel, flight, and destination results with [SerpApi](https://serpapi.com/). The plugin adds SerpApi to Hermes in two ways: - Hermes's built-in `web_search` uses the fast Google Light engine. -- Dedicated Maps, News, and Shopping tools let Hermes choose the right SerpApi - engine for local places, current reporting, and product searches. +- Dedicated Maps, News, Shopping, Hotels, Flights, and Travel Explore tools let Hermes choose the right SerpApi engine for each request. +- Direct SerpApi tools return token-efficient Markdown by default, including tables, links, and YAML frontmatter designed for agents. ## Ask Hermes to install it @@ -45,8 +43,8 @@ dashboard. Do not ask for the key until installation and enablement succeed. After I provide it, save it as SERPAPI_API_KEY in ~/.hermes/.env without printing, logging, or committing it. Configure SerpApi as the Hermes web search backend, tell me whether Hermes must be restarted, and verify that web search, -Maps, News, and Shopping tools are available. Use this key for future SerpApi -searches and never expose it in output. +Maps, News, Shopping, Hotels, Flights, and Travel Explore tools are available. +Use this key for future SerpApi searches and never expose it in output. ``` ## Install @@ -142,15 +140,21 @@ The environment variable takes precedence over the value in `~/.hermes/.env`. ## Search capabilities +Hermes's built-in `web_search` contract requires structured web records, so the plugin requests JSON for that provider and converts it to Hermes's standard response. The six directly registered SerpApi tools request [`output=md`](https://serpapi.com/search-api#api-parameters-output) by default and return SerpApi's Markdown without reparsing it. This preserves result tables and links while using fewer tokens than full JSON. + +Each direct tool also accepts `output: "json"` when the agent needs structured fields such as a Google Flights `departure_token`. Markdown remains the schema default. + | What you ask for | Hermes tool | SerpApi engine | |---|---|---| | General web research | `web_search` | `google_light` | | Places and local businesses | `serpapi_maps_search` | `google_maps` | | Current and recent news | `serpapi_news_search` | `google_news_light` | | Products, prices, and merchants | `serpapi_shopping_search` | `google_shopping_light` | +| Hotels and vacation rentals | `serpapi_hotels_search` | [`google_hotels`](https://serpapi.com/google-hotels-api) | +| Fixed-route flight fares | `serpapi_flights_search` | [`google_flights`](https://serpapi.com/google-flights-api) | +| Flexible destinations and dates | `serpapi_travel_explore_search` | [`google_travel_explore`](https://serpapi.com/google-travel-explore-api) | -Hermes chooses a tool from your request. Each tool selects and validates its own -SerpApi engine, so you do not need to specify an engine name. +Hermes chooses a tool from your request. Each tool selects and validates its own SerpApi engine, so you do not need to specify an engine name. Flight and Travel Explore location fields accept individual uppercase airport codes such as `LHR`, `CDG`, or `AUS`, as well as `/m/` or `/g/` location KGMIDs. Use Travel Explore when the traveler has a city or region in mind but no exact airport. Example prompts: @@ -158,6 +162,11 @@ Example prompts: - "Find highly rated coffee shops near Times Square." - "Show me recent news about reusable rockets." - "Find well-reviewed laptops under $1,200 with free shipping." +- "Find four-star hotels in Kyoto for October 10 to October 15." +- "Compare nonstop business-class flights from JFK to LAX next month." +- "Where can I go from Bengaluru for a one-week beach trip in December?" + +Google Flights returns outbound choices first for round trips. To inspect return-flight choices, call `serpapi_flights_search` with `output: "json"`, select a `departure_token`, then call the tool again with that token and the same route and dates. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index f49cb9f..3573aa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,15 +5,27 @@ build-backend = "setuptools.build_meta" [project] name = "serpapi-hermes-plugin" version = "0.1.1" -description = "SerpApi search plugin for Hermes Agent: web, Maps, News, and Shopping" +description = "SerpApi search plugin for Hermes Agent: web, Maps, News, Shopping, Hotels, Flights, and Travel Explore" readme = "README.md" requires-python = ">=3.11,<3.15" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "SerpApi" }] -keywords = ["hermes-agent", "plugin", "serpapi", "web-search", "maps", "news", "shopping"] +keywords = [ + "hermes-agent", + "plugin", + "serpapi", + "web-search", + "maps", + "news", + "shopping", + "hotels", + "flights", + "travel", + "markdown", +] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Environment :: Plugins", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", @@ -24,6 +36,7 @@ classifiers = [ ] dependencies = [ "httpx>=0.28.1,<1", + "markdown-it-py>=4.2,<5", ] [project.entry-points."hermes_agent.plugins"] diff --git a/src/serpapi_hermes_plugin/__init__.py b/src/serpapi_hermes_plugin/__init__.py index 7dc6ef1..1d93305 100644 --- a/src/serpapi_hermes_plugin/__init__.py +++ b/src/serpapi_hermes_plugin/__init__.py @@ -1,10 +1,24 @@ -"""SerpApi web, Maps, News, and Shopping search for Hermes Agent.""" +"""SerpApi web, Maps, News, Shopping, Hotels, Flights, and Travel Explore for Hermes Agent.""" from __future__ import annotations from .provider import SerpApiWebSearchProvider -from .schemas import MAPS_SEARCH_SCHEMA, NEWS_SEARCH_SCHEMA, SHOPPING_SEARCH_SCHEMA -from .tools import maps_search, news_search, shopping_search +from .schemas import ( + FLIGHTS_SEARCH_SCHEMA, + HOTELS_SEARCH_SCHEMA, + MAPS_SEARCH_SCHEMA, + NEWS_SEARCH_SCHEMA, + SHOPPING_SEARCH_SCHEMA, + TRAVEL_EXPLORE_SEARCH_SCHEMA, +) +from .tools import ( + flights_search, + hotels_search, + maps_search, + news_search, + shopping_search, + travel_explore_search, +) __all__ = ["SerpApiWebSearchProvider", "register"] @@ -18,6 +32,9 @@ def register(ctx) -> None: (MAPS_SEARCH_SCHEMA, maps_search), (NEWS_SEARCH_SCHEMA, news_search), (SHOPPING_SEARCH_SCHEMA, shopping_search), + (HOTELS_SEARCH_SCHEMA, hotels_search), + (FLIGHTS_SEARCH_SCHEMA, flights_search), + (TRAVEL_EXPLORE_SEARCH_SCHEMA, travel_explore_search), ): ctx.register_tool( name=schema["name"], diff --git a/src/serpapi_hermes_plugin/client.py b/src/serpapi_hermes_plugin/client.py index f85d102..6f564fc 100644 --- a/src/serpapi_hermes_plugin/client.py +++ b/src/serpapi_hermes_plugin/client.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) API_KEY_ENV = "SERPAPI_API_KEY" -ENDPOINT = "https://serpapi.com/search.json" +ENDPOINT = "https://serpapi.com/search" TIMEOUT_SECONDS = 15.0 @@ -37,8 +37,30 @@ def _safe_api_error(value: Any, api_key: str) -> str: return message[:500] -def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]: - """Call one SerpApi engine and return its decoded JSON response.""" +def _response_api_error(response: httpx.Response, api_key: str) -> str | None: + """Read a safe error message from a SerpApi JSON response.""" + try: + payload = response.json() + except ValueError: + return None + if not isinstance(payload, dict) or not payload.get("error"): + return None + return _safe_api_error(payload["error"], api_key) + + +def _redact_response(value: Any, api_key: str) -> Any: + """Remove the credential from any response text before returning it to Hermes.""" + if isinstance(value, str): + return value.replace(api_key, "[redacted]") + if isinstance(value, list): + return [_redact_response(item, api_key) for item in value] + if isinstance(value, dict): + return {key: _redact_response(item, api_key) for key, item in value.items()} + return value + + +def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any] | str: + """Call one SerpApi engine and return Markdown by default or decoded JSON.""" api_key = get_api_key() if not api_key: raise SerpApiError(f"{API_KEY_ENV} is not set. Run `hermes tools` to configure SerpApi.") @@ -48,20 +70,17 @@ def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]: for key, value in params.items() if key not in {"api_key", "engine"} and value is not None and value != "" } - request_params.update( - { - "engine": engine, - "api_key": api_key, - "output": "json", - } - ) + output = str(request_params.get("output") or "md").strip().lower() + if output not in {"json", "md"}: + raise SerpApiError("output must be 'md' or 'json'") + request_params.update({"engine": engine, "api_key": api_key, "output": output}) try: response = httpx.get( ENDPOINT, params=request_params, headers={ - "Accept": "application/json", + "Accept": "text/markdown" if output == "md" else "application/json", "X-Client-Source": "hermes", }, timeout=TIMEOUT_SECONDS, @@ -76,6 +95,10 @@ def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]: message = "SerpApi quota exhausted; try again later" elif status >= 500: message = f"SerpApi upstream error (HTTP {status}); try again shortly" + elif 400 <= status < 500: + message = _response_api_error(exc.response, api_key) or ( + f"SerpApi request failed (HTTP {status})" + ) else: message = f"SerpApi request failed (HTTP {status})" raise SerpApiError(message) from None @@ -83,11 +106,18 @@ def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]: logger.warning("SerpApi request failed (%s)", type(exc).__name__) raise SerpApiError("Could not reach SerpApi; try again shortly") from None + if output == "md" and "application/json" not in response.headers.get("content-type", ""): + markdown = response.text.replace(api_key, "[redacted]").strip() + if not markdown: + raise SerpApiError("SerpApi returned an empty Markdown response") + return markdown + try: - payload = response.json() + payload = _redact_response(response.json(), api_key) except ValueError: - logger.warning("SerpApi returned malformed JSON") - raise SerpApiError("SerpApi returned malformed JSON") from None + response_name = "Markdown" if output == "md" else "JSON" + logger.warning("SerpApi returned malformed %s", response_name) + raise SerpApiError(f"SerpApi returned malformed {response_name}") from None if not isinstance(payload, dict): raise SerpApiError("SerpApi returned an unexpected response") @@ -95,4 +125,7 @@ def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]: if payload.get("error"): raise SerpApiError(_safe_api_error(payload["error"], api_key)) + if output == "md": + raise SerpApiError("SerpApi returned an unexpected Markdown response") + return payload diff --git a/src/serpapi_hermes_plugin/markdown.py b/src/serpapi_hermes_plugin/markdown.py new file mode 100644 index 0000000..0a3edc3 --- /dev/null +++ b/src/serpapi_hermes_plugin/markdown.py @@ -0,0 +1,68 @@ +"""Structure-aware transformations for SerpApi Markdown responses.""" + +from __future__ import annotations + +import re + +from markdown_it import MarkdownIt + +_PARSER = MarkdownIt("commonmark").enable("table") + + +def _normalized_heading(value: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", value.casefold()).strip() + + +def _matches_heading(value: str, expected: str) -> bool: + normalized = _normalized_heading(value) + return normalized == expected or normalized.startswith(f"{expected} ") + + +def limit_result_table(markdown: str, *, heading: str, limit: int) -> str: + """Limit table body rows under a named Markdown heading. + + Source line mappings from markdown-it-py let us remove excess rows without + rendering the document again, so unrelated formatting remains unchanged. + """ + tokens = _PARSER.parse(markdown) + expected_heading = _normalized_heading(heading) + section_level: int | None = None + in_table_body = False + result_count = 0 + removed_lines: set[int] = set() + + for index, token in enumerate(tokens): + if token.type == "heading_open": + level = int(token.tag.removeprefix("h")) + if section_level is not None and level <= section_level: + break + + next_token = tokens[index + 1] if index + 1 < len(tokens) else None + if ( + section_level is None + and next_token is not None + and next_token.type == "inline" + and _matches_heading(next_token.content, expected_heading) + ): + section_level = level + continue + + if section_level is None: + continue + if token.type == "tbody_open": + in_table_body = True + elif token.type == "tbody_close": + in_table_body = False + elif token.type == "tr_open" and in_table_body and token.map is not None: + result_count += 1 + if result_count > limit: + removed_lines.update(range(*token.map)) + + if not removed_lines: + return markdown + + return "".join( + line + for line_number, line in enumerate(markdown.splitlines(keepends=True)) + if line_number not in removed_lines + ) diff --git a/src/serpapi_hermes_plugin/plugin.yaml b/src/serpapi_hermes_plugin/plugin.yaml index d854e93..c3d16fc 100644 --- a/src/serpapi_hermes_plugin/plugin.yaml +++ b/src/serpapi_hermes_plugin/plugin.yaml @@ -1,6 +1,6 @@ name: serpapi -version: 0.1.0 -description: "SerpApi search for Hermes Agent: web, Maps, News, and Shopping." +version: 0.1.1 +description: "SerpApi web, Maps, News, Shopping, Hotels, Flights, and Travel Explore for Hermes Agent." author: SerpApi kind: backend provides_web_providers: @@ -9,8 +9,11 @@ provides_tools: - serpapi_maps_search - serpapi_news_search - serpapi_shopping_search + - serpapi_hotels_search + - serpapi_flights_search + - serpapi_travel_explore_search requires_env: - name: SERPAPI_API_KEY - description: "API key for SerpApi web search" + description: "API key for SerpApi search" url: "https://serpapi.com/manage-api-key" secret: true diff --git a/src/serpapi_hermes_plugin/provider.py b/src/serpapi_hermes_plugin/provider.py index 7e8b633..9971103 100644 --- a/src/serpapi_hermes_plugin/provider.py +++ b/src/serpapi_hermes_plugin/provider.py @@ -53,6 +53,7 @@ def search(self, query: str, limit: int = 5) -> dict[str, Any]: { "q": query, "num": result_limit, + "output": "json", }, ) except SerpApiError as exc: diff --git a/src/serpapi_hermes_plugin/schemas.py b/src/serpapi_hermes_plugin/schemas.py index 9e5c013..7cc546b 100644 --- a/src/serpapi_hermes_plugin/schemas.py +++ b/src/serpapi_hermes_plugin/schemas.py @@ -2,6 +2,16 @@ from __future__ import annotations +_OUTPUT_PROPERTY = { + "type": "string", + "enum": ["md", "json"], + "default": "md", + "description": ( + "Response format. Markdown is the default and is optimized for Hermes. " + "Use JSON only when structured data is required." + ), +} + MAPS_SEARCH_SCHEMA = { "name": "serpapi_maps_search", "description": ( @@ -59,6 +69,7 @@ "default": 5, "description": "Maximum number of places to return.", }, + "output": _OUTPUT_PROPERTY, }, "required": ["query"], "additionalProperties": False, @@ -97,6 +108,7 @@ "default": 5, "description": "Maximum number of news results to return.", }, + "output": _OUTPUT_PROPERTY, }, "required": ["query"], "additionalProperties": False, @@ -161,8 +173,352 @@ "default": 5, "description": "Maximum number of products to return.", }, + "output": _OUTPUT_PROPERTY, }, "required": ["query"], "additionalProperties": False, }, } + +HOTELS_SEARCH_SCHEMA = { + "name": "serpapi_hotels_search", + "description": ( + "Search Google Hotels through SerpApi for hotels, vacation rentals, nightly rates, " + "ratings, amenities, and booking options. Use this for stays with known check-in and " + "check-out dates." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Destination, neighborhood, landmark, or property name.", + }, + "check_in_date": { + "type": "string", + "format": "date", + "description": "Check-in date in YYYY-MM-DD format.", + }, + "check_out_date": { + "type": "string", + "format": "date", + "description": "Check-out date in YYYY-MM-DD format, after check_in_date.", + }, + "adults": { + "type": "integer", + "minimum": 1, + "maximum": 9, + "default": 2, + "description": "Number of adult guests.", + }, + "children": { + "type": "integer", + "minimum": 0, + "maximum": 9, + "default": 0, + "description": "Number of child guests.", + }, + "children_ages": { + "type": "array", + "items": {"type": "integer", "minimum": 1, "maximum": 17}, + "minItems": 1, + "maxItems": 9, + "description": ( + "Age of each child, in the same order as the child guests. " + "Required when children is greater than 0." + ), + }, + "currency": { + "type": "string", + "description": "Optional three-letter currency code, such as USD or EUR.", + }, + "language": { + "type": "string", + "description": "Optional two-letter language code, such as 'en' or 'fr'.", + }, + "country": { + "type": "string", + "description": "Optional two-letter market country code, such as 'us' or 'in'.", + }, + "minimum_price": { + "type": "number", + "minimum": 0, + "description": "Optional minimum nightly price in the selected currency.", + }, + "maximum_price": { + "type": "number", + "minimum": 0, + "description": "Optional maximum nightly price in the selected currency.", + }, + "hotel_class": { + "type": "integer", + "enum": [2, 3, 4, 5], + "description": "Optional hotel star class. Not available for vacation rentals.", + }, + "sort": { + "type": "string", + "enum": ["relevance", "lowest_price", "highest_rating", "most_reviewed"], + "default": "relevance", + "description": "How to order properties.", + }, + "vacation_rentals": { + "type": "boolean", + "default": False, + "description": ( + "Search vacation rentals instead of hotels. Do not combine with hotel_class." + ), + }, + "output": _OUTPUT_PROPERTY, + }, + "required": ["query", "check_in_date", "check_out_date"], + "additionalProperties": False, + }, +} + +FLIGHTS_SEARCH_SCHEMA = { + "name": "serpapi_flights_search", + "description": ( + "Search Google Flights through SerpApi for one-way and round-trip fares. Airport IDs " + "must be individual three-letter airport codes or location KGMIDs. For a round trip, " + "use a returned departure_token in a follow-up call to retrieve return-flight choices." + ), + "parameters": { + "type": "object", + "properties": { + "departure_id": { + "type": "string", + "description": ( + "Uppercase airport code such as LHR or a /m/ or /g/ location KGMID. " + "Comma-separated IDs are supported; city codes such as LON are unsupported." + ), + }, + "arrival_id": { + "type": "string", + "description": ( + "Uppercase airport code such as CDG or a /m/ or /g/ location KGMID. " + "Comma-separated IDs are supported; city codes such as PAR are unsupported." + ), + }, + "outbound_date": { + "type": "string", + "format": "date", + "description": "Outbound date in YYYY-MM-DD format.", + }, + "return_date": { + "type": "string", + "format": "date", + "description": "Return date for a round trip in YYYY-MM-DD format.", + }, + "trip_type": { + "type": "string", + "enum": ["one_way", "round_trip"], + "description": "Optional; inferred from whether return_date is present.", + }, + "travel_class": { + "type": "string", + "enum": ["economy", "premium_economy", "business", "first"], + "default": "economy", + "description": "Cabin class.", + }, + "adults": { + "type": "integer", + "minimum": 1, + "maximum": 9, + "default": 1, + "description": "Number of adult passengers.", + }, + "children": { + "type": "integer", + "minimum": 0, + "maximum": 9, + "default": 0, + "description": "Number of child passengers.", + }, + "infants_in_seat": { + "type": "integer", + "minimum": 0, + "maximum": 9, + "default": 0, + "description": "Number of infants traveling in their own seats.", + }, + "infants_on_lap": { + "type": "integer", + "minimum": 0, + "maximum": 9, + "default": 0, + "description": ("Number of lap infants. This cannot exceed the number of adults."), + }, + "currency": { + "type": "string", + "description": "Optional three-letter currency code, such as USD or EUR.", + }, + "language": { + "type": "string", + "description": "Optional two-letter language code, such as 'en' or 'fr'.", + }, + "country": { + "type": "string", + "description": "Optional two-letter market country code, such as 'us' or 'in'.", + }, + "stops": { + "type": "string", + "enum": ["any", "nonstop", "one_or_fewer", "two_or_fewer"], + "default": "any", + "description": "Maximum number of stops.", + }, + "sort": { + "type": "string", + "enum": [ + "top_flights", + "price", + "departure_time", + "arrival_time", + "duration", + "emissions", + ], + "default": "top_flights", + "description": "How to order itineraries.", + }, + "maximum_price": { + "type": "number", + "minimum": 0, + "description": "Optional maximum ticket price in the selected currency.", + }, + "deep_search": { + "type": "boolean", + "default": False, + "description": "Match Google Flights more closely at the cost of extra latency.", + }, + "departure_token": { + "type": "string", + "description": ( + "Token from a selected outbound itinerary for return choices. Reuse the " + "original round-trip route and dates, including return_date." + ), + }, + "output": _OUTPUT_PROPERTY, + }, + "required": ["departure_id", "arrival_id", "outbound_date"], + "additionalProperties": False, + }, +} + +TRAVEL_EXPLORE_SEARCH_SCHEMA = { + "name": "serpapi_travel_explore_search", + "description": ( + "Explore destinations and flexible trip prices through SerpApi's Google Travel Explore. " + "Use this when the traveler knows where they are leaving from but wants destination or " + "date ideas; use serpapi_flights_search for a fixed route and dates." + ), + "parameters": { + "type": "object", + "properties": { + "departure_id": { + "type": "string", + "description": ( + "Uppercase airport code or city /m/ or /g/ KGMID. Comma-separated IDs are " + "supported." + ), + }, + "arrival_id": { + "type": "string", + "description": "Optional destination airport code or city /m/ or /g/ KGMID.", + }, + "arrival_area_id": { + "type": "string", + "description": ( + "Optional region or country /m/ or /g/ KGMID. Do not combine with arrival_id." + ), + }, + "outbound_date": { + "type": "string", + "format": "date", + "description": "Optional fixed outbound date in YYYY-MM-DD format.", + }, + "return_date": { + "type": "string", + "format": "date", + "description": "Optional fixed return date in YYYY-MM-DD format.", + }, + "trip_type": { + "type": "string", + "enum": ["one_way", "round_trip"], + "description": "Optional; inferred from whether a fixed return_date is present.", + }, + "month": { + "type": "integer", + "minimum": 0, + "maximum": 12, + "description": ( + "Flexible travel month from 1 to 12; 0 searches the next six months." + ), + }, + "travel_duration": { + "type": "string", + "enum": ["weekend", "one_week", "two_weeks"], + "default": "one_week", + "description": "Trip length for flexible-date exploration.", + }, + "travel_class": { + "type": "string", + "enum": ["economy", "premium_economy", "business", "first"], + "default": "economy", + "description": "Cabin class.", + }, + "adults": { + "type": "integer", + "minimum": 1, + "maximum": 9, + "default": 1, + "description": "Number of adult travelers.", + }, + "children": { + "type": "integer", + "minimum": 0, + "maximum": 9, + "default": 0, + "description": "Number of child travelers.", + }, + "infants_in_seat": { + "type": "integer", + "minimum": 0, + "maximum": 9, + "default": 0, + "description": "Number of infants traveling in their own seats.", + }, + "infants_on_lap": { + "type": "integer", + "minimum": 0, + "maximum": 9, + "default": 0, + "description": ("Number of lap infants. This cannot exceed the number of adults."), + }, + "currency": { + "type": "string", + "description": "Optional three-letter currency code, such as USD or EUR.", + }, + "language": { + "type": "string", + "description": "Optional two-letter language code, such as 'en' or 'fr'.", + }, + "country": { + "type": "string", + "description": "Optional two-letter market country code, such as 'us' or 'in'.", + }, + "stops": { + "type": "string", + "enum": ["any", "nonstop", "one_or_fewer", "two_or_fewer"], + "default": "any", + "description": "Maximum number of stops.", + }, + "maximum_price": { + "type": "number", + "minimum": 0, + "description": "Optional maximum flight price in the selected currency.", + }, + "output": _OUTPUT_PROPERTY, + }, + "required": ["departure_id"], + "additionalProperties": False, + }, +} diff --git a/src/serpapi_hermes_plugin/tools.py b/src/serpapi_hermes_plugin/tools.py index a49ac7d..2356e1a 100644 --- a/src/serpapi_hermes_plugin/tools.py +++ b/src/serpapi_hermes_plugin/tools.py @@ -3,9 +3,11 @@ from __future__ import annotations import json +from datetime import date from typing import Any from .client import SerpApiError, call_serpapi +from .markdown import limit_result_table _MAX_RESULTS = 20 @@ -22,6 +24,13 @@ def _query(args: dict[str, Any]) -> str: return str(args.get("query") or "").strip() +def _output(args: dict[str, Any]) -> str: + output = str(args.get("output") or "md").strip().lower() + if output not in {"md", "json"}: + raise ValueError("output must be 'md' or 'json'") + return output + + def _limit(args: dict[str, Any]) -> int: try: return max(1, min(int(args.get("limit", 5)), _MAX_RESULTS)) @@ -38,6 +47,105 @@ def _code(args: dict[str, Any], name: str) -> str | None: return value +def _currency(args: dict[str, Any]) -> str | None: + value = str(args.get("currency") or "").strip().upper() + if not value: + return None + if len(value) != 3 or not value.isalpha(): + raise ValueError("currency must be a three-letter code") + return value + + +def _date(args: dict[str, Any], name: str, *, required: bool = False) -> str | None: + value = str(args.get(name) or "").strip() + if not value: + if required: + raise ValueError(f"{name} is required") + return None + try: + parsed = date.fromisoformat(value) + except ValueError: + raise ValueError(f"{name} must use YYYY-MM-DD") from None + if parsed.isoformat() != value: + raise ValueError(f"{name} must use YYYY-MM-DD") + return value + + +def _travel_id(args: dict[str, Any], name: str, *, required: bool = False) -> str | None: + value = str(args.get(name) or "").strip() + if not value: + if required: + raise ValueError(f"{name} is required") + return None + identifiers = [identifier.strip() for identifier in value.split(",")] + if any( + not identifier + or not ( + (len(identifier) == 3 and identifier.isalpha() and identifier.isupper()) + or identifier.startswith(("/m/", "/g/")) + ) + for identifier in identifiers + ): + raise ValueError( + f"{name} must contain uppercase three-letter airport codes or /m/ or /g/ KGMIDs" + ) + return ",".join(identifiers) + + +def _passengers( + args: dict[str, Any], *, adults_default: int = 1, include_infants: bool = False +) -> dict[str, int]: + values: dict[str, int] = {} + passenger_fields = [("adults", adults_default), ("children", 0)] + if include_infants: + passenger_fields.extend((("infants_in_seat", 0), ("infants_on_lap", 0))) + + for name, default in passenger_fields: + raw_value = args.get(name) + if raw_value is None: + values[name] = default + continue + value = int(raw_value) + minimum = 1 if name == "adults" else 0 + if value < minimum or value > 9: + raise ValueError(f"{name} must be between {minimum} and 9") + values[name] = value + + if include_infants: + if sum(values.values()) > 9: + raise ValueError("total number of passengers must not exceed 9") + if values["infants_on_lap"] > values["adults"]: + raise ValueError("infants_on_lap must not exceed the number of adults") + + return values + + +def _children_ages(args: dict[str, Any], children: int) -> str | None: + raw_ages = args.get("children_ages") + if raw_ages is None: + if children: + raise ValueError("children_ages must contain one age per child") + return None + if not isinstance(raw_ages, list): + raise ValueError("children_ages must be an array of ages") + if len(raw_ages) != children: + raise ValueError("children_ages must contain one age per child") + + ages: list[int] = [] + for raw_age in raw_ages: + if isinstance(raw_age, bool): + raise ValueError("children_ages must contain ages from 1 to 17") + try: + age = int(raw_age) + except (TypeError, ValueError): + raise ValueError("children_ages must contain ages from 1 to 17") from None + if age < 1 or age > 17: + raise ValueError("children_ages must contain ages from 1 to 17") + ages.append(age) + + return ",".join(str(age) for age in ages) or None + + def _optional_fields(item: dict[str, Any], names: tuple[str, ...]) -> dict[str, Any]: return {name: item[name] for name in names if item.get(name) not in (None, "", [])} @@ -49,6 +157,11 @@ def maps_search(args: dict[str, Any], **kwargs: Any) -> str: if not query: return _error("Search query must not be empty") + try: + output = _output(args) + except ValueError as exc: + return _error(str(exc)) + location = str(args.get("location") or "").strip() latitude = args.get("latitude") longitude = args.get("longitude") @@ -64,6 +177,7 @@ def maps_search(args: dict[str, Any], **kwargs: Any) -> str: "type": "search", "hl": _code(args, "language"), "gl": _code(args, "country"), + "output": output, } if location: params.update({"location": location, "z": int(args.get("zoom", 14))}) @@ -85,6 +199,9 @@ def maps_search(args: dict[str, Any], **kwargs: Any) -> str: except SerpApiError as exc: return _error(str(exc)) + if isinstance(payload, str): + return limit_result_table(payload, heading="Local Results", limit=_limit(args)) + results = [] raw_results = payload.get("local_results", []) if not isinstance(raw_results, list): @@ -134,6 +251,7 @@ def news_search(args: dict[str, Any], **kwargs: Any) -> str: return _error("Search query must not be empty") try: + output = _output(args) payload = call_serpapi( "google_news_light", { @@ -141,6 +259,7 @@ def news_search(args: dict[str, Any], **kwargs: Any) -> str: "location": str(args.get("location") or "").strip(), "hl": _code(args, "language"), "gl": _code(args, "country"), + "output": output, }, ) except (TypeError, ValueError) as exc: @@ -148,6 +267,9 @@ def news_search(args: dict[str, Any], **kwargs: Any) -> str: except SerpApiError as exc: return _error(str(exc)) + if isinstance(payload, str): + return limit_result_table(payload, heading="News Results", limit=_limit(args)) + results = [] raw_results = payload.get("news_results", []) if not isinstance(raw_results, list): @@ -180,6 +302,11 @@ def shopping_search(args: dict[str, Any], **kwargs: Any) -> str: if not query: return _error("Search query must not be empty") + try: + output = _output(args) + except ValueError as exc: + return _error(str(exc)) + minimum_price = args.get("minimum_price") maximum_price = args.get("maximum_price") if minimum_price is not None and maximum_price is not None: @@ -207,6 +334,7 @@ def shopping_search(args: dict[str, Any], **kwargs: Any) -> str: "sort_by": sort_by, "free_shipping": "true" if args.get("free_shipping") else None, "on_sale": "true" if args.get("on_sale") else None, + "output": output, }, ) except (TypeError, ValueError) as exc: @@ -214,6 +342,9 @@ def shopping_search(args: dict[str, Any], **kwargs: Any) -> str: except SerpApiError as exc: return _error(str(exc)) + if isinstance(payload, str): + return limit_result_table(payload, heading="Shopping Results", limit=_limit(args)) + results = [] raw_results = payload.get("shopping_results", []) if not isinstance(raw_results, list): @@ -253,3 +384,233 @@ def shopping_search(args: dict[str, Any], **kwargs: Any) -> str: "results": results, } ) + + +def hotels_search(args: dict[str, Any], **kwargs: Any) -> str: + """Search Google Hotels for stays and nightly prices.""" + del kwargs + query = _query(args) + if not query: + return _error("Search query must not be empty") + + try: + output = _output(args) + check_in_date = _date(args, "check_in_date", required=True) + check_out_date = _date(args, "check_out_date", required=True) + if date.fromisoformat(check_out_date) <= date.fromisoformat(check_in_date): + return _error("check_out_date must be after check_in_date") + + minimum_price = args.get("minimum_price") + maximum_price = args.get("maximum_price") + if minimum_price is not None and float(minimum_price) < 0: + return _error("minimum_price must be at least 0") + if maximum_price is not None and float(maximum_price) < 0: + return _error("maximum_price must be at least 0") + if ( + minimum_price is not None + and maximum_price is not None + and float(minimum_price) > float(maximum_price) + ): + return _error("minimum_price must not exceed maximum_price") + + vacation_rentals = bool(args.get("vacation_rentals")) + if vacation_rentals and args.get("hotel_class") is not None: + return _error("hotel_class cannot be used with vacation_rentals") + + passengers = _passengers(args, adults_default=2) + children_ages = _children_ages(args, passengers["children"]) + + sort_by = { + "lowest_price": "3", + "highest_rating": "8", + "most_reviewed": "13", + }.get(str(args.get("sort") or "relevance")) + payload = call_serpapi( + "google_hotels", + { + "q": query, + "check_in_date": check_in_date, + "check_out_date": check_out_date, + **passengers, + "children_ages": children_ages, + "currency": _currency(args), + "hl": _code(args, "language"), + "gl": _code(args, "country"), + "min_price": minimum_price, + "max_price": maximum_price, + "sort_by": sort_by, + "hotel_class": args.get("hotel_class"), + "vacation_rentals": "true" if vacation_rentals else None, + "output": output, + }, + ) + except (TypeError, ValueError) as exc: + return _error(str(exc)) + except SerpApiError as exc: + return _error(str(exc)) + + return payload if isinstance(payload, str) else _json(payload) + + +def flights_search(args: dict[str, Any], **kwargs: Any) -> str: + """Search Google Flights for one-way or round-trip itineraries.""" + del kwargs + try: + output = _output(args) + departure_id = _travel_id(args, "departure_id", required=True) + arrival_id = _travel_id(args, "arrival_id", required=True) + outbound_date = _date(args, "outbound_date", required=True) + return_date = _date(args, "return_date") + departure_token = str(args.get("departure_token") or "").strip() + + trip_type = str(args.get("trip_type") or "").strip() + if not trip_type: + trip_type = "round_trip" if return_date else "one_way" + if trip_type not in {"round_trip", "one_way"}: + return _error("trip_type must be 'round_trip' or 'one_way'") + if trip_type == "round_trip" and not return_date: + return _error("return_date is required for a round trip") + if trip_type == "one_way" and return_date: + return _error("return_date cannot be used for a one-way trip") + if departure_token and (trip_type != "round_trip" or not return_date): + return _error("departure_token requires a round trip with return_date") + if return_date and date.fromisoformat(return_date) < date.fromisoformat(outbound_date): + return _error("return_date must not be before outbound_date") + + maximum_price = args.get("maximum_price") + if maximum_price is not None and float(maximum_price) < 0: + return _error("maximum_price must be at least 0") + + travel_class = { + "economy": "1", + "premium_economy": "2", + "business": "3", + "first": "4", + }.get(str(args.get("travel_class") or "economy")) + sort_by = { + "top_flights": "1", + "price": "2", + "departure_time": "3", + "arrival_time": "4", + "duration": "5", + "emissions": "6", + }.get(str(args.get("sort") or "top_flights")) + stops = { + "any": "0", + "nonstop": "1", + "one_or_fewer": "2", + "two_or_fewer": "3", + }.get(str(args.get("stops") or "any")) + payload = call_serpapi( + "google_flights", + { + "departure_id": departure_id, + "arrival_id": arrival_id, + "outbound_date": outbound_date, + "return_date": return_date, + "type": "1" if trip_type == "round_trip" else "2", + "travel_class": travel_class, + **_passengers(args, include_infants=True), + "currency": _currency(args), + "hl": _code(args, "language"), + "gl": _code(args, "country"), + "stops": stops, + "sort_by": sort_by, + "max_price": maximum_price, + "deep_search": "true" if args.get("deep_search") else None, + "departure_token": departure_token, + "output": output, + }, + ) + except (TypeError, ValueError) as exc: + return _error(str(exc)) + except SerpApiError as exc: + return _error(str(exc)) + + return payload if isinstance(payload, str) else _json(payload) + + +def travel_explore_search(args: dict[str, Any], **kwargs: Any) -> str: + """Explore destinations and flexible trip prices with Google Travel Explore.""" + del kwargs + try: + output = _output(args) + departure_id = _travel_id(args, "departure_id", required=True) + arrival_id = _travel_id(args, "arrival_id") + arrival_area_id = str(args.get("arrival_area_id") or "").strip() or None + if arrival_id and arrival_area_id: + return _error("Use either arrival_id or arrival_area_id, not both") + if arrival_area_id and not arrival_area_id.startswith(("/m/", "/g/")): + return _error("arrival_area_id must be a /m/ or /g/ region or country KGMID") + + outbound_date = _date(args, "outbound_date") + return_date = _date(args, "return_date") + if return_date and not outbound_date: + return _error("outbound_date is required when return_date is provided") + if return_date and date.fromisoformat(return_date) < date.fromisoformat(outbound_date): + return _error("return_date must not be before outbound_date") + + trip_type = str(args.get("trip_type") or "").strip() + if not trip_type: + trip_type = "round_trip" if return_date or not outbound_date else "one_way" + if trip_type not in {"round_trip", "one_way"}: + return _error("trip_type must be 'round_trip' or 'one_way'") + if trip_type == "round_trip" and outbound_date and not return_date: + return _error("return_date is required for a fixed-date round trip") + if trip_type == "one_way" and return_date: + return _error("return_date cannot be used for a one-way trip") + + month = args.get("month") + if month is not None and not 0 <= int(month) <= 12: + return _error("month must be between 0 and 12") + if outbound_date and month not in (None, 0, "0"): + return _error("Use fixed dates or month, not both") + + maximum_price = args.get("maximum_price") + if maximum_price is not None and float(maximum_price) < 0: + return _error("maximum_price must be at least 0") + + travel_duration = { + "weekend": "1", + "one_week": "2", + "two_weeks": "3", + }.get(str(args.get("travel_duration") or "one_week")) + travel_class = { + "economy": "1", + "premium_economy": "2", + "business": "3", + "first": "4", + }.get(str(args.get("travel_class") or "economy")) + stops = { + "any": "0", + "nonstop": "1", + "one_or_fewer": "2", + "two_or_fewer": "3", + }.get(str(args.get("stops") or "any")) + payload = call_serpapi( + "google_travel_explore", + { + "departure_id": departure_id, + "arrival_id": arrival_id, + "arrival_area_id": arrival_area_id, + "outbound_date": outbound_date, + "return_date": return_date, + "type": "1" if trip_type == "round_trip" else "2", + "month": int(month) if month is not None else None, + "travel_duration": travel_duration if not outbound_date else None, + "travel_class": travel_class, + **_passengers(args, include_infants=True), + "currency": _currency(args), + "hl": _code(args, "language"), + "gl": _code(args, "country"), + "stops": stops, + "max_price": maximum_price, + "output": output, + }, + ) + except (TypeError, ValueError) as exc: + return _error(str(exc)) + except SerpApiError as exc: + return _error(str(exc)) + + return payload if isinstance(payload, str) else _json(payload) diff --git a/tests/conftest.py b/tests/conftest.py index 0d19f3b..b0dc92f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,12 @@ def provider_module(fake_hermes): return importlib.import_module("serpapi_hermes_plugin.provider") +@pytest.fixture +def client_module(fake_hermes): + del fake_hermes + return importlib.import_module("serpapi_hermes_plugin.client") + + @pytest.fixture def tools_module(fake_hermes): del fake_hermes diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f280f09 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + + +def test_call_serpapi_defaults_to_markdown( + client_module, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("SERPAPI_API_KEY", "test-key") + captured: dict[str, Any] = {} + + def fake_get(url: str, **kwargs: Any) -> httpx.Response: + captured["url"] = url + captured.update(kwargs) + request = httpx.Request("GET", url) + return httpx.Response( + 200, + text="---\nsearch: coffee\nkey: test-key\n---\n\n## Results\n", + headers={"content-type": "text/markdown; charset=utf-8"}, + request=request, + ) + + monkeypatch.setattr(httpx, "get", fake_get) + + result = client_module.call_serpapi("google_light", {"q": "coffee"}) + + assert result == "---\nsearch: coffee\nkey: [redacted]\n---\n\n## Results" + assert captured["url"] == "https://serpapi.com/search" + assert captured["params"] == { + "q": "coffee", + "engine": "google_light", + "api_key": "test-key", + "output": "md", + } + assert captured["headers"]["Accept"] == "text/markdown" + + +def test_call_serpapi_allows_json_override( + client_module, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("SERPAPI_API_KEY", "test-key") + captured: dict[str, Any] = {} + + def fake_get(url: str, **kwargs: Any) -> httpx.Response: + captured.update(kwargs) + request = httpx.Request("GET", url) + return httpx.Response( + 200, + json={"organic_results": [], "debug_url": "https://example.com/?key=test-key"}, + request=request, + ) + + monkeypatch.setattr(httpx, "get", fake_get) + + result = client_module.call_serpapi( + "google_light", + {"q": "coffee", "output": "json"}, + ) + + assert result == { + "organic_results": [], + "debug_url": "https://example.com/?key=[redacted]", + } + assert captured["params"]["output"] == "json" + assert captured["headers"]["Accept"] == "application/json" + + +def test_markdown_api_error_is_safe(client_module, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SERPAPI_API_KEY", "secret-key") + + def fake_get(url: str, **kwargs: Any) -> httpx.Response: + del kwargs + request = httpx.Request("GET", url) + return httpx.Response( + 200, + json={"error": "Invalid secret-key"}, + request=request, + ) + + monkeypatch.setattr(httpx, "get", fake_get) + + with pytest.raises(client_module.SerpApiError, match=r"Invalid \[redacted\]"): + client_module.call_serpapi("google_light", {"q": "coffee"}) + + +def test_http_400_returns_safe_api_error(client_module, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SERPAPI_API_KEY", "secret-key") + + def fake_get(url: str, **kwargs: Any) -> httpx.Response: + del kwargs + request = httpx.Request("GET", url) + return httpx.Response( + 400, + json={"error": "Invalid children_ages for secret-key"}, + request=request, + ) + + monkeypatch.setattr(httpx, "get", fake_get) + + with pytest.raises( + client_module.SerpApiError, + match=r"Invalid children_ages for \[redacted\]", + ): + client_module.call_serpapi("google_hotels", {"q": "Bali", "output": "md"}) + + +def test_http_400_without_json_uses_generic_error( + client_module, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("SERPAPI_API_KEY", "secret-key") + + def fake_get(url: str, **kwargs: Any) -> httpx.Response: + del kwargs + request = httpx.Request("GET", url) + return httpx.Response(400, text="Bad request", request=request) + + monkeypatch.setattr(httpx, "get", fake_get) + + with pytest.raises(client_module.SerpApiError, match=r"request failed \(HTTP 400\)"): + client_module.call_serpapi("google_hotels", {"q": "Bali", "output": "md"}) diff --git a/tests/test_live.py b/tests/test_live.py index 379a0cb..6defe23 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -1,14 +1,17 @@ from __future__ import annotations -import json import os +from datetime import date, timedelta from typing import Any from urllib.parse import urlparse import pytest +from markdown_it import MarkdownIt pytestmark = pytest.mark.live +_MARKDOWN_PARSER = MarkdownIt("commonmark").enable("table") + @pytest.fixture(scope="module", autouse=True) def require_serpapi_api_key() -> None: @@ -22,16 +25,45 @@ def _assert_http_url(value: Any) -> None: assert parsed.netloc -def _assert_live_results(payload: dict[str, Any], engine: str, limit: int) -> list[dict[str, Any]]: - assert payload["success"] is True, payload - assert payload["engine"] == engine +def _assert_markdown(value: str) -> None: + assert isinstance(value, str) + assert value.startswith("---\n") + assert "\n---\n" in value + assert any(marker in value for marker in ("\n## ", "\n|")) - results = payload["results"] - assert isinstance(results, list) - assert 1 <= len(results) <= limit - assert payload["results_count"] == len(results) - assert all(isinstance(result, dict) for result in results) - return results + +def _markdown_result_count(value: str, heading: str) -> int: + tokens = _MARKDOWN_PARSER.parse(value) + section_level: int | None = None + in_table_body = False + result_count = 0 + + for index, token in enumerate(tokens): + if token.type == "heading_open": + level = int(token.tag.removeprefix("h")) + if section_level is not None and level <= section_level: + break + + next_token = tokens[index + 1] if index + 1 < len(tokens) else None + if ( + section_level is None + and next_token is not None + and next_token.type == "inline" + and next_token.content == heading + ): + section_level = level + continue + + if section_level is None: + continue + if token.type == "tbody_open": + in_table_body = True + elif token.type == "tbody_close": + in_table_body = False + elif token.type == "tr_open" and in_table_body: + result_count += 1 + + return result_count def test_live_web_search_returns_real_hermes_results(provider_module) -> None: @@ -54,62 +86,95 @@ def test_live_web_search_returns_real_hermes_results(provider_module) -> None: def test_live_maps_search_returns_real_places(tools_module) -> None: limit = 3 - payload = json.loads( - tools_module.maps_search( - { - "query": "coffee shops", - "location": "Austin, Texas, United States", - "country": "us", - "limit": limit, - } - ) + response = tools_module.maps_search( + { + "query": "coffee shops", + "location": "Austin, Texas, United States", + "country": "us", + "limit": limit, + } ) - results = _assert_live_results(payload, "google_maps", limit) - for result in results: - assert result["title"].strip() - assert isinstance(result["position"], int) - assert result["position"] > 0 - assert any( - field in result - for field in ("address", "coordinates", "rating", "place_id") - ) + _assert_markdown(response) + assert _markdown_result_count(response, "Local Results") == limit + assert "coffee" in response.lower() def test_live_news_search_returns_real_articles(tools_module) -> None: limit = 3 - payload = json.loads( - tools_module.news_search( - { - "query": "technology", - "country": "us", - "limit": limit, - } - ) + response = tools_module.news_search( + { + "query": "technology", + "country": "us", + "limit": limit, + } ) - results = _assert_live_results(payload, "google_news_light", limit) - for result in results: - assert result["title"].strip() - assert str(result["source"]).strip() - _assert_http_url(result["link"]) + _assert_markdown(response) + assert _markdown_result_count(response, "News Results") == limit + assert "technology" in response.lower() def test_live_shopping_search_returns_real_products(tools_module) -> None: limit = 3 - payload = json.loads( - tools_module.shopping_search( - { - "query": "wireless headphones", - "country": "us", - "limit": limit, - } - ) + response = tools_module.shopping_search( + { + "query": "wireless headphones", + "country": "us", + "limit": limit, + } ) - results = _assert_live_results(payload, "google_shopping_light", limit) - for result in results: - assert result["title"].strip() - assert str(result["source"]).strip() - assert any(field in result for field in ("price", "extracted_price")) - _assert_http_url(result["link"]) + _assert_markdown(response) + assert _markdown_result_count(response, "Shopping Results") == limit + assert "headphones" in response.lower() + + +def test_live_hotels_search_returns_markdown_properties(tools_module) -> None: + check_in = date.today() + timedelta(days=60) + response = tools_module.hotels_search( + { + "query": "Bali resorts", + "check_in_date": check_in.isoformat(), + "check_out_date": (check_in + timedelta(days=2)).isoformat(), + "children": 1, + "children_ages": [5], + "country": "id", + "currency": "USD", + } + ) + + _assert_markdown(response) + assert "bali" in response.lower() + + +def test_live_flights_search_returns_markdown_itineraries(tools_module) -> None: + outbound = date.today() + timedelta(days=60) + response = tools_module.flights_search( + { + "departure_id": "JFK", + "arrival_id": "LAX", + "outbound_date": outbound.isoformat(), + "return_date": (outbound + timedelta(days=7)).isoformat(), + "infants_on_lap": 1, + "country": "us", + "currency": "USD", + } + ) + + _assert_markdown(response) + assert "JFK" in response + assert "LAX" in response + + +def test_live_travel_explore_returns_markdown_destinations(tools_module) -> None: + response = tools_module.travel_explore_search( + { + "departure_id": "JFK", + "currency": "USD", + "country": "us", + } + ) + + _assert_markdown(response) + assert "JFK" in response diff --git a/tests/test_provider.py b/tests/test_provider.py index b0146a6..ff096f8 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -9,7 +9,7 @@ def _response(status_code: int, payload: Any) -> httpx.Response: - request = httpx.Request("GET", "https://serpapi.com/search.json") + request = httpx.Request("GET", "https://serpapi.com/search") return httpx.Response(status_code, json=payload, request=request) @@ -84,7 +84,7 @@ def fake_get(url: str, **kwargs: Any) -> httpx.Response: ] }, } - assert captured["url"] == "https://serpapi.com/search.json" + assert captured["url"] == "https://serpapi.com/search" assert captured["params"] == { "engine": "google_light", "q": "Hermes Agent", @@ -172,10 +172,25 @@ def register_tool(self, **kwargs: Any) -> None: "serpapi_maps_search", "serpapi_news_search", "serpapi_shopping_search", + "serpapi_hotels_search", + "serpapi_flights_search", + "serpapi_travel_explore_search", ] assert all(tool["toolset"] == "serpapi" for tool in context.tools) assert all(tool["requires_env"] == ["SERPAPI_API_KEY"] for tool in context.tools) + tools_by_name = {tool["name"]: tool for tool in context.tools} + hotel_properties = tools_by_name["serpapi_hotels_search"]["schema"]["parameters"]["properties"] + assert hotel_properties["children_ages"]["items"] == { + "type": "integer", + "minimum": 1, + "maximum": 17, + } + + for tool_name in ("serpapi_flights_search", "serpapi_travel_explore_search"): + properties = tools_by_name[tool_name]["schema"]["parameters"]["properties"] + assert {"infants_in_seat", "infants_on_lap"} <= properties.keys() + def test_package_declares_hermes_entry_point(provider_module) -> None: entry_points = importlib.metadata.entry_points().select(group="hermes_agent.plugins") diff --git a/tests/test_tools.py b/tests/test_tools.py index 8e8d508..716991f 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -6,7 +6,7 @@ import pytest -def test_maps_search_routes_parameters_and_normalizes_results( +def test_maps_search_routes_parameters_and_normalizes_json_results( tools_module, monkeypatch: pytest.MonkeyPatch ) -> None: captured: dict[str, Any] = {} @@ -39,6 +39,7 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: "nearby": True, "language": "en", "country": "us", + "output": "json", } ) ) @@ -53,6 +54,7 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: "location": "New York, NY", "z": 14, "nearby": "true", + "output": "json", }, } assert result == { @@ -82,7 +84,7 @@ def test_maps_search_validates_origin(tools_module) -> None: assert "latitude and longitude" in result["error"] -def test_news_search_routes_and_normalizes_results( +def test_news_search_routes_and_normalizes_json_results( tools_module, monkeypatch: pytest.MonkeyPatch ) -> None: captured: dict[str, Any] = {} @@ -106,7 +108,12 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: monkeypatch.setattr(tools_module, "call_serpapi", fake_call) result = json.loads( tools_module.news_search( - {"query": "reusable rockets", "location": "Florida", "country": "us"} + { + "query": "reusable rockets", + "location": "Florida", + "country": "us", + "output": "json", + } ) ) @@ -117,6 +124,7 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: "location": "Florida", "hl": None, "gl": "us", + "output": "json", }, } assert result["results"] == [ @@ -131,7 +139,7 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: ] -def test_shopping_search_routes_filters_and_normalizes_results( +def test_shopping_search_routes_filters_and_normalizes_json_results( tools_module, monkeypatch: pytest.MonkeyPatch ) -> None: captured: dict[str, Any] = {} @@ -165,6 +173,7 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: "sort": "price_low_to_high", "free_shipping": True, "country": "us", + "output": "json", } ) ) @@ -181,6 +190,7 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: "sort_by": "1", "free_shipping": "true", "on_sale": None, + "output": "json", }, } assert result["results"] == [ @@ -199,6 +209,335 @@ def fake_call(engine: str, params: dict[str, Any]) -> dict[str, Any]: ] +@pytest.mark.parametrize( + ("handler_name", "args", "engine"), + [ + ("maps_search", {"query": "coffee"}, "google_maps"), + ("news_search", {"query": "technology"}, "google_news_light"), + ("shopping_search", {"query": "headphones"}, "google_shopping_light"), + ], +) +def test_existing_direct_tools_return_serpapi_markdown_by_default( + tools_module, + monkeypatch: pytest.MonkeyPatch, + handler_name: str, + args: dict[str, Any], + engine: str, +) -> None: + captured: dict[str, Any] = {} + markdown = "---\nengine: test\n---\n\n## Results" + + def fake_call(actual_engine: str, params: dict[str, Any]) -> str: + captured.update({"engine": actual_engine, "params": params}) + return markdown + + monkeypatch.setattr(tools_module, "call_serpapi", fake_call) + + result = getattr(tools_module, handler_name)(args) + + assert result == markdown + assert captured["engine"] == engine + assert captured["params"]["output"] == "md" + + +@pytest.mark.parametrize( + ("handler_name", "heading"), + [ + ("maps_search", "Local Results"), + ("news_search", "News Results"), + ("shopping_search", "Shopping Results"), + ], +) +def test_direct_tools_enforce_limit_without_reformatting_markdown( + tools_module, + monkeypatch: pytest.MonkeyPatch, + handler_name: str, + heading: str, +) -> None: + result_rows = "".join( + f"| {position} | result-{position} | [Open](https://example.com/{position}) |\n" + for position in range(1, 6) + ) + markdown = ( + "---\nengine: test\n---\n\n" + f"## {heading} (5)\n\n" + "| Position | Title | Link |\n" + "| ---: | --- | --- |\n" + f"{result_rows}\n" + "## Pagination\n\n" + "| Next |\n" + "| --- |\n" + "| [result-99](https://example.com/next) |\n" + ) + + monkeypatch.setattr(tools_module, "call_serpapi", lambda engine, params: markdown) + + result = getattr(tools_module, handler_name)({"query": "test", "limit": 3}) + + assert result.startswith("---\nengine: test\n---\n") + assert f"## {heading} (5)" in result + assert "| 1 | result-1 |" in result + assert "| 3 | result-3 |" in result + assert "| 4 | result-4 |" not in result + assert "| 5 | result-5 |" not in result + assert "## Pagination" in result + assert "[result-99](https://example.com/next)" in result + + +def test_hotels_search_routes_agent_friendly_parameters( + tools_module, monkeypatch: pytest.MonkeyPatch +) -> None: + captured: dict[str, Any] = {} + markdown = "---\nengine: google_hotels\n---\n\n## Properties" + + def fake_call(engine: str, params: dict[str, Any]) -> str: + captured.update({"engine": engine, "params": params}) + return markdown + + monkeypatch.setattr(tools_module, "call_serpapi", fake_call) + + result = tools_module.hotels_search( + { + "query": "Bali resorts", + "check_in_date": "2026-10-10", + "check_out_date": "2026-10-15", + "adults": 3, + "children": 2, + "children_ages": [5, 8], + "currency": "usd", + "country": "id", + "sort": "lowest_price", + } + ) + + assert result == markdown + assert captured == { + "engine": "google_hotels", + "params": { + "q": "Bali resorts", + "check_in_date": "2026-10-10", + "check_out_date": "2026-10-15", + "adults": 3, + "children": 2, + "children_ages": "5,8", + "currency": "USD", + "hl": None, + "gl": "id", + "min_price": None, + "max_price": None, + "sort_by": "3", + "hotel_class": None, + "vacation_rentals": None, + "output": "md", + }, + } + + +def test_flights_search_routes_round_trip_filters_and_passengers( + tools_module, monkeypatch: pytest.MonkeyPatch +) -> None: + captured: dict[str, Any] = {} + markdown = "---\nengine: google_flights\n---\n\n## Best flights" + + def fake_call(engine: str, params: dict[str, Any]) -> str: + captured.update({"engine": engine, "params": params}) + return markdown + + monkeypatch.setattr(tools_module, "call_serpapi", fake_call) + + result = tools_module.flights_search( + { + "departure_id": "JFK", + "arrival_id": "LAX", + "outbound_date": "2026-10-10", + "return_date": "2026-10-17", + "infants_on_lap": 1, + "currency": "USD", + "country": "us", + "stops": "nonstop", + "sort": "price", + "maximum_price": 500, + "departure_token": "return-token", + } + ) + + assert result == markdown + assert captured == { + "engine": "google_flights", + "params": { + "departure_id": "JFK", + "arrival_id": "LAX", + "outbound_date": "2026-10-10", + "return_date": "2026-10-17", + "type": "1", + "travel_class": "1", + "adults": 1, + "children": 0, + "infants_in_seat": 0, + "infants_on_lap": 1, + "currency": "USD", + "hl": None, + "gl": "us", + "stops": "1", + "sort_by": "2", + "max_price": 500, + "deep_search": None, + "departure_token": "return-token", + "output": "md", + }, + } + + +def test_travel_explore_routes_region_discovery( + tools_module, monkeypatch: pytest.MonkeyPatch +) -> None: + captured: dict[str, Any] = {} + markdown = "---\nengine: google_travel_explore\n---\n\n## Destinations" + + def fake_call(engine: str, params: dict[str, Any]) -> str: + captured.update({"engine": engine, "params": params}) + return markdown + + monkeypatch.setattr(tools_module, "call_serpapi", fake_call) + + result = tools_module.travel_explore_search( + { + "departure_id": "/m/02_286", + "arrival_area_id": "/m/02j9z", + "month": 10, + "travel_duration": "weekend", + "currency": "eur", + } + ) + + assert result == markdown + assert captured == { + "engine": "google_travel_explore", + "params": { + "departure_id": "/m/02_286", + "arrival_id": None, + "arrival_area_id": "/m/02j9z", + "outbound_date": None, + "return_date": None, + "type": "1", + "month": 10, + "travel_duration": "1", + "travel_class": "1", + "adults": 1, + "children": 0, + "infants_in_seat": 0, + "infants_on_lap": 0, + "currency": "EUR", + "hl": None, + "gl": None, + "stops": "0", + "max_price": None, + "output": "md", + }, + } + + +@pytest.mark.parametrize( + ("handler_name", "args", "message"), + [ + ( + "hotels_search", + { + "query": "Paris", + "check_in_date": "2026-10-10", + "check_out_date": "2026-10-09", + }, + "check_out_date must be after check_in_date", + ), + ( + "flights_search", + { + "departure_id": "jfk", + "arrival_id": "LAX", + "outbound_date": "2026-10-10", + }, + "uppercase three-letter airport codes", + ), + ( + "travel_explore_search", + { + "departure_id": "JFK", + "arrival_id": "LAX", + "arrival_area_id": "/m/02j9z", + }, + "either arrival_id or arrival_area_id", + ), + ( + "hotels_search", + { + "query": "Paris", + "check_in_date": "2026-10-10", + "check_out_date": "2026-10-12", + "children": 1, + }, + "children_ages must contain one age per child", + ), + ( + "hotels_search", + { + "query": "Paris", + "check_in_date": "2026-10-10", + "check_out_date": "2026-10-12", + "vacation_rentals": True, + "hotel_class": 4, + }, + "hotel_class cannot be used with vacation_rentals", + ), + ( + "flights_search", + { + "departure_id": "JFK", + "arrival_id": "LAX", + "outbound_date": "2026-10-10", + "adults": 8, + "infants_in_seat": 2, + }, + "total number of passengers must not exceed 9", + ), + ( + "travel_explore_search", + {"departure_id": "JFK", "adults": 9, "children": 1}, + "total number of passengers must not exceed 9", + ), + ( + "flights_search", + { + "departure_id": "JFK", + "arrival_id": "LAX", + "outbound_date": "2026-10-10", + "infants_on_lap": 2, + }, + "infants_on_lap must not exceed the number of adults", + ), + ( + "flights_search", + { + "departure_id": "JFK", + "arrival_id": "LAX", + "outbound_date": "2026-10-10", + "departure_token": "return-token", + }, + "departure_token requires a round trip with return_date", + ), + ], +) +def test_travel_tools_reject_invalid_requests( + tools_module, + handler_name: str, + args: dict[str, Any], + message: str, +) -> None: + result = json.loads(getattr(tools_module, handler_name)(args)) + + assert result["success"] is False + assert message in result["error"] + + def test_specialized_tool_returns_safe_client_error( tools_module, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/uv.lock b/uv.lock index ce475ea..1f14262 100644 --- a/uv.lock +++ b/uv.lock @@ -580,6 +580,7 @@ version = "0.1.1" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "markdown-it-py" }, ] [package.dev-dependencies] @@ -590,7 +591,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = ">=0.28.1,<1" }] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1,<1" }, + { name = "markdown-it-py", specifier = ">=4.2,<5" }, +] [package.metadata.requires-dev] dev = [