hermes-plugin-tinyfish 0.3.3__tar.gz → 0.3.4__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.3 → hermes_plugin_tinyfish-0.3.4}/CHANGELOG.md +10 -0
  2. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/PKG-INFO +7 -5
  3. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/README.md +6 -4
  4. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/rest_client.py +26 -2
  5. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/setup_cli.py +88 -134
  6. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/plugin.yaml +1 -1
  7. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/pyproject.toml +1 -1
  8. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_rest_client.py +47 -5
  9. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_setup_cli.py +77 -111
  10. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  11. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
  12. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/generate_report.py +0 -0
  13. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/improve_description.py +0 -0
  14. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/package_skill.py +0 -0
  15. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/quick_validate.py +0 -0
  16. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/run_eval.py +0 -0
  17. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/run_loop.py +0 -0
  18. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/utils.py +0 -0
  19. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/.gitignore +0 -0
  20. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/CODE_OF_CONDUCT.md +0 -0
  21. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/CONTRIBUTING.md +0 -0
  22. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/LICENSE +0 -0
  23. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/SECURITY.md +0 -0
  24. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/__init__.py +0 -0
  25. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/__init__.py +0 -0
  26. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/browser_provider.py +0 -0
  27. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/config.py +0 -0
  28. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/credit_policy.py +0 -0
  29. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/health.py +0 -0
  30. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/normalize.py +0 -0
  31. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/provider.py +0 -0
  32. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/routing_context.py +0 -0
  33. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/update_check.py +0 -0
  34. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/scripts/prepare_release.py +0 -0
  35. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/scripts/validate_release.py +0 -0
  36. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/conftest.py +0 -0
  37. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_browser_provider.py +0 -0
  38. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_credit_policy.py +0 -0
  39. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_health.py +0 -0
  40. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_live.py +0 -0
  41. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_normalize.py +0 -0
  42. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_prepare_release.py +0 -0
  43. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_provider.py +0 -0
  44. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_register.py +0 -0
  45. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_routing_context.py +0 -0
  46. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_update_check.py +0 -0
  47. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_validate_release.py +0 -0
  48. {hermes_plugin_tinyfish-0.3.3 → hermes_plugin_tinyfish-0.3.4}/tests/test_version.py +0 -0
@@ -7,6 +7,16 @@ and this project uses semantic versioning.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.3.4] - 2026-08-13
11
+
12
+ ### Fixed
13
+
14
+ - Replaced `hermes tinyfish usage` request-history output with the actual
15
+ TinyFish wallet balance, auto-reload, pending top-up, and billing-rate API.
16
+ Legacy-billing accounts now receive a clear explanation instead of request
17
+ history, and the command states that no documented historical-spend API is
18
+ available.
19
+
10
20
  ## [0.3.3] - 2026-08-12
11
21
 
12
22
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hermes-plugin-tinyfish
3
- Version: 0.3.3
3
+ Version: 0.3.4
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
@@ -300,10 +300,12 @@ hermes tinyfish doctor --live-paid
300
300
  present. It does not mean the access or refresh token is valid.
301
301
  - `/tinyfish-status` shows non-networked status inside CLI or gateway sessions;
302
302
  `/tinyfish-status live` explicitly runs Search and Fetch checks.
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.
303
+ - `usage` reads TinyFish's wallet API and reports the available balance,
304
+ auto-reload state, pending top-ups, and per-product billing rates. TinyFish
305
+ does not expose historical aggregate spend through its documented APIs. For
306
+ legacy-billing accounts without a Metronome wallet, the command explains why
307
+ balance data is unavailable and links to the billing dashboard. Use
308
+ `usage --json` for machine-readable output; request history is never dumped.
307
309
  - `doctor --live-paid` only creates and closes a TinyFish Browser session. It
308
310
  refuses under `deny`, requests approval under `request`, and never prints
309
311
  connection URLs or credentials.
@@ -268,10 +268,12 @@ hermes tinyfish doctor --live-paid
268
268
  present. It does not mean the access or refresh token is valid.
269
269
  - `/tinyfish-status` shows non-networked status inside CLI or gateway sessions;
270
270
  `/tinyfish-status live` explicitly runs Search and Fetch checks.
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.
271
+ - `usage` reads TinyFish's wallet API and reports the available balance,
272
+ auto-reload state, pending top-ups, and per-product billing rates. TinyFish
273
+ does not expose historical aggregate spend through its documented APIs. For
274
+ legacy-billing accounts without a Metronome wallet, the command explains why
275
+ balance data is unavailable and links to the billing dashboard. Use
276
+ `usage --json` for machine-readable output; request history is never dumped.
275
277
  - `doctor --live-paid` only creates and closes a TinyFish Browser session. It
276
278
  refuses under `deny`, requests approval under `request`, and never prints
277
279
  connection URLs or credentials.
@@ -11,6 +11,7 @@ import httpx
11
11
  SEARCH_URL = "https://api.search.tinyfish.ai"
12
12
  FETCH_URL = "https://api.fetch.tinyfish.ai"
13
13
  BROWSER_URL = "https://api.browser.tinyfish.ai"
14
+ WALLET_URL = "https://agent.tinyfish.ai/v1/wallet"
14
15
  SEARCH_USAGE_URL = f"{SEARCH_URL}/usage"
15
16
  FETCH_USAGE_URL = f"{FETCH_URL}/usage"
16
17
 
@@ -24,6 +25,10 @@ class TinyFishRestError(RuntimeError):
24
25
  """Raised for TinyFish REST transport or HTTP failures."""
25
26
 
26
27
 
28
+ class TinyFishWalletNotFound(TinyFishRestError):
29
+ """Raised when an account uses legacy billing or has no wallet yet."""
30
+
31
+
27
32
  def _headers(api_key: str) -> dict[str, str]:
28
33
  return {"X-API-Key": api_key, "Accept": "application/json"}
29
34
 
@@ -237,7 +242,26 @@ def fetch_usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
237
242
  )
238
243
 
239
244
 
245
+ def wallet(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
246
+ """Return the caller's TinyFish wallet balance and billing rates."""
247
+
248
+ try:
249
+ response = httpx.get(WALLET_URL, headers=_headers(api_key), timeout=timeout)
250
+ response.raise_for_status()
251
+ return cast(dict[str, Any], response.json())
252
+ except httpx.HTTPStatusError as exc:
253
+ if exc.response.status_code == 404:
254
+ raise TinyFishWalletNotFound(
255
+ "TinyFish has no wallet for this account (legacy billing or no Metronome customer yet)."
256
+ ) from exc
257
+ _raise_http_error("TinyFish Wallet", exc)
258
+ except httpx.RequestError as exc:
259
+ raise TinyFishRestError(f"Could not reach TinyFish Wallet: {exc}") from exc
260
+ except ValueError as exc:
261
+ raise TinyFishRestError("TinyFish Wallet returned invalid JSON") from exc
262
+
263
+
240
264
  def usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
241
- """Compatibility wrapper for the Fetch usage endpoint."""
265
+ """Compatibility wrapper for the wallet-backed usage command."""
242
266
 
243
- return fetch_usage(api_key=api_key, timeout=timeout)
267
+ return wallet(api_key=api_key, timeout=timeout)
@@ -6,7 +6,6 @@ import argparse
6
6
  import json
7
7
  import os
8
8
  import sys
9
- from collections.abc import Callable
10
9
  from pathlib import Path
11
10
  from typing import Any, cast
12
11
 
@@ -35,6 +34,7 @@ from .routing_context import tinyfish_mcp_configured
35
34
  from .update_check import InstallInfo, UpdateChecker
36
35
 
37
36
  MCP_LOGIN_COMMAND = ("hermes", "mcp", "login", "tinyfish")
37
+ TINYFISH_BILLING_URL = "https://agent.tinyfish.ai/api-keys"
38
38
 
39
39
 
40
40
  def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
@@ -86,7 +86,7 @@ def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
86
86
  credits_set.add_argument("policy", choices=list(CREDIT_POLICIES))
87
87
  credits_sub.add_parser("reset", help="Reset Browser to deny and remove retired Agent/Profile policy keys")
88
88
 
89
- usage = sub.add_parser("usage", help="Read TinyFish Search and Fetch operation history")
89
+ usage = sub.add_parser("usage", help="Read TinyFish wallet balance and billing rates")
90
90
  usage.add_argument("--json", action="store_true", help="Print machine-readable JSON")
91
91
 
92
92
 
@@ -702,133 +702,64 @@ def cmd_credits(args: argparse.Namespace) -> int:
702
702
  return 2
703
703
 
704
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
-
705
+ def _wallet_amount(value: Any, currency: Any) -> str:
706
+ amount = str(value) if value is not None else "unavailable"
707
+ code = str(currency or "").strip()
708
+ if amount == "unavailable":
709
+ return amount
710
+ if code == "USD":
711
+ return f"${amount} USD"
712
+ return f"{amount} {code}".rstrip()
750
713
 
751
- def _usage_field_label(key: str) -> str:
752
- return _USAGE_FIELD_LABELS.get(key, key.replace("_", " ").capitalize())
753
714
 
715
+ def _wallet_text_lines(wallet: dict[str, Any]) -> list[str]:
716
+ currency = wallet.get("currency")
717
+ lines = [
718
+ "TinyFish usage",
719
+ f" Available balance: {_wallet_amount(wallet.get('available_balance'), currency)}",
720
+ f" As of: {wallet.get('as_of') or 'unavailable'}",
721
+ ]
754
722
 
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]}")
723
+ auto_reload = wallet.get("auto_reload")
724
+ if auto_reload is None:
725
+ lines.append(" Auto-reload: unavailable")
726
+ elif isinstance(auto_reload, dict):
727
+ state = str(auto_reload.get("state") or "unknown").replace("_", " ")
728
+ if state == "unconfigured":
729
+ lines.append(" Auto-reload: not configured")
730
+ else:
731
+ threshold = _wallet_amount(auto_reload.get("threshold"), currency)
732
+ recharge_to = _wallet_amount(auto_reload.get("recharge_to"), currency)
733
+ lines.append(f" Auto-reload: {state} (at {threshold}, recharge to {recharge_to})")
734
+ else:
735
+ lines.append(" Auto-reload: invalid response")
736
+
737
+ pending = wallet.get("pending_top_up")
738
+ if pending is None:
739
+ lines.append(" Pending top-up: none")
740
+ elif isinstance(pending, dict):
741
+ amount = _wallet_amount(pending.get("amount"), currency)
742
+ lines.append(f" Pending top-up: {amount} since {pending.get('started_at') or 'unknown'}")
743
+ else:
744
+ lines.append(" Pending top-up: invalid response")
745
+
746
+ rates = wallet.get("rates")
747
+ meters = rates.get("meters") if isinstance(rates, dict) else None
748
+ if isinstance(meters, list) and meters:
749
+ lines.append(" Rates:")
750
+ for meter in meters:
751
+ if not isinstance(meter, dict):
825
752
  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:])
753
+ label = meter.get("label") or meter.get("product_id") or "Unnamed meter"
754
+ amount = _wallet_amount(meter.get("unit_amount"), meter.get("currency"))
755
+ per = f" per {meter['per']}" if meter.get("per") else ""
756
+ lines.append(f" {label}: {amount}{per}")
757
+ elif rates is None:
758
+ lines.append(" Rates: unavailable")
759
+ else:
760
+ lines.append(" Rates: none")
761
+
762
+ lines.append(" Historical spend: not provided by TinyFish's documented APIs")
832
763
  return lines
833
764
 
834
765
 
@@ -836,16 +767,39 @@ def cmd_usage(args: argparse.Namespace) -> int:
836
767
  api_key = _api_key_or_error()
837
768
  if not api_key:
838
769
  return 1
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
- }
770
+ try:
771
+ wallet = rest_client.wallet(api_key=api_key)
772
+ except rest_client.TinyFishWalletNotFound:
773
+ payload = {
774
+ "success": True,
775
+ "wallet_available": False,
776
+ "reason": "legacy_billing_or_no_metronome_customer",
777
+ "message": "This account uses legacy billing or does not have a Metronome wallet yet.",
778
+ "billing_url": TINYFISH_BILLING_URL,
779
+ }
780
+ text_lines = [
781
+ "TinyFish usage",
782
+ " Wallet balance unavailable.",
783
+ f" {payload['message']}",
784
+ " Historical spend is not provided by TinyFish's documented APIs.",
785
+ f" Billing: {TINYFISH_BILLING_URL}",
786
+ ]
787
+ except Exception as exc: # noqa: BLE001
788
+ payload = {"success": False, "wallet_available": None, "error": str(exc)}
789
+ text_lines = ["TinyFish usage", f" Error: {exc}"]
790
+ else:
791
+ if not isinstance(wallet, dict):
792
+ payload = {
793
+ "success": False,
794
+ "wallet_available": None,
795
+ "error": "TinyFish Wallet returned an invalid response",
796
+ }
797
+ text_lines = ["TinyFish usage", f" Error: {payload['error']}"]
798
+ else:
799
+ payload = {"success": True, "wallet_available": True, "wallet": wallet}
800
+ text_lines = _wallet_text_lines(wallet)
847
801
  if bool(getattr(args, "json", False)):
848
802
  print(json.dumps(payload, indent=2, sort_keys=True))
849
803
  else:
850
- print("\n".join(_usage_text_lines(payload)))
804
+ print("\n".join(text_lines))
851
805
  return 0 if payload["success"] else 1
@@ -1,5 +1,5 @@
1
1
  name: web-tinyfish
2
- version: 0.3.3
2
+ version: 0.3.4
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.3"
7
+ version = "0.3.4"
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"
@@ -183,16 +183,55 @@ def test_search_usage_uses_documented_search_endpoint(monkeypatch: pytest.Monkey
183
183
  }
184
184
 
185
185
 
186
- def test_usage_is_a_compatibility_wrapper(monkeypatch: pytest.MonkeyPatch) -> None:
186
+ def test_wallet_uses_documented_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
187
187
  captured: dict[str, Any] = {}
188
188
 
189
- def fake_fetch_usage(*, api_key: str, timeout: float) -> dict[str, Any]:
189
+ def fake_get(url: str, **kwargs: Any) -> httpx.Response:
190
+ captured.update(url=url, **kwargs)
191
+ return _response("GET", url, payload={"available_balance": "12.34", "currency": "USD"})
192
+
193
+ monkeypatch.setattr(rest_client.httpx, "get", fake_get)
194
+
195
+ result = rest_client.wallet(api_key="tf_test", timeout=13.0)
196
+
197
+ assert result == {"available_balance": "12.34", "currency": "USD"}
198
+ assert captured == {
199
+ "url": "https://agent.tinyfish.ai/v1/wallet",
200
+ "headers": {"X-API-Key": "tf_test", "Accept": "application/json"},
201
+ "timeout": 13.0,
202
+ }
203
+
204
+
205
+ def test_wallet_reports_documented_not_found_account_state(
206
+ monkeypatch: pytest.MonkeyPatch,
207
+ ) -> None:
208
+ def fake_get(url: str, **kwargs: Any) -> httpx.Response:
209
+ return _response(
210
+ "GET",
211
+ url,
212
+ status=404,
213
+ payload={"error": {"code": "NOT_FOUND", "message": "Wallet not found"}},
214
+ )
215
+
216
+ monkeypatch.setattr(rest_client.httpx, "get", fake_get)
217
+
218
+ with pytest.raises(
219
+ rest_client.TinyFishWalletNotFound,
220
+ match="legacy billing or no Metronome customer yet",
221
+ ):
222
+ rest_client.wallet(api_key="tf_test")
223
+
224
+
225
+ def test_usage_is_a_wallet_compatibility_wrapper(monkeypatch: pytest.MonkeyPatch) -> None:
226
+ captured: dict[str, Any] = {}
227
+
228
+ def fake_wallet(*, api_key: str, timeout: float) -> dict[str, Any]:
190
229
  captured.update(api_key=api_key, timeout=timeout)
191
- return {"operations": []}
230
+ return {"available_balance": "12.34"}
192
231
 
193
- monkeypatch.setattr(rest_client, "fetch_usage", fake_fetch_usage)
232
+ monkeypatch.setattr(rest_client, "wallet", fake_wallet)
194
233
 
195
- assert rest_client.usage(api_key="tf_test", timeout=14.0) == {"operations": []}
234
+ assert rest_client.usage(api_key="tf_test", timeout=14.0) == {"available_balance": "12.34"}
196
235
  assert captured == {"api_key": "tf_test", "timeout": 14.0}
197
236
 
198
237
 
@@ -211,6 +250,7 @@ RestCall = Callable[[], dict[str, Any]]
211
250
  ),
212
251
  ("get", lambda: rest_client.search_usage(api_key="tf_test"), "TinyFish Search usage"),
213
252
  ("get", lambda: rest_client.fetch_usage(api_key="tf_test"), "TinyFish Fetch usage"),
253
+ ("get", lambda: rest_client.wallet(api_key="tf_test"), "TinyFish Wallet"),
214
254
  ],
215
255
  )
216
256
  @pytest.mark.parametrize("status", [402, 500])
@@ -245,6 +285,7 @@ def test_rest_methods_report_http_failures(
245
285
  ),
246
286
  ("get", lambda: rest_client.search_usage(api_key="tf_test"), "TinyFish Search usage"),
247
287
  ("get", lambda: rest_client.fetch_usage(api_key="tf_test"), "TinyFish Fetch usage"),
288
+ ("get", lambda: rest_client.wallet(api_key="tf_test"), "TinyFish Wallet"),
248
289
  ],
249
290
  )
250
291
  def test_rest_methods_report_transport_failures(
@@ -274,6 +315,7 @@ def test_rest_methods_report_transport_failures(
274
315
  ),
275
316
  ("get", lambda: rest_client.search_usage(api_key="tf_test"), "TinyFish Search usage"),
276
317
  ("get", lambda: rest_client.fetch_usage(api_key="tf_test"), "TinyFish Fetch usage"),
318
+ ("get", lambda: rest_client.wallet(api_key="tf_test"), "TinyFish Wallet"),
277
319
  ],
278
320
  )
279
321
  def test_rest_methods_report_invalid_json(
@@ -715,86 +715,55 @@ 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_search_and_fetch_usage(
718
+ def test_usage_calls_wallet_and_returns_machine_readable_billing_data(
719
719
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
720
720
  ) -> None:
721
- calls: list[tuple[str, str]] = []
721
+ calls: list[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"}]}
724
+ def wallet(*, api_key: str) -> dict[str, Any]:
725
+ calls.append(api_key)
726
+ return {"available_balance": "21.44", "currency": "USD"}
727
727
 
728
- def fetch_usage(*, api_key: str) -> dict[str, Any]:
729
- calls.append(("fetch", api_key))
730
- return {"operations": [{"status": "completed"}]}
731
-
732
- monkeypatch.setattr(cli.rest_client, "search_usage", search_usage)
733
- monkeypatch.setattr(cli.rest_client, "fetch_usage", fetch_usage)
728
+ monkeypatch.setattr(cli.rest_client, "wallet", wallet)
734
729
 
735
730
  result = cli.cmd_usage(argparse.Namespace(json=True))
736
731
  payload = json.loads(capsys.readouterr().out)
737
732
 
738
733
  assert result == 0
739
- assert calls == [("search", "tf_secret"), ("fetch", "tf_secret")]
734
+ assert calls == ["tf_secret"]
740
735
  assert payload == {
741
736
  "success": True,
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
- },
737
+ "wallet_available": True,
738
+ "wallet": {"available_balance": "21.44", "currency": "USD"},
752
739
  }
753
740
  assert "tf_secret" not in json.dumps(payload)
754
741
 
755
742
 
756
- def test_usage_text_output_formats_operation_history_for_people(
743
+ def test_usage_text_output_formats_wallet_for_people(
757
744
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
758
745
  ) -> None:
759
746
  monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
760
747
  monkeypatch.setattr(
761
748
  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",
749
+ "wallet",
784
750
  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,
751
+ "available_balance": "21.44",
752
+ "currency": "USD",
753
+ "as_of": "2026-08-13T12:00:00Z",
754
+ "auto_reload": {"state": "on", "threshold": "10.00", "recharge_to": "50.00"},
755
+ "pending_top_up": {"amount": "50.00", "started_at": "2026-08-13T11:59:00Z"},
756
+ "rates": {
757
+ "meters": [
758
+ {
759
+ "product_id": "prod_agent",
760
+ "label": "Agent steps",
761
+ "unit_amount": "0.016000",
762
+ "currency": "USD",
763
+ "per": "step",
764
+ }
765
+ ]
766
+ },
798
767
  },
799
768
  )
800
769
 
@@ -805,92 +774,89 @@ def test_usage_text_output_formats_operation_history_for_people(
805
774
  assert (
806
775
  output
807
776
  == """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
777
+ Available balance: $21.44 USD
778
+ As of: 2026-08-13T12:00:00Z
779
+ Auto-reload: on (at $10.00 USD, recharge to $50.00 USD)
780
+ Pending top-up: $50.00 USD since 2026-08-13T11:59:00Z
781
+ Rates:
782
+ Agent steps: $0.016000 USD per step
783
+ Historical spend: not provided by TinyFish's documented APIs
828
784
  """
829
785
  )
830
- assert "{'" not in output
831
786
  assert "tf_secret" not in output
787
+ assert "query" not in output.lower()
788
+ assert "url" not in output.lower()
832
789
 
833
790
 
834
- def test_usage_text_output_keeps_partial_failure_readable(
791
+ def test_usage_wallet_not_found_explains_legacy_billing(
835
792
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
836
793
  ) -> None:
837
794
  monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
838
- monkeypatch.setattr(cli.rest_client, "search_usage", lambda *, api_key: {"items": []})
839
795
 
840
- def fail_usage(*, api_key: str) -> dict[str, Any]:
841
- raise RuntimeError("usage unavailable")
796
+ def wallet_not_found(*, api_key: str) -> dict[str, Any]:
797
+ raise cli.rest_client.TinyFishWalletNotFound("wallet not found")
842
798
 
843
- monkeypatch.setattr(cli.rest_client, "fetch_usage", fail_usage)
799
+ monkeypatch.setattr(cli.rest_client, "wallet", wallet_not_found)
844
800
 
845
801
  result = cli.cmd_usage(argparse.Namespace(json=False))
846
802
  output = capsys.readouterr().out
847
803
 
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
804
+ assert result == 0
805
+ assert "Wallet balance unavailable" in output
806
+ assert "legacy billing or does not have a Metronome wallet" in output
807
+ assert "Historical spend is not provided" in output
808
+ assert cli.TINYFISH_BILLING_URL in output
851
809
 
852
810
 
853
- def test_usage_partial_failure_returns_both_surface_results_and_failure(
811
+ def test_usage_wallet_not_found_is_machine_readable(
854
812
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
855
813
  ) -> None:
856
814
  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
- )
862
815
 
863
- def fail_usage(*, api_key: str) -> dict[str, Any]:
864
- raise RuntimeError("usage unavailable")
816
+ def wallet_not_found(*, api_key: str) -> dict[str, Any]:
817
+ raise cli.rest_client.TinyFishWalletNotFound("wallet not found")
818
+
819
+ monkeypatch.setattr(cli.rest_client, "wallet", wallet_not_found)
820
+
821
+ result = cli.cmd_usage(argparse.Namespace(json=True))
822
+ payload = json.loads(capsys.readouterr().out)
823
+
824
+ assert result == 0
825
+ assert payload == {
826
+ "success": True,
827
+ "wallet_available": False,
828
+ "reason": "legacy_billing_or_no_metronome_customer",
829
+ "message": "This account uses legacy billing or does not have a Metronome wallet yet.",
830
+ "billing_url": cli.TINYFISH_BILLING_URL,
831
+ }
832
+
865
833
 
866
- monkeypatch.setattr(cli.rest_client, "fetch_usage", fail_usage)
834
+ def test_usage_transport_failure_is_reported_without_history(
835
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
836
+ ) -> None:
837
+ monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
838
+
839
+ def fail_wallet(*, api_key: str) -> dict[str, Any]:
840
+ raise cli.rest_client.TinyFishRestError("Could not reach TinyFish Wallet")
841
+
842
+ monkeypatch.setattr(cli.rest_client, "wallet", fail_wallet)
867
843
 
868
844
  result = cli.cmd_usage(argparse.Namespace(json=True))
869
845
  payload = json.loads(capsys.readouterr().out)
870
846
 
871
847
  assert result == 1
872
- assert payload["success"] is False
873
- assert payload["surfaces"]["search"]["success"] is True
874
- assert payload["surfaces"]["fetch"] == {
848
+ assert payload == {
875
849
  "success": False,
876
- "error": "usage unavailable",
850
+ "wallet_available": None,
851
+ "error": "Could not reach TinyFish Wallet",
877
852
  }
878
853
 
879
854
 
880
- def test_usage_without_api_key_fails_before_calling_usage_endpoints(
855
+ def test_usage_without_api_key_fails_before_calling_wallet(
881
856
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
882
857
  ) -> None:
883
858
  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
- )
889
- monkeypatch.setattr(
890
- cli.rest_client,
891
- "fetch_usage",
892
- lambda **kwargs: pytest.fail("Fetch usage must not be called"),
893
- )
859
+ monkeypatch.setattr(cli.rest_client, "wallet", lambda **kwargs: pytest.fail("Wallet must not be called"))
894
860
 
895
861
  result = cli.cmd_usage(argparse.Namespace(json=True))
896
862
  captured = capsys.readouterr()