hermes-plugin-tinyfish 0.3.1__tar.gz → 0.3.3__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.3}/CHANGELOG.md +30 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/PKG-INFO +9 -6
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/README.md +7 -4
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/rest_client.py +81 -13
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/routing_context.py +3 -3
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/setup_cli.py +145 -19
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/plugin.yaml +1 -1
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/pyproject.toml +1 -1
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_rest_client.py +101 -9
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_routing_context.py +24 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_setup_cli.py +133 -10
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/__init__.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/generate_report.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/improve_description.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/package_skill.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/quick_validate.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/run_eval.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/run_loop.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.agents/skills/skill-creator/scripts/utils.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/.gitignore +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/CODE_OF_CONDUCT.md +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/CONTRIBUTING.md +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/LICENSE +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/SECURITY.md +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/__init__.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/__init__.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/browser_provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/config.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/credit_policy.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/health.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/normalize.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/update_check.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/scripts/prepare_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/scripts/validate_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/conftest.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_browser_provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_credit_policy.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_health.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_live.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_normalize.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_prepare_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_provider.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_register.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_update_check.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_validate_release.py +0 -0
- {hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/tests/test_version.py +0 -0
|
@@ -7,6 +7,36 @@ and this project uses semantic versioning.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.3.3] - 2026-08-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Replaced the unreadable raw dictionary output from `hermes tinyfish usage`
|
|
15
|
+
with labeled Search and Fetch histories, pagination summaries, and clear
|
|
16
|
+
partial-failure reporting while preserving `--json` for automation.
|
|
17
|
+
|
|
18
|
+
## [0.3.2] - 2026-08-11
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added automated compatibility coverage for the Hermes 0.20.0 source release,
|
|
23
|
+
alongside the packaged 0.18.2 and 0.19.0 compatibility jobs.
|
|
24
|
+
- Expanded `hermes tinyfish usage` to report both Search and Fetch operation
|
|
25
|
+
history, including per-surface success or failure.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Bumped the ephemeral routing context to version 2 with research-paper and
|
|
30
|
+
publication-year search modes, conditional Fetch validators, and explicit
|
|
31
|
+
guidance not to silently drop requested controls when native MCP tools are
|
|
32
|
+
unavailable.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Made Browser session termination retry documented transient and transport
|
|
37
|
+
failures within a bounded cleanup budget, honor capped `Retry-After` delays,
|
|
38
|
+
and stop treating HTTP 404 as successful cleanup.
|
|
39
|
+
|
|
10
40
|
## [0.3.1] - 2026-08-04
|
|
11
41
|
|
|
12
42
|
### 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.3
|
|
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,10 @@ 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
|
-
|
|
303
|
+
- `usage` reads TinyFish Search and Fetch operation history independently and
|
|
304
|
+
prints a labeled, paginated history for people. Use `usage --json` for the raw
|
|
305
|
+
machine-readable response. It reports per-surface success or failure and is
|
|
306
|
+
not Agent or Browser billing data.
|
|
304
307
|
- `doctor --live-paid` only creates and closes a TinyFish Browser session. It
|
|
305
308
|
refuses under `deny`, requests approval under `request`, and never prints
|
|
306
309
|
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,10 @@ 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
|
-
|
|
271
|
+
- `usage` reads TinyFish Search and Fetch operation history independently and
|
|
272
|
+
prints a labeled, paginated history for people. Use `usage --json` for the raw
|
|
273
|
+
machine-readable response. It reports per-surface success or failure and is
|
|
274
|
+
not Agent or Browser billing data.
|
|
272
275
|
- `doctor --live-paid` only creates and closes a TinyFish Browser session. It
|
|
273
276
|
refuses under `deny`, requests approval under `request`, and never prints
|
|
274
277
|
connection URLs or credentials.
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/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.3}/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
|
|
|
@@ -602,19 +603,11 @@ def tinyfish_status_command(
|
|
|
602
603
|
return "\n".join(_status_lines(status))
|
|
603
604
|
|
|
604
605
|
|
|
605
|
-
def _print_json_or_text(payload: dict[str, Any], *, as_json: bool) -> None:
|
|
606
|
-
if as_json:
|
|
607
|
-
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
608
|
-
else:
|
|
609
|
-
for key, value in payload.items():
|
|
610
|
-
print(f"{key}: {value}")
|
|
611
|
-
|
|
612
|
-
|
|
613
606
|
def _api_key_or_error() -> str | None:
|
|
614
607
|
api_key = _get_env("TINYFISH_API_KEY")
|
|
615
608
|
if not api_key:
|
|
616
609
|
print(
|
|
617
|
-
"TINYFISH_API_KEY is required for TinyFish Fetch usage and Browser operations.",
|
|
610
|
+
"TINYFISH_API_KEY is required for TinyFish Search/Fetch usage and Browser operations.",
|
|
618
611
|
file=sys.stderr,
|
|
619
612
|
)
|
|
620
613
|
return None
|
|
@@ -709,17 +702,150 @@ def cmd_credits(args: argparse.Namespace) -> int:
|
|
|
709
702
|
return 2
|
|
710
703
|
|
|
711
704
|
|
|
705
|
+
def _usage_surface(
|
|
706
|
+
reader: Callable[..., dict[str, Any]],
|
|
707
|
+
*,
|
|
708
|
+
api_key: str,
|
|
709
|
+
) -> dict[str, Any]:
|
|
710
|
+
try:
|
|
711
|
+
return {"success": True, "data": reader(api_key=api_key)}
|
|
712
|
+
except Exception as exc: # noqa: BLE001
|
|
713
|
+
return {"success": False, "error": str(exc)}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
_USAGE_FIELD_ORDER = (
|
|
717
|
+
"created_at",
|
|
718
|
+
"status",
|
|
719
|
+
"query",
|
|
720
|
+
"url",
|
|
721
|
+
"final_url",
|
|
722
|
+
"title",
|
|
723
|
+
"description",
|
|
724
|
+
"domain_type",
|
|
725
|
+
"location",
|
|
726
|
+
"language",
|
|
727
|
+
"author",
|
|
728
|
+
"published_date",
|
|
729
|
+
"format",
|
|
730
|
+
"results_count",
|
|
731
|
+
"total_results",
|
|
732
|
+
"text_length",
|
|
733
|
+
"links_count",
|
|
734
|
+
"image_links_count",
|
|
735
|
+
"latency_ms",
|
|
736
|
+
"request_origin",
|
|
737
|
+
"id",
|
|
738
|
+
"request_id",
|
|
739
|
+
"error",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
_USAGE_FIELD_LABELS = {
|
|
743
|
+
"id": "ID",
|
|
744
|
+
"url": "URL",
|
|
745
|
+
"final_url": "Final URL",
|
|
746
|
+
"latency_ms": "Latency (ms)",
|
|
747
|
+
"request_id": "Request ID",
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _usage_field_label(key: str) -> str:
|
|
752
|
+
return _USAGE_FIELD_LABELS.get(key, key.replace("_", " ").capitalize())
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _usage_value_lines(value: Any) -> list[str]:
|
|
756
|
+
if value is None:
|
|
757
|
+
return ["-"]
|
|
758
|
+
if isinstance(value, bool):
|
|
759
|
+
return ["yes" if value else "no"]
|
|
760
|
+
if isinstance(value, list) and all(
|
|
761
|
+
item is None or isinstance(item, (str, int, float, bool)) for item in value
|
|
762
|
+
):
|
|
763
|
+
return [", ".join(str(item) for item in value) if value else "-"]
|
|
764
|
+
if isinstance(value, (dict, list)):
|
|
765
|
+
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False).splitlines()
|
|
766
|
+
return [str(value)]
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _usage_items(data: dict[str, Any]) -> list[Any]:
|
|
770
|
+
for key in ("items", "operations"):
|
|
771
|
+
value = data.get(key)
|
|
772
|
+
if isinstance(value, list):
|
|
773
|
+
return value
|
|
774
|
+
return []
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _usage_summary(data: dict[str, Any], *, shown: int) -> str:
|
|
778
|
+
noun = "operation" if shown == 1 else "operations"
|
|
779
|
+
parts = [f"{shown} {noun}"]
|
|
780
|
+
total = data.get("total")
|
|
781
|
+
if isinstance(total, int) and not isinstance(total, bool):
|
|
782
|
+
parts[0] = f"{shown} shown / {total} total"
|
|
783
|
+
page = data.get("page")
|
|
784
|
+
total_pages = data.get("total_pages")
|
|
785
|
+
if isinstance(page, int) and isinstance(total_pages, int):
|
|
786
|
+
parts.append(f"page {page} of {total_pages}")
|
|
787
|
+
limit = data.get("limit")
|
|
788
|
+
if isinstance(limit, int) and not isinstance(limit, bool):
|
|
789
|
+
parts.append(f"limit {limit}")
|
|
790
|
+
if isinstance(data.get("has_more"), bool):
|
|
791
|
+
parts.append(f"more: {'yes' if data['has_more'] else 'no'}")
|
|
792
|
+
return " | ".join(parts)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _usage_text_lines(payload: dict[str, Any]) -> list[str]:
|
|
796
|
+
lines = ["TinyFish usage"]
|
|
797
|
+
surfaces = payload.get("surfaces")
|
|
798
|
+
if not isinstance(surfaces, dict):
|
|
799
|
+
return [*lines, " Invalid usage response."]
|
|
800
|
+
|
|
801
|
+
for surface_name in ("search", "fetch"):
|
|
802
|
+
lines.extend(("", surface_name.capitalize()))
|
|
803
|
+
result = surfaces.get(surface_name)
|
|
804
|
+
if not isinstance(result, dict):
|
|
805
|
+
lines.append(" No response.")
|
|
806
|
+
continue
|
|
807
|
+
if not result.get("success"):
|
|
808
|
+
lines.append(f" Error: {result.get('error') or 'Unknown error'}")
|
|
809
|
+
continue
|
|
810
|
+
|
|
811
|
+
data = result.get("data")
|
|
812
|
+
if not isinstance(data, dict):
|
|
813
|
+
lines.append(" Invalid usage response.")
|
|
814
|
+
continue
|
|
815
|
+
items = _usage_items(data)
|
|
816
|
+
lines.append(f" {_usage_summary(data, shown=len(items))}")
|
|
817
|
+
if not items:
|
|
818
|
+
lines.append(" No operations found on this page.")
|
|
819
|
+
continue
|
|
820
|
+
|
|
821
|
+
for index, item in enumerate(items, start=1):
|
|
822
|
+
lines.extend(("", f" Operation {index}"))
|
|
823
|
+
if not isinstance(item, dict):
|
|
824
|
+
lines.append(f" Value: {_usage_value_lines(item)[0]}")
|
|
825
|
+
continue
|
|
826
|
+
ordered_keys = [key for key in _USAGE_FIELD_ORDER if key in item]
|
|
827
|
+
ordered_keys.extend(sorted(str(key) for key in item if key not in ordered_keys))
|
|
828
|
+
for key in ordered_keys:
|
|
829
|
+
value_lines = _usage_value_lines(item[key])
|
|
830
|
+
lines.append(f" {_usage_field_label(key)}: {value_lines[0]}")
|
|
831
|
+
lines.extend(f" {line}" for line in value_lines[1:])
|
|
832
|
+
return lines
|
|
833
|
+
|
|
834
|
+
|
|
712
835
|
def cmd_usage(args: argparse.Namespace) -> int:
|
|
713
836
|
api_key = _api_key_or_error()
|
|
714
837
|
if not api_key:
|
|
715
838
|
return 1
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
839
|
+
surfaces = {
|
|
840
|
+
"search": _usage_surface(rest_client.search_usage, api_key=api_key),
|
|
841
|
+
"fetch": _usage_surface(rest_client.fetch_usage, api_key=api_key),
|
|
842
|
+
}
|
|
843
|
+
payload = {
|
|
844
|
+
"success": all(result["success"] for result in surfaces.values()),
|
|
845
|
+
"surfaces": surfaces,
|
|
846
|
+
}
|
|
847
|
+
if bool(getattr(args, "json", False)):
|
|
848
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
849
|
+
else:
|
|
850
|
+
print("\n".join(_usage_text_lines(payload)))
|
|
725
851
|
return 0 if payload["success"] else 1
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: web-tinyfish
|
|
2
|
-
version: 0.3.
|
|
2
|
+
version: 0.3.3
|
|
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.3"
|
|
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,150 @@ 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_text_output_formats_operation_history_for_people(
|
|
757
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
758
|
+
) -> None:
|
|
759
|
+
monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
|
|
760
|
+
monkeypatch.setattr(
|
|
761
|
+
cli.rest_client,
|
|
762
|
+
"search_usage",
|
|
763
|
+
lambda *, api_key: {
|
|
764
|
+
"items": [
|
|
765
|
+
{
|
|
766
|
+
"id": "search-1",
|
|
767
|
+
"query": "Hermes Agent",
|
|
768
|
+
"status": "completed",
|
|
769
|
+
"results_count": 10,
|
|
770
|
+
"latency_ms": 125,
|
|
771
|
+
"created_at": "2026-08-12T20:00:00Z",
|
|
772
|
+
}
|
|
773
|
+
],
|
|
774
|
+
"total": 3,
|
|
775
|
+
"limit": 1,
|
|
776
|
+
"page": 1,
|
|
777
|
+
"total_pages": 3,
|
|
778
|
+
"has_more": True,
|
|
779
|
+
},
|
|
780
|
+
)
|
|
781
|
+
monkeypatch.setattr(
|
|
782
|
+
cli.rest_client,
|
|
783
|
+
"fetch_usage",
|
|
784
|
+
lambda *, api_key: {
|
|
785
|
+
"operations": [
|
|
786
|
+
{
|
|
787
|
+
"url": "https://example.com",
|
|
788
|
+
"status": "failed",
|
|
789
|
+
"error": "timeout",
|
|
790
|
+
"created_at": "2026-08-12T20:01:00Z",
|
|
791
|
+
}
|
|
792
|
+
],
|
|
793
|
+
"total": 1,
|
|
794
|
+
"limit": 100,
|
|
795
|
+
"page": 1,
|
|
796
|
+
"total_pages": 1,
|
|
797
|
+
"has_more": False,
|
|
798
|
+
},
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
result = cli.cmd_usage(argparse.Namespace(json=False))
|
|
802
|
+
output = capsys.readouterr().out
|
|
803
|
+
|
|
804
|
+
assert result == 0
|
|
805
|
+
assert (
|
|
806
|
+
output
|
|
807
|
+
== """TinyFish usage
|
|
808
|
+
|
|
809
|
+
Search
|
|
810
|
+
1 shown / 3 total | page 1 of 3 | limit 1 | more: yes
|
|
811
|
+
|
|
812
|
+
Operation 1
|
|
813
|
+
Created at: 2026-08-12T20:00:00Z
|
|
814
|
+
Status: completed
|
|
815
|
+
Query: Hermes Agent
|
|
816
|
+
Results count: 10
|
|
817
|
+
Latency (ms): 125
|
|
818
|
+
ID: search-1
|
|
819
|
+
|
|
820
|
+
Fetch
|
|
821
|
+
1 shown / 1 total | page 1 of 1 | limit 100 | more: no
|
|
822
|
+
|
|
823
|
+
Operation 1
|
|
824
|
+
Created at: 2026-08-12T20:01:00Z
|
|
825
|
+
Status: failed
|
|
826
|
+
URL: https://example.com
|
|
827
|
+
Error: timeout
|
|
828
|
+
"""
|
|
829
|
+
)
|
|
830
|
+
assert "{'" not in output
|
|
831
|
+
assert "tf_secret" not in output
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def test_usage_text_output_keeps_partial_failure_readable(
|
|
744
835
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
745
836
|
) -> None:
|
|
746
837
|
monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
|
|
838
|
+
monkeypatch.setattr(cli.rest_client, "search_usage", lambda *, api_key: {"items": []})
|
|
839
|
+
|
|
840
|
+
def fail_usage(*, api_key: str) -> dict[str, Any]:
|
|
841
|
+
raise RuntimeError("usage unavailable")
|
|
842
|
+
|
|
843
|
+
monkeypatch.setattr(cli.rest_client, "fetch_usage", fail_usage)
|
|
844
|
+
|
|
845
|
+
result = cli.cmd_usage(argparse.Namespace(json=False))
|
|
846
|
+
output = capsys.readouterr().out
|
|
847
|
+
|
|
848
|
+
assert result == 1
|
|
849
|
+
assert "Search\n 0 operations\n No operations found on this page." in output
|
|
850
|
+
assert "Fetch\n Error: usage unavailable" in output
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def test_usage_partial_failure_returns_both_surface_results_and_failure(
|
|
854
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
855
|
+
) -> None:
|
|
856
|
+
monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
|
|
857
|
+
monkeypatch.setattr(
|
|
858
|
+
cli.rest_client,
|
|
859
|
+
"search_usage",
|
|
860
|
+
lambda *, api_key: {"items": [{"status": "completed"}]},
|
|
861
|
+
)
|
|
747
862
|
|
|
748
863
|
def fail_usage(*, api_key: str) -> dict[str, Any]:
|
|
749
864
|
raise RuntimeError("usage unavailable")
|
|
@@ -755,14 +870,22 @@ def test_usage_failure_keeps_fetch_surface_and_returns_failure(
|
|
|
755
870
|
|
|
756
871
|
assert result == 1
|
|
757
872
|
assert payload["success"] is False
|
|
758
|
-
assert payload["
|
|
759
|
-
assert payload["
|
|
873
|
+
assert payload["surfaces"]["search"]["success"] is True
|
|
874
|
+
assert payload["surfaces"]["fetch"] == {
|
|
875
|
+
"success": False,
|
|
876
|
+
"error": "usage unavailable",
|
|
877
|
+
}
|
|
760
878
|
|
|
761
879
|
|
|
762
|
-
def
|
|
880
|
+
def test_usage_without_api_key_fails_before_calling_usage_endpoints(
|
|
763
881
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
764
882
|
) -> None:
|
|
765
883
|
monkeypatch.setattr(cli, "_get_env", lambda name: "")
|
|
884
|
+
monkeypatch.setattr(
|
|
885
|
+
cli.rest_client,
|
|
886
|
+
"search_usage",
|
|
887
|
+
lambda **kwargs: pytest.fail("Search usage must not be called"),
|
|
888
|
+
)
|
|
766
889
|
monkeypatch.setattr(
|
|
767
890
|
cli.rest_client,
|
|
768
891
|
"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.3}/hermes_plugin_tinyfish/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/config.py
RENAMED
|
File without changes
|
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/health.py
RENAMED
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/normalize.py
RENAMED
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/hermes_plugin_tinyfish/provider.py
RENAMED
|
File without changes
|
{hermes_plugin_tinyfish-0.3.1 → hermes_plugin_tinyfish-0.3.3}/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.3}/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.3}/tests/test_validate_release.py
RENAMED
|
File without changes
|
|
File without changes
|