hermes-plugin-tinyfish 0.3.1__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.
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/CHANGELOG.md +22 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/PKG-INFO +8 -6
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/README.md +6 -4
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/rest_client.py +81 -13
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/routing_context.py +3 -3
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/setup_cli.py +22 -10
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/plugin.yaml +1 -1
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/pyproject.toml +1 -1
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_rest_client.py +101 -9
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_routing_context.py +24 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_setup_cli.py +36 -10
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/__init__.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/generate_report.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/improve_description.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/package_skill.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/quick_validate.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/run_eval.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/run_loop.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.agents/skills/skill-creator/scripts/utils.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/.gitignore +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/CODE_OF_CONDUCT.md +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/CONTRIBUTING.md +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/LICENSE +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/SECURITY.md +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/__init__.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/__init__.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/browser_provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/config.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/credit_policy.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/health.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/normalize.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/update_check.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/scripts/prepare_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/scripts/validate_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/conftest.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_browser_provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_credit_policy.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_health.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_live.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_normalize.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_prepare_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_register.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_update_check.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_validate_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_version.py +0 -0
|
@@ -7,6 +7,28 @@ 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
|
+
|
|
10
32
|
## [0.3.1] - 2026-08-04
|
|
11
33
|
|
|
12
34
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: hermes-plugin-tinyfish
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -194,8 +194,9 @@ surface:
|
|
|
194
194
|
- “Search only these domains, in English, after this date, and return page 2”
|
|
195
195
|
uses TinyFish MCP's native `search` tool because the generic search schema
|
|
196
196
|
cannot express all of those controls.
|
|
197
|
-
- “
|
|
198
|
-
|
|
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`.
|
|
199
200
|
|
|
200
201
|
Hermes keeps the visible stored user message clean but persists the exact
|
|
201
202
|
API-bound message in an `api_content` sidecar for prompt-cache replay. The note
|
|
@@ -299,8 +300,9 @@ hermes tinyfish doctor --live-paid
|
|
|
299
300
|
present. It does not mean the access or refresh token is valid.
|
|
300
301
|
- `/tinyfish-status` shows non-networked status inside CLI or gateway sessions;
|
|
301
302
|
`/tinyfish-status live` explicitly runs Search and Fetch checks.
|
|
302
|
-
- `usage` reads TinyFish Fetch operation history
|
|
303
|
-
billing
|
|
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.
|
|
304
306
|
- `doctor --live-paid` only creates and closes a TinyFish Browser session. It
|
|
305
307
|
refuses under `deny`, requests approval under `request`, and never prints
|
|
306
308
|
connection URLs or credentials.
|
|
@@ -162,8 +162,9 @@ surface:
|
|
|
162
162
|
- “Search only these domains, in English, after this date, and return page 2”
|
|
163
163
|
uses TinyFish MCP's native `search` tool because the generic search schema
|
|
164
164
|
cannot express all of those controls.
|
|
165
|
-
- “
|
|
166
|
-
|
|
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`.
|
|
167
168
|
|
|
168
169
|
Hermes keeps the visible stored user message clean but persists the exact
|
|
169
170
|
API-bound message in an `api_content` sidecar for prompt-cache replay. The note
|
|
@@ -267,8 +268,9 @@ hermes tinyfish doctor --live-paid
|
|
|
267
268
|
present. It does not mean the access or refresh token is valid.
|
|
268
269
|
- `/tinyfish-status` shows non-networked status inside CLI or gateway sessions;
|
|
269
270
|
`/tinyfish-status live` explicitly runs Search and Fetch checks.
|
|
270
|
-
- `usage` reads TinyFish Fetch operation history
|
|
271
|
-
billing
|
|
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.
|
|
272
274
|
- `doctor --live-paid` only creates and closes a TinyFish Browser session. It
|
|
273
275
|
refuses under `deny`, requests approval under `request`, and never prints
|
|
274
276
|
connection URLs or credentials.
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/rest_client.py
RENAMED
|
@@ -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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
158
|
-
"""
|
|
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(
|
|
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(
|
|
211
|
+
_raise_http_error(service, exc)
|
|
166
212
|
except httpx.RequestError as exc:
|
|
167
|
-
raise TinyFishRestError(f"Could not reach
|
|
213
|
+
raise TinyFishRestError(f"Could not reach {service}: {exc}") from exc
|
|
168
214
|
except ValueError as exc:
|
|
169
|
-
raise TinyFishRestError("
|
|
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="
|
|
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:
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/setup_cli.py
RENAMED
|
@@ -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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
272
|
-
def
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
738
|
-
|
|
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
|
|
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["
|
|
759
|
-
assert payload["
|
|
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
|
|
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",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/config.py
RENAMED
|
File without changes
|
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/health.py
RENAMED
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/normalize.py
RENAMED
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/provider.py
RENAMED
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/hermes_plugin_tinyfish/update_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_browser_provider.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.2}/tests/test_validate_release.py
RENAMED
|
File without changes
|
|
File without changes
|