hermes-plugin-tinyfish 0.3.2__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.2 → hermes_plugin_tinyfish-0.3.4}/CHANGELOG.md +18 -0
  2. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/PKG-INFO +7 -4
  3. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/README.md +6 -3
  4. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/rest_client.py +26 -2
  5. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/setup_cli.py +96 -28
  6. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/plugin.yaml +1 -1
  7. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/pyproject.toml +1 -1
  8. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_rest_client.py +47 -5
  9. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_setup_cli.py +106 -43
  10. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  11. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
  12. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/generate_report.py +0 -0
  13. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/improve_description.py +0 -0
  14. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/package_skill.py +0 -0
  15. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/quick_validate.py +0 -0
  16. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/run_eval.py +0 -0
  17. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/run_loop.py +0 -0
  18. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.agents/skills/skill-creator/scripts/utils.py +0 -0
  19. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/.gitignore +0 -0
  20. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/CODE_OF_CONDUCT.md +0 -0
  21. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/CONTRIBUTING.md +0 -0
  22. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/LICENSE +0 -0
  23. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/SECURITY.md +0 -0
  24. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/__init__.py +0 -0
  25. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/__init__.py +0 -0
  26. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/browser_provider.py +0 -0
  27. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/config.py +0 -0
  28. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/credit_policy.py +0 -0
  29. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/health.py +0 -0
  30. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/normalize.py +0 -0
  31. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/provider.py +0 -0
  32. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/routing_context.py +0 -0
  33. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/hermes_plugin_tinyfish/update_check.py +0 -0
  34. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/scripts/prepare_release.py +0 -0
  35. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/scripts/validate_release.py +0 -0
  36. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/conftest.py +0 -0
  37. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_browser_provider.py +0 -0
  38. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_credit_policy.py +0 -0
  39. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_health.py +0 -0
  40. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_live.py +0 -0
  41. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_normalize.py +0 -0
  42. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_prepare_release.py +0 -0
  43. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_provider.py +0 -0
  44. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_register.py +0 -0
  45. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_routing_context.py +0 -0
  46. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_update_check.py +0 -0
  47. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_validate_release.py +0 -0
  48. {hermes_plugin_tinyfish-0.3.2 → hermes_plugin_tinyfish-0.3.4}/tests/test_version.py +0 -0
@@ -7,6 +7,24 @@ 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
+
20
+ ## [0.3.3] - 2026-08-12
21
+
22
+ ### Fixed
23
+
24
+ - Replaced the unreadable raw dictionary output from `hermes tinyfish usage`
25
+ with labeled Search and Fetch histories, pagination summaries, and clear
26
+ partial-failure reporting while preserving `--json` for automation.
27
+
10
28
  ## [0.3.2] - 2026-08-11
11
29
 
12
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hermes-plugin-tinyfish
3
- Version: 0.3.2
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,9 +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
- reports per-surface success or failure; it is not Agent or Browser billing
305
- 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.
306
309
  - `doctor --live-paid` only creates and closes a TinyFish Browser session. It
307
310
  refuses under `deny`, requests approval under `request`, and never prints
308
311
  connection URLs or credentials.
@@ -268,9 +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
- reports per-surface success or failure; it is not Agent or Browser billing
273
- 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.
274
277
  - `doctor --live-paid` only creates and closes a TinyFish Browser session. It
275
278
  refuses under `deny`, requests approval under `request`, and never prints
276
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
 
@@ -603,14 +603,6 @@ def tinyfish_status_command(
603
603
  return "\n".join(_status_lines(status))
604
604
 
605
605
 
606
- def _print_json_or_text(payload: dict[str, Any], *, as_json: bool) -> None:
607
- if as_json:
608
- print(json.dumps(payload, indent=2, sort_keys=True))
609
- else:
610
- for key, value in payload.items():
611
- print(f"{key}: {value}")
612
-
613
-
614
606
  def _api_key_or_error() -> str | None:
615
607
  api_key = _get_env("TINYFISH_API_KEY")
616
608
  if not api_key:
@@ -710,28 +702,104 @@ def cmd_credits(args: argparse.Namespace) -> int:
710
702
  return 2
711
703
 
712
704
 
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)}
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()
713
+
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
+ ]
722
+
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):
752
+ continue
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")
763
+ return lines
722
764
 
723
765
 
724
766
  def cmd_usage(args: argparse.Namespace) -> int:
725
767
  api_key = _api_key_or_error()
726
768
  if not api_key:
727
769
  return 1
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
- }
736
- _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
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)
801
+ if bool(getattr(args, "json", False)):
802
+ print(json.dumps(payload, indent=2, sort_keys=True))
803
+ else:
804
+ print("\n".join(text_lines))
737
805
  return 0 if payload["success"] else 1
@@ -1,5 +1,5 @@
1
1
  name: web-tinyfish
2
- version: 0.3.2
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.2"
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,85 +715,148 @@ 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_partial_failure_returns_both_surface_results_and_failure(
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: {"items": [{"status": "completed"}]},
749
+ "wallet",
750
+ lambda *, api_key: {
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
+ },
767
+ },
764
768
  )
765
769
 
766
- def fail_usage(*, api_key: str) -> dict[str, Any]:
767
- raise RuntimeError("usage unavailable")
770
+ result = cli.cmd_usage(argparse.Namespace(json=False))
771
+ output = capsys.readouterr().out
772
+
773
+ assert result == 0
774
+ assert (
775
+ output
776
+ == """TinyFish usage
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
784
+ """
785
+ )
786
+ assert "tf_secret" not in output
787
+ assert "query" not in output.lower()
788
+ assert "url" not in output.lower()
789
+
790
+
791
+ def test_usage_wallet_not_found_explains_legacy_billing(
792
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
793
+ ) -> None:
794
+ monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
795
+
796
+ def wallet_not_found(*, api_key: str) -> dict[str, Any]:
797
+ raise cli.rest_client.TinyFishWalletNotFound("wallet not found")
798
+
799
+ monkeypatch.setattr(cli.rest_client, "wallet", wallet_not_found)
800
+
801
+ result = cli.cmd_usage(argparse.Namespace(json=False))
802
+ output = capsys.readouterr().out
803
+
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
809
+
810
+
811
+ def test_usage_wallet_not_found_is_machine_readable(
812
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
813
+ ) -> None:
814
+ monkeypatch.setattr(cli, "_get_env", lambda name: "tf_secret")
815
+
816
+ def wallet_not_found(*, api_key: str) -> dict[str, Any]:
817
+ raise cli.rest_client.TinyFishWalletNotFound("wallet not found")
768
818
 
769
- monkeypatch.setattr(cli.rest_client, "fetch_usage", fail_usage)
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
+
833
+
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)
770
843
 
771
844
  result = cli.cmd_usage(argparse.Namespace(json=True))
772
845
  payload = json.loads(capsys.readouterr().out)
773
846
 
774
847
  assert result == 1
775
- assert payload["success"] is False
776
- assert payload["surfaces"]["search"]["success"] is True
777
- assert payload["surfaces"]["fetch"] == {
848
+ assert payload == {
778
849
  "success": False,
779
- "error": "usage unavailable",
850
+ "wallet_available": None,
851
+ "error": "Could not reach TinyFish Wallet",
780
852
  }
781
853
 
782
854
 
783
- def test_usage_without_api_key_fails_before_calling_usage_endpoints(
855
+ def test_usage_without_api_key_fails_before_calling_wallet(
784
856
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
785
857
  ) -> None:
786
858
  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
- )
792
- monkeypatch.setattr(
793
- cli.rest_client,
794
- "fetch_usage",
795
- lambda **kwargs: pytest.fail("Fetch usage must not be called"),
796
- )
859
+ monkeypatch.setattr(cli.rest_client, "wallet", lambda **kwargs: pytest.fail("Wallet must not be called"))
797
860
 
798
861
  result = cli.cmd_usage(argparse.Namespace(json=True))
799
862
  captured = capsys.readouterr()