hermes-plugin-tinyfish 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/CHANGELOG.md +29 -0
  2. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/PKG-INFO +9 -6
  3. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/README.md +7 -4
  4. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/rest_client.py +81 -13
  5. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/routing_context.py +3 -3
  6. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/setup_cli.py +22 -10
  7. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/plugin.yaml +1 -1
  8. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/pyproject.toml +1 -1
  9. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_rest_client.py +101 -9
  10. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_routing_context.py +24 -0
  11. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_setup_cli.py +36 -10
  12. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  13. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
  14. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/generate_report.py +0 -0
  15. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/improve_description.py +0 -0
  16. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/package_skill.py +0 -0
  17. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/quick_validate.py +0 -0
  18. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/run_eval.py +0 -0
  19. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/run_loop.py +0 -0
  20. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/utils.py +0 -0
  21. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/.gitignore +0 -0
  22. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/CODE_OF_CONDUCT.md +0 -0
  23. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/CONTRIBUTING.md +0 -0
  24. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/LICENSE +0 -0
  25. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/SECURITY.md +0 -0
  26. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/__init__.py +0 -0
  27. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/__init__.py +0 -0
  28. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/browser_provider.py +0 -0
  29. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/config.py +0 -0
  30. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/credit_policy.py +0 -0
  31. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/health.py +0 -0
  32. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/normalize.py +0 -0
  33. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/provider.py +0 -0
  34. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/update_check.py +0 -0
  35. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/scripts/prepare_release.py +0 -0
  36. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/scripts/validate_release.py +0 -0
  37. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/conftest.py +0 -0
  38. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_browser_provider.py +0 -0
  39. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_credit_policy.py +0 -0
  40. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_health.py +0 -0
  41. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_live.py +0 -0
  42. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_normalize.py +0 -0
  43. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_prepare_release.py +0 -0
  44. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_provider.py +0 -0
  45. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_register.py +0 -0
  46. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_update_check.py +0 -0
  47. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_validate_release.py +0 -0
  48. {hermes_plugin_tinyfish-0.3.0 → hermes_plugin_tinyfish-0.3.2}/tests/test_version.py +0 -0
@@ -7,6 +7,35 @@ and this project uses semantic versioning.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.3.2] - 2026-08-11
11
+
12
+ ### Added
13
+
14
+ - Added automated compatibility coverage for the Hermes 0.20.0 source release,
15
+ alongside the packaged 0.18.2 and 0.19.0 compatibility jobs.
16
+ - Expanded `hermes tinyfish usage` to report both Search and Fetch operation
17
+ history, including per-surface success or failure.
18
+
19
+ ### Changed
20
+
21
+ - Bumped the ephemeral routing context to version 2 with research-paper and
22
+ publication-year search modes, conditional Fetch validators, and explicit
23
+ guidance not to silently drop requested controls when native MCP tools are
24
+ unavailable.
25
+
26
+ ### Fixed
27
+
28
+ - Made Browser session termination retry documented transient and transport
29
+ failures within a bounded cleanup budget, honor capped `Retry-After` delays,
30
+ and stop treating HTTP 404 as successful cleanup.
31
+
32
+ ## [0.3.1] - 2026-08-04
33
+
34
+ ### Fixed
35
+
36
+ - Added the required Hermes plugin enable step to the Python package install
37
+ instructions before running TinyFish setup.
38
+
10
39
  ## [0.3.0] - 2026-08-04
11
40
 
12
41
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hermes-plugin-tinyfish
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: TinyFish Search, Fetch, plain-language routing, update awareness, and optional Browser for Hermes Agent
5
5
  Project-URL: Homepage, https://github.com/gabeosx/hermes-plugin-tinyfish
6
6
  Project-URL: Documentation, https://github.com/gabeosx/hermes-plugin-tinyfish#readme
@@ -67,6 +67,7 @@ Python package install:
67
67
 
68
68
  ```bash
69
69
  pip install hermes-plugin-tinyfish
70
+ hermes plugins enable web-tinyfish
70
71
  hermes tinyfish setup
71
72
  ```
72
73
 
@@ -193,8 +194,9 @@ surface:
193
194
  - “Search only these domains, in English, after this date, and return page 2”
194
195
  uses TinyFish MCP's native `search` tool because the generic search schema
195
196
  cannot express all of those controls.
196
- - “Fetch these URLs using this selector, include image links, and use this
197
- cache TTL” uses TinyFish MCP's native `fetch_content` tool.
197
+ - “Find research papers from these publication years” also uses native
198
+ `search`, while “Fetch these URLs using this selector and conditional ETag”
199
+ uses native `fetch_content`.
198
200
 
199
201
  Hermes keeps the visible stored user message clean but persists the exact
200
202
  API-bound message in an `api_content` sidecar for prompt-cache replay. The note
@@ -298,8 +300,9 @@ hermes tinyfish doctor --live-paid
298
300
  present. It does not mean the access or refresh token is valid.
299
301
  - `/tinyfish-status` shows non-networked status inside CLI or gateway sessions;
300
302
  `/tinyfish-status live` explicitly runs Search and Fetch checks.
301
- - `usage` reads TinyFish Fetch operation history; it is not Agent or Browser
302
- billing data.
303
+ - `usage` reads TinyFish Search and Fetch operation history independently and
304
+ reports per-surface success or failure; it is not Agent or Browser billing
305
+ data.
303
306
  - `doctor --live-paid` only creates and closes a TinyFish Browser session. It
304
307
  refuses under `deny`, requests approval under `request`, and never prints
305
308
  connection URLs or credentials.
@@ -35,6 +35,7 @@ Python package install:
35
35
 
36
36
  ```bash
37
37
  pip install hermes-plugin-tinyfish
38
+ hermes plugins enable web-tinyfish
38
39
  hermes tinyfish setup
39
40
  ```
40
41
 
@@ -161,8 +162,9 @@ surface:
161
162
  - “Search only these domains, in English, after this date, and return page 2”
162
163
  uses TinyFish MCP's native `search` tool because the generic search schema
163
164
  cannot express all of those controls.
164
- - “Fetch these URLs using this selector, include image links, and use this
165
- cache TTL” uses TinyFish MCP's native `fetch_content` tool.
165
+ - “Find research papers from these publication years” also uses native
166
+ `search`, while “Fetch these URLs using this selector and conditional ETag”
167
+ uses native `fetch_content`.
166
168
 
167
169
  Hermes keeps the visible stored user message clean but persists the exact
168
170
  API-bound message in an `api_content` sidecar for prompt-cache replay. The note
@@ -266,8 +268,9 @@ hermes tinyfish doctor --live-paid
266
268
  present. It does not mean the access or refresh token is valid.
267
269
  - `/tinyfish-status` shows non-networked status inside CLI or gateway sessions;
268
270
  `/tinyfish-status live` explicitly runs Search and Fetch checks.
269
- - `usage` reads TinyFish Fetch operation history; it is not Agent or Browser
270
- billing data.
271
+ - `usage` reads TinyFish Search and Fetch operation history independently and
272
+ reports per-surface success or failure; it is not Agent or Browser billing
273
+ data.
271
274
  - `doctor --live-paid` only creates and closes a TinyFish Browser session. It
272
275
  refuses under `deny`, requests approval under `request`, and never prints
273
276
  connection URLs or credentials.
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
6
+ import time
5
7
  from typing import Any, NoReturn, cast
6
8
 
7
9
  import httpx
@@ -9,8 +11,14 @@ import httpx
9
11
  SEARCH_URL = "https://api.search.tinyfish.ai"
10
12
  FETCH_URL = "https://api.fetch.tinyfish.ai"
11
13
  BROWSER_URL = "https://api.browser.tinyfish.ai"
14
+ SEARCH_USAGE_URL = f"{SEARCH_URL}/usage"
12
15
  FETCH_USAGE_URL = f"{FETCH_URL}/usage"
13
16
 
17
+ _BROWSER_CLOSE_MAX_ATTEMPTS = 3
18
+ _BROWSER_CLOSE_RETRYABLE_STATUSES = frozenset({409, 429, 500, 503, 504})
19
+ _BROWSER_CLOSE_RETRY_AFTER_CAP_SECONDS = 5.0
20
+ _BROWSER_CLOSE_RETRY_BASE_SECONDS = 0.25
21
+
14
22
 
15
23
  class TinyFishRestError(RuntimeError):
16
24
  """Raised for TinyFish REST transport or HTTP failures."""
@@ -146,27 +154,87 @@ def create_browser_session(
146
154
  raise TinyFishRestError("TinyFish Browser returned invalid JSON") from exc
147
155
 
148
156
 
149
- def close_browser_session(session_id: str, *, api_key: str, timeout: float = 15.0) -> bool:
150
- try:
151
- response = httpx.delete(f"{BROWSER_URL}/{session_id}", headers=_headers(api_key), timeout=timeout)
152
- return response.status_code in {200, 201, 202, 204, 404}
153
- except httpx.RequestError:
154
- return False
157
+ def _browser_close_retry_delay(response: httpx.Response | None, attempt: int) -> float:
158
+ if response is not None:
159
+ retry_after = str(response.headers.get("Retry-After", "") or "").strip()
160
+ if retry_after:
161
+ try:
162
+ seconds = float(retry_after)
163
+ except ValueError:
164
+ seconds = -1.0
165
+ if math.isfinite(seconds) and seconds >= 0:
166
+ return min(seconds, _BROWSER_CLOSE_RETRY_AFTER_CAP_SECONDS)
167
+ return _BROWSER_CLOSE_RETRY_BASE_SECONDS * (2.0**attempt)
155
168
 
156
169
 
157
- def fetch_usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
158
- """Return TinyFish Fetch operation history."""
159
-
170
+ def close_browser_session(session_id: str, *, api_key: str, timeout: float = 15.0) -> bool:
171
+ """Terminate a Browser session with bounded transient-failure retries.
172
+
173
+ TinyFish returns a 2xx response, normally 204, for successful and
174
+ idempotent termination. A 404 is not success: it can also mean the session
175
+ belongs to a different API key. Retry only documented transient statuses
176
+ and transport failures, keeping process-exit cleanup bounded.
177
+ """
178
+
179
+ url = f"{BROWSER_URL}/{session_id}"
180
+ for attempt in range(_BROWSER_CLOSE_MAX_ATTEMPTS):
181
+ response: httpx.Response | None = None
182
+ try:
183
+ response = httpx.delete(url, headers=_headers(api_key), timeout=timeout)
184
+ except httpx.RequestError:
185
+ retry = True
186
+ else:
187
+ if 200 <= response.status_code < 300:
188
+ return True
189
+ retry = response.status_code in _BROWSER_CLOSE_RETRYABLE_STATUSES
190
+
191
+ if not retry or attempt + 1 >= _BROWSER_CLOSE_MAX_ATTEMPTS:
192
+ return False
193
+ delay = _browser_close_retry_delay(response, attempt)
194
+ if delay > 0:
195
+ time.sleep(delay)
196
+ return False
197
+
198
+
199
+ def _read_usage(
200
+ url: str,
201
+ *,
202
+ service: str,
203
+ api_key: str,
204
+ timeout: float,
205
+ ) -> dict[str, Any]:
160
206
  try:
161
- response = httpx.get(FETCH_USAGE_URL, headers=_headers(api_key), timeout=timeout)
207
+ response = httpx.get(url, headers=_headers(api_key), timeout=timeout)
162
208
  response.raise_for_status()
163
209
  return cast(dict[str, Any], response.json())
164
210
  except httpx.HTTPStatusError as exc:
165
- _raise_http_error("TinyFish Fetch usage", exc)
211
+ _raise_http_error(service, exc)
166
212
  except httpx.RequestError as exc:
167
- raise TinyFishRestError(f"Could not reach TinyFish Fetch usage: {exc}") from exc
213
+ raise TinyFishRestError(f"Could not reach {service}: {exc}") from exc
168
214
  except ValueError as exc:
169
- raise TinyFishRestError("TinyFish Fetch usage returned invalid JSON") from exc
215
+ raise TinyFishRestError(f"{service} returned invalid JSON") from exc
216
+
217
+
218
+ def search_usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
219
+ """Return TinyFish Search operation history."""
220
+
221
+ return _read_usage(
222
+ SEARCH_USAGE_URL,
223
+ service="TinyFish Search usage",
224
+ api_key=api_key,
225
+ timeout=timeout,
226
+ )
227
+
228
+
229
+ def fetch_usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
230
+ """Return TinyFish Fetch operation history."""
231
+
232
+ return _read_usage(
233
+ FETCH_USAGE_URL,
234
+ service="TinyFish Fetch usage",
235
+ api_key=api_key,
236
+ timeout=timeout,
237
+ )
170
238
 
171
239
 
172
240
  def usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
@@ -8,12 +8,12 @@ from typing import Any
8
8
  from .config import TINYFISH_MCP_URL, load_config, routing_context_enabled
9
9
  from .update_check import UpdateChecker
10
10
 
11
- ROUTING_CONTEXT_MARKER = '<tinyfish-routing-context version="1">'
11
+ ROUTING_CONTEXT_MARKER = '<tinyfish-routing-context version="2">'
12
12
  ROUTING_GUIDANCE = f"""{ROUTING_CONTEXT_MARKER}
13
13
  TinyFish tool-routing guidance:
14
14
  - For ordinary web discovery or reading a page, use Hermes `web_search` or `web_extract`; the TinyFish provider already keeps those calls MCP-first.
15
- - When the request needs TinyFish-specific controls that the generic schemas cannot express—domain/date/language/location/purpose/pagination filters, selectors, output formats, link or image extraction, cache TTL, or per-URL timeouts—use the native `search` or `fetch_content` tool registered by the `tinyfish` MCP server. Use the exact names Hermes exposes, commonly `mcp__tinyfish__search` and `mcp__tinyfish__fetch_content`.
16
- - Infer the choice from the user's plain language. Do not ask them to choose MCP versus the plugin, and do not persist per-request controls as configuration."""
15
+ - When the request needs TinyFish-specific controls that the generic schemas cannot express—domain/date/language/location/purpose/pagination filters, news or research-paper modes, publication-year filters, selectors, output formats, link or image extraction, conditional ETag/Last-Modified validators, cache TTL, or per-URL timeouts—use the native `search` or `fetch_content` tool registered by the `tinyfish` MCP server. Use the exact names Hermes exposes, commonly `mcp__tinyfish__search` and `mcp__tinyfish__fetch_content`.
16
+ - Infer the choice from the user's plain language. Do not ask them to choose MCP versus the plugin, and do not persist per-request controls as configuration. If a required native tool is unavailable, use the generic provider only when it can preserve the requested constraints; otherwise explain which control is unavailable rather than silently dropping it."""
17
17
 
18
18
 
19
19
  def tinyfish_mcp_configured(config: dict[str, Any]) -> bool:
@@ -6,6 +6,7 @@ import argparse
6
6
  import json
7
7
  import os
8
8
  import sys
9
+ from collections.abc import Callable
9
10
  from pathlib import Path
10
11
  from typing import Any, cast
11
12
 
@@ -85,7 +86,7 @@ def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
85
86
  credits_set.add_argument("policy", choices=list(CREDIT_POLICIES))
86
87
  credits_sub.add_parser("reset", help="Reset Browser to deny and remove retired Agent/Profile policy keys")
87
88
 
88
- usage = sub.add_parser("usage", help="Read TinyFish Fetch operation history")
89
+ usage = sub.add_parser("usage", help="Read TinyFish Search and Fetch operation history")
89
90
  usage.add_argument("--json", action="store_true", help="Print machine-readable JSON")
90
91
 
91
92
 
@@ -614,7 +615,7 @@ def _api_key_or_error() -> str | None:
614
615
  api_key = _get_env("TINYFISH_API_KEY")
615
616
  if not api_key:
616
617
  print(
617
- "TINYFISH_API_KEY is required for TinyFish Fetch usage and Browser operations.",
618
+ "TINYFISH_API_KEY is required for TinyFish Search/Fetch usage and Browser operations.",
618
619
  file=sys.stderr,
619
620
  )
620
621
  return None
@@ -709,17 +710,28 @@ def cmd_credits(args: argparse.Namespace) -> int:
709
710
  return 2
710
711
 
711
712
 
713
+ def _usage_surface(
714
+ reader: Callable[..., dict[str, Any]],
715
+ *,
716
+ api_key: str,
717
+ ) -> dict[str, Any]:
718
+ try:
719
+ return {"success": True, "data": reader(api_key=api_key)}
720
+ except Exception as exc: # noqa: BLE001
721
+ return {"success": False, "error": str(exc)}
722
+
723
+
712
724
  def cmd_usage(args: argparse.Namespace) -> int:
713
725
  api_key = _api_key_or_error()
714
726
  if not api_key:
715
727
  return 1
716
- try:
717
- payload = {
718
- "success": True,
719
- "surface": "fetch",
720
- "data": rest_client.fetch_usage(api_key=api_key),
721
- }
722
- except Exception as exc: # noqa: BLE001
723
- payload = {"success": False, "surface": "fetch", "error": str(exc)}
728
+ surfaces = {
729
+ "search": _usage_surface(rest_client.search_usage, api_key=api_key),
730
+ "fetch": _usage_surface(rest_client.fetch_usage, api_key=api_key),
731
+ }
732
+ payload = {
733
+ "success": all(result["success"] for result in surfaces.values()),
734
+ "surfaces": surfaces,
735
+ }
724
736
  _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
725
737
  return 0 if payload["success"] else 1
@@ -1,5 +1,5 @@
1
1
  name: web-tinyfish
2
- version: 0.3.0
2
+ version: 0.3.2
3
3
  description: "Independent TinyFish provider plugin for Hermes Agent. Adds MCP-first Search/Fetch, plain-language tool routing, update awareness, and optional default-deny Browser infrastructure."
4
4
  author: gabeosx
5
5
  kind: backend
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hermes-plugin-tinyfish"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "TinyFish Search, Fetch, plain-language routing, update awareness, and optional Browser for Hermes Agent"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -16,11 +16,12 @@ def _response(
16
16
  status: int = 200,
17
17
  payload: dict[str, Any] | None = None,
18
18
  content: bytes | None = None,
19
+ headers: dict[str, str] | None = None,
19
20
  ) -> httpx.Response:
20
21
  request = httpx.Request(method, url)
21
22
  if content is not None:
22
- return httpx.Response(status, content=content, request=request)
23
- return httpx.Response(status, json=payload or {}, request=request)
23
+ return httpx.Response(status, content=content, headers=headers, request=request)
24
+ return httpx.Response(status, json=payload or {}, headers=headers, request=request)
24
25
 
25
26
 
26
27
  def test_search_sends_supported_query_options(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -163,6 +164,25 @@ def test_fetch_usage_uses_documented_fetch_endpoint(monkeypatch: pytest.MonkeyPa
163
164
  }
164
165
 
165
166
 
167
+ def test_search_usage_uses_documented_search_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
168
+ captured: dict[str, Any] = {}
169
+
170
+ def fake_get(url: str, **kwargs: Any) -> httpx.Response:
171
+ captured.update(url=url, **kwargs)
172
+ return _response("GET", url, payload={"items": []})
173
+
174
+ monkeypatch.setattr(rest_client.httpx, "get", fake_get)
175
+
176
+ result = rest_client.search_usage(api_key="tf_test", timeout=11.0)
177
+
178
+ assert result == {"items": []}
179
+ assert captured == {
180
+ "url": "https://api.search.tinyfish.ai/usage",
181
+ "headers": {"X-API-Key": "tf_test", "Accept": "application/json"},
182
+ "timeout": 11.0,
183
+ }
184
+
185
+
166
186
  def test_usage_is_a_compatibility_wrapper(monkeypatch: pytest.MonkeyPatch) -> None:
167
187
  captured: dict[str, Any] = {}
168
188
 
@@ -189,6 +209,7 @@ RestCall = Callable[[], dict[str, Any]]
189
209
  lambda: rest_client.create_browser_session(api_key="tf_test"),
190
210
  "TinyFish Browser",
191
211
  ),
212
+ ("get", lambda: rest_client.search_usage(api_key="tf_test"), "TinyFish Search usage"),
192
213
  ("get", lambda: rest_client.fetch_usage(api_key="tf_test"), "TinyFish Fetch usage"),
193
214
  ],
194
215
  )
@@ -222,6 +243,7 @@ def test_rest_methods_report_http_failures(
222
243
  lambda: rest_client.create_browser_session(api_key="tf_test"),
223
244
  "TinyFish Browser",
224
245
  ),
246
+ ("get", lambda: rest_client.search_usage(api_key="tf_test"), "TinyFish Search usage"),
225
247
  ("get", lambda: rest_client.fetch_usage(api_key="tf_test"), "TinyFish Fetch usage"),
226
248
  ],
227
249
  )
@@ -250,6 +272,7 @@ def test_rest_methods_report_transport_failures(
250
272
  lambda: rest_client.create_browser_session(api_key="tf_test"),
251
273
  "TinyFish Browser",
252
274
  ),
275
+ ("get", lambda: rest_client.search_usage(api_key="tf_test"), "TinyFish Search usage"),
253
276
  ("get", lambda: rest_client.fetch_usage(api_key="tf_test"), "TinyFish Fetch usage"),
254
277
  ],
255
278
  )
@@ -268,8 +291,8 @@ def test_rest_methods_report_invalid_json(
268
291
  call()
269
292
 
270
293
 
271
- @pytest.mark.parametrize("status", [200, 201, 202, 204, 404])
272
- def test_close_browser_session_accepts_success_and_missing_statuses(
294
+ @pytest.mark.parametrize("status", [200, 201, 202, 204, 299])
295
+ def test_close_browser_session_accepts_any_success_status(
273
296
  monkeypatch: pytest.MonkeyPatch, status: int
274
297
  ) -> None:
275
298
  captured: dict[str, Any] = {}
@@ -286,27 +309,96 @@ def test_close_browser_session_accepts_success_and_missing_statuses(
286
309
  assert captured["timeout"] == 9.0
287
310
 
288
311
 
289
- def test_close_browser_session_returns_false_for_http_failure(
312
+ def test_close_browser_session_rejects_404_without_retry(
290
313
  monkeypatch: pytest.MonkeyPatch,
291
314
  ) -> None:
315
+ calls = 0
316
+
317
+ def fake_delete(url: str, **kwargs: Any) -> httpx.Response:
318
+ nonlocal calls
319
+ calls += 1
320
+ return _response("DELETE", url, status=404)
321
+
322
+ monkeypatch.setattr(rest_client.httpx, "delete", fake_delete)
323
+ monkeypatch.setattr(rest_client.time, "sleep", lambda delay: pytest.fail("must not retry"))
324
+
325
+ assert rest_client.close_browser_session("sess_123", api_key="tf_test") is False
326
+ assert calls == 1
327
+
328
+
329
+ @pytest.mark.parametrize("status", [409, 429, 500, 503, 504])
330
+ def test_close_browser_session_retries_documented_transient_statuses(
331
+ monkeypatch: pytest.MonkeyPatch,
332
+ status: int,
333
+ ) -> None:
334
+ statuses = iter([status, 204])
335
+ sleeps: list[float] = []
292
336
  monkeypatch.setattr(
293
337
  rest_client.httpx,
294
338
  "delete",
295
- lambda url, **kwargs: _response("DELETE", url, status=500),
339
+ lambda url, **kwargs: _response("DELETE", url, status=next(statuses)),
296
340
  )
341
+ monkeypatch.setattr(rest_client.time, "sleep", sleeps.append)
297
342
 
298
- assert rest_client.close_browser_session("sess_123", api_key="tf_test") is False
343
+ assert rest_client.close_browser_session("sess_123", api_key="tf_test") is True
344
+ assert sleeps == [0.25]
299
345
 
300
346
 
301
- def test_close_browser_session_returns_false_for_transport_failure(
347
+ def test_close_browser_session_honors_bounded_retry_after(
302
348
  monkeypatch: pytest.MonkeyPatch,
303
349
  ) -> None:
350
+ responses = iter(
351
+ [
352
+ _response("DELETE", rest_client.BROWSER_URL, status=429, headers={"Retry-After": "60"}),
353
+ _response("DELETE", rest_client.BROWSER_URL, status=204),
354
+ ]
355
+ )
356
+ sleeps: list[float] = []
357
+ monkeypatch.setattr(rest_client.httpx, "delete", lambda url, **kwargs: next(responses))
358
+ monkeypatch.setattr(rest_client.time, "sleep", sleeps.append)
359
+
360
+ assert rest_client.close_browser_session("sess_123", api_key="tf_test") is True
361
+ assert sleeps == [5.0]
362
+
363
+
364
+ def test_close_browser_session_retries_transport_failure_then_succeeds(
365
+ monkeypatch: pytest.MonkeyPatch,
366
+ ) -> None:
367
+ calls = 0
368
+ sleeps: list[float] = []
369
+
370
+ def flaky_delete(url: str, **kwargs: Any) -> httpx.Response:
371
+ nonlocal calls
372
+ calls += 1
373
+ if calls == 1:
374
+ raise httpx.ConnectError("network unavailable", request=httpx.Request("DELETE", url))
375
+ return _response("DELETE", url, status=204)
376
+
377
+ monkeypatch.setattr(rest_client.httpx, "delete", flaky_delete)
378
+ monkeypatch.setattr(rest_client.time, "sleep", sleeps.append)
379
+
380
+ assert rest_client.close_browser_session("sess_123", api_key="tf_test") is True
381
+ assert calls == 2
382
+ assert sleeps == [0.25]
383
+
384
+
385
+ def test_close_browser_session_returns_false_after_bounded_retries(
386
+ monkeypatch: pytest.MonkeyPatch,
387
+ ) -> None:
388
+ calls = 0
389
+ sleeps: list[float] = []
390
+
304
391
  def fail(url: str, **kwargs: Any) -> httpx.Response:
305
- raise httpx.ConnectError("network unavailable", request=httpx.Request("DELETE", url))
392
+ nonlocal calls
393
+ calls += 1
394
+ return _response("DELETE", url, status=503)
306
395
 
307
396
  monkeypatch.setattr(rest_client.httpx, "delete", fail)
397
+ monkeypatch.setattr(rest_client.time, "sleep", sleeps.append)
308
398
 
309
399
  assert rest_client.close_browser_session("sess_123", api_key="tf_test") is False
400
+ assert calls == 3
401
+ assert sleeps == [0.25, 0.5]
310
402
 
311
403
 
312
404
  def test_agent_and_profile_rest_methods_are_removed() -> None:
@@ -34,9 +34,13 @@ def test_routing_context_defaults_on_only_for_configured_tinyfish_mcp(
34
34
  first = context(user_message="ordinary search", conversation_history=[])
35
35
 
36
36
  assert first is not None
37
+ assert routing.ROUTING_CONTEXT_MARKER == '<tinyfish-routing-context version="2">'
37
38
  assert routing.ROUTING_CONTEXT_MARKER in first["context"]
38
39
  assert "`web_search` or `web_extract`" in first["context"]
39
40
  assert "`search` or `fetch_content`" in first["context"]
41
+ assert "research-paper" in first["context"]
42
+ assert "ETag/Last-Modified" in first["context"]
43
+ assert "silently dropping" in first["context"]
40
44
  assert "plain language" in first["context"]
41
45
  assert "persist per-request controls" in first["context"]
42
46
 
@@ -79,6 +83,26 @@ def test_routing_guidance_returns_after_compression_removes_marker(
79
83
  assert result["context"].count(routing.ROUTING_CONTEXT_MARKER) == 1
80
84
 
81
85
 
86
+ def test_v1_marker_does_not_suppress_v2_routing_guidance(
87
+ monkeypatch: pytest.MonkeyPatch,
88
+ ) -> None:
89
+ monkeypatch.setattr(routing, "load_config", _mcp_config)
90
+ context = routing.TinyFishTurnContext(_FakeUpdateChecker()) # type: ignore[arg-type]
91
+
92
+ result = context(
93
+ conversation_history=[
94
+ {
95
+ "role": "user",
96
+ "content": "search",
97
+ "api_content": '<tinyfish-routing-context version="1">old guidance',
98
+ }
99
+ ]
100
+ )
101
+
102
+ assert result is not None
103
+ assert routing.ROUTING_CONTEXT_MARKER in result["context"]
104
+
105
+
82
106
  @pytest.mark.parametrize(
83
107
  "config",
84
108
  [
@@ -715,35 +715,53 @@ def test_credits_reset_removes_retired_keys_and_leaves_browser_denied(
715
715
  assert saved[-1]["tinyfish"]["credit_policy"] == {"browser": "deny"}
716
716
 
717
717
 
718
- def test_usage_calls_fetch_usage_and_labels_surface(
718
+ def test_usage_calls_search_and_fetch_usage(
719
719
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
720
720
  ) -> None:
721
- calls: list[str] = []
721
+ calls: list[tuple[str, str]] = []
722
722
  monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
723
723
 
724
+ def search_usage(*, api_key: str) -> dict[str, Any]:
725
+ calls.append(("search", api_key))
726
+ return {"items": [{"status": "completed"}]}
727
+
724
728
  def fetch_usage(*, api_key: str) -> dict[str, Any]:
725
- calls.append(api_key)
729
+ calls.append(("fetch", api_key))
726
730
  return {"operations": [{"status": "completed"}]}
727
731
 
732
+ monkeypatch.setattr(cli.rest_client, "search_usage", search_usage)
728
733
  monkeypatch.setattr(cli.rest_client, "fetch_usage", fetch_usage)
729
734
 
730
735
  result = cli.cmd_usage(argparse.Namespace(json=True))
731
736
  payload = json.loads(capsys.readouterr().out)
732
737
 
733
738
  assert result == 0
734
- assert calls == ["tf_secret"]
739
+ assert calls == [("search", "tf_secret"), ("fetch", "tf_secret")]
735
740
  assert payload == {
736
741
  "success": True,
737
- "surface": "fetch",
738
- "data": {"operations": [{"status": "completed"}]},
742
+ "surfaces": {
743
+ "search": {
744
+ "success": True,
745
+ "data": {"items": [{"status": "completed"}]},
746
+ },
747
+ "fetch": {
748
+ "success": True,
749
+ "data": {"operations": [{"status": "completed"}]},
750
+ },
751
+ },
739
752
  }
740
753
  assert "tf_secret" not in json.dumps(payload)
741
754
 
742
755
 
743
- def test_usage_failure_keeps_fetch_surface_and_returns_failure(
756
+ def test_usage_partial_failure_returns_both_surface_results_and_failure(
744
757
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
745
758
  ) -> None:
746
759
  monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
760
+ monkeypatch.setattr(
761
+ cli.rest_client,
762
+ "search_usage",
763
+ lambda *, api_key: {"items": [{"status": "completed"}]},
764
+ )
747
765
 
748
766
  def fail_usage(*, api_key: str) -> dict[str, Any]:
749
767
  raise RuntimeError("usage unavailable")
@@ -755,14 +773,22 @@ def test_usage_failure_keeps_fetch_surface_and_returns_failure(
755
773
 
756
774
  assert result == 1
757
775
  assert payload["success"] is False
758
- assert payload["surface"] == "fetch"
759
- assert payload["error"] == "usage unavailable"
776
+ assert payload["surfaces"]["search"]["success"] is True
777
+ assert payload["surfaces"]["fetch"] == {
778
+ "success": False,
779
+ "error": "usage unavailable",
780
+ }
760
781
 
761
782
 
762
- def test_usage_without_api_key_fails_before_calling_fetch_usage(
783
+ def test_usage_without_api_key_fails_before_calling_usage_endpoints(
763
784
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
764
785
  ) -> None:
765
786
  monkeypatch.setattr(cli, "_get_env", lambda name: "")
787
+ monkeypatch.setattr(
788
+ cli.rest_client,
789
+ "search_usage",
790
+ lambda **kwargs: pytest.fail("Search usage must not be called"),
791
+ )
766
792
  monkeypatch.setattr(
767
793
  cli.rest_client,
768
794
  "fetch_usage",