@tiny-fish/hermes 0.1.1

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.
@@ -0,0 +1,128 @@
1
+ """Credit policy gating for TinyFish features that consume credits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ from .config import (
9
+ CreditFeature,
10
+ CreditPolicy,
11
+ browser_cloud_provider,
12
+ credit_policy,
13
+ load_config,
14
+ normalize_feature,
15
+ )
16
+
17
+ FEATURE_LABELS: dict[CreditFeature, str] = {
18
+ "browser": "TinyFish Browser",
19
+ }
20
+
21
+
22
+ def target_domain(target: str | None) -> str:
23
+ if not target:
24
+ return ""
25
+ parsed = urlparse(target)
26
+ if parsed.netloc:
27
+ return parsed.netloc
28
+ return target[:120]
29
+
30
+
31
+ def policy_message(
32
+ feature: CreditFeature | str, policy: CreditPolicy | None = None
33
+ ) -> str:
34
+ normalized = normalize_feature(str(feature))
35
+ resolved = policy or credit_policy(normalized)
36
+ return (
37
+ f"{FEATURE_LABELS[normalized]} sessions consume TinyFish credits. "
38
+ f"Current policy is '{resolved}'."
39
+ )
40
+
41
+
42
+ def block_message(feature: CreditFeature | str) -> str:
43
+ normalized = normalize_feature(str(feature))
44
+ return (
45
+ f"BLOCKED: {policy_message(normalized, 'deny')} "
46
+ f"Set `tinyfish.credit_policy.{normalized}` to `request` (per-session "
47
+ "approval) or `allow` in Hermes config; `hermes tinyfish browser` "
48
+ "manages this setting."
49
+ )
50
+
51
+
52
+ def approval_reason(
53
+ feature: CreditFeature | str, operation: str, target: str | None = None
54
+ ) -> str:
55
+ normalized = normalize_feature(str(feature))
56
+ domain = target_domain(target)
57
+ target_text = f" Target: {domain}." if domain else ""
58
+ return (
59
+ f"{FEATURE_LABELS[normalized]} operation '{operation}' consumes "
60
+ f"TinyFish credits.{target_text}"
61
+ )
62
+
63
+
64
+ def request_credit_approval(
65
+ feature: CreditFeature | str, operation: str, target: str | None = None
66
+ ) -> tuple[bool, str]:
67
+ normalized = normalize_feature(str(feature))
68
+ policy = credit_policy(normalized)
69
+ if policy == "deny":
70
+ return False, block_message(normalized)
71
+ if policy == "allow":
72
+ return True, ""
73
+
74
+ reason = approval_reason(normalized, operation, target)
75
+ try:
76
+ from tools.approval import request_tool_approval
77
+
78
+ # Domain-grained key: one [a]lways covers the browse; new domains still prompt.
79
+ result = request_tool_approval(
80
+ f"tinyfish_{normalized}",
81
+ reason,
82
+ rule_key=f"tinyfish:{normalized}:{target_domain(target)}",
83
+ )
84
+ except Exception as exc:
85
+ return False, (
86
+ f"BLOCKED: {FEATURE_LABELS[normalized]} requires approval, but Hermes' "
87
+ f"approval gate is unavailable ({exc})."
88
+ )
89
+ if result.get("approved"):
90
+ return True, ""
91
+ return False, str(
92
+ result.get("message")
93
+ or f"BLOCKED: approval required for {FEATURE_LABELS[normalized]}"
94
+ )
95
+
96
+
97
+ def _directive_for_feature(
98
+ feature: CreditFeature,
99
+ operation: str,
100
+ target: str | None,
101
+ config: dict[str, Any] | None = None,
102
+ ) -> dict[str, str] | None:
103
+ policy = credit_policy(feature, config)
104
+ if policy == "deny":
105
+ return {"action": "block", "message": block_message(feature)}
106
+ if policy == "request":
107
+ return {
108
+ "action": "approve",
109
+ "message": approval_reason(feature, operation, target),
110
+ "rule_key": f"tinyfish:{feature}:{target_domain(target)}",
111
+ }
112
+ return None
113
+
114
+
115
+ def pre_tool_call_policy(
116
+ tool_name: str, args: dict[str, Any] | None = None, **_: Any
117
+ ) -> dict[str, str] | None:
118
+ """Hermes plugin hook for policy-gating TinyFish credit-consuming tools."""
119
+
120
+ params = args or {}
121
+
122
+ if not tool_name.startswith("browser_"):
123
+ return None
124
+ config = load_config()
125
+ if browser_cloud_provider(config) != "tinyfish":
126
+ return None
127
+ target = str(params.get("url") or params.get("target") or "")
128
+ return _directive_for_feature("browser", tool_name, target, config)
@@ -0,0 +1,110 @@
1
+ """Normalize TinyFish REST payloads into Hermes web-provider shapes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def _document_text(value: Any) -> str:
10
+ # With format=json the Fetch API returns a document tree, not a string.
11
+ if isinstance(value, str):
12
+ return value
13
+ if value is None:
14
+ return ""
15
+ try:
16
+ return json.dumps(value, separators=(",", ":"))
17
+ except (TypeError, ValueError):
18
+ return str(value)
19
+
20
+
21
+ def normalize_search_response(payload: Any, limit: int = 5) -> dict[str, Any]:
22
+ """Return Hermes' standard web-search response envelope."""
23
+
24
+ results = payload.get("results") if isinstance(payload, dict) else None
25
+ if not isinstance(results, list):
26
+ results = []
27
+
28
+ count = max(1, int(limit or 5))
29
+ web_results: list[dict[str, Any]] = []
30
+ for idx, item in enumerate(results[:count]):
31
+ if not isinstance(item, dict):
32
+ continue
33
+ url = str(item.get("url") or "")
34
+ web_results.append(
35
+ {
36
+ "title": str(item.get("title") or item.get("site_name") or url),
37
+ "url": url,
38
+ "description": str(item.get("snippet") or ""),
39
+ "position": int(item.get("position") or idx + 1),
40
+ }
41
+ )
42
+ return {"success": True, "data": {"web": web_results}}
43
+
44
+
45
+ def normalize_fetch_documents(
46
+ payload: Any, fallback_urls: list[str] | None = None
47
+ ) -> list[dict[str, Any]]:
48
+ """Return Hermes' standard extract document list."""
49
+
50
+ urls = list(fallback_urls or [])
51
+ results = payload.get("results") if isinstance(payload, dict) else None
52
+ errors = payload.get("errors") if isinstance(payload, dict) else None
53
+ if not isinstance(results, list):
54
+ results = []
55
+ if not isinstance(errors, list):
56
+ errors = []
57
+
58
+ documents: list[dict[str, Any]] = []
59
+ for idx, item in enumerate(results):
60
+ if not isinstance(item, dict):
61
+ continue
62
+ url = str(
63
+ item.get("url")
64
+ or item.get("final_url")
65
+ or (urls[idx] if idx < len(urls) else "")
66
+ )
67
+ raw = _document_text(item.get("text"))
68
+ documents.append(
69
+ {
70
+ "url": url,
71
+ "title": str(item.get("title") or ""),
72
+ "content": raw,
73
+ "raw_content": raw,
74
+ "metadata": {
75
+ "sourceURL": url,
76
+ "finalURL": str(item.get("final_url") or url),
77
+ "description": str(item.get("description") or ""),
78
+ "language": str(item.get("language") or ""),
79
+ },
80
+ }
81
+ )
82
+
83
+ for idx, item in enumerate(errors):
84
+ if not isinstance(item, dict):
85
+ continue
86
+ url = str(item.get("url") or (urls[idx] if idx < len(urls) else ""))
87
+ documents.append(
88
+ {
89
+ "url": url,
90
+ "title": "",
91
+ "content": "",
92
+ "raw_content": "",
93
+ "error": str(item.get("error") or "fetch failed"),
94
+ "metadata": {"sourceURL": url},
95
+ }
96
+ )
97
+
98
+ if not documents and urls:
99
+ return [
100
+ {
101
+ "url": url,
102
+ "title": "",
103
+ "content": "",
104
+ "raw_content": "",
105
+ "error": "TinyFish returned no content",
106
+ "metadata": {"sourceURL": url},
107
+ }
108
+ for url in urls
109
+ ]
110
+ return documents
@@ -0,0 +1,163 @@
1
+ """Hermes WebSearchProvider implementation for TinyFish, REST-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ try:
10
+ from agent.web_search_provider import WebSearchProvider as _HermesWebSearchProvider
11
+ except Exception: # pragma: no cover - lets the package import outside Hermes
12
+
13
+ class _HermesWebSearchProvider: # type: ignore[no-redef]
14
+ pass
15
+
16
+
17
+ from . import rest_client
18
+ from .config import default_fetch_format, fetch_options, search_options
19
+ from .normalize import normalize_fetch_documents, normalize_search_response
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ API_KEY_ENV_VARS = ("TINYFISH_API_KEY", "MCP_TINYFISH_API_KEY")
24
+ API_KEY_URL = "https://agent.tinyfish.ai/api-keys"
25
+ MISSING_KEY_ERROR = (
26
+ "TinyFish API key not found. Set TINYFISH_API_KEY or MCP_TINYFISH_API_KEY; "
27
+ f"create a key at {API_KEY_URL}."
28
+ )
29
+
30
+
31
+ def _provider_env(name: str) -> str:
32
+ """Read Hermes config-aware environment values when available."""
33
+
34
+ try:
35
+ from agent.web_search_provider import get_provider_env
36
+
37
+ return str(get_provider_env(name) or "").strip()
38
+ except Exception:
39
+ return str(os.getenv(name, "") or "").strip()
40
+
41
+
42
+ def _api_key() -> str:
43
+ for name in API_KEY_ENV_VARS:
44
+ value = _provider_env(name)
45
+ if value:
46
+ return value
47
+ return ""
48
+
49
+
50
+ def _is_interrupted() -> bool:
51
+ try:
52
+ from tools.interrupt import is_interrupted
53
+
54
+ return bool(is_interrupted())
55
+ except Exception:
56
+ return False
57
+
58
+
59
+ def _safe_rest_failure(operation: str, exc: Exception) -> str:
60
+ """Keep controlled REST diagnostics while suppressing arbitrary exceptions."""
61
+
62
+ if isinstance(exc, rest_client.TinyFishRestError):
63
+ return f"TinyFish REST {operation} failed: {exc}"
64
+ return f"TinyFish REST {operation} failed ({type(exc).__name__})."
65
+
66
+
67
+ def _error_document(url: str, error: str) -> dict[str, Any]:
68
+ return {
69
+ "url": url,
70
+ "title": "",
71
+ "content": "",
72
+ "raw_content": "",
73
+ "error": error,
74
+ "metadata": {"sourceURL": url},
75
+ }
76
+
77
+
78
+ class TinyFishWebSearchProvider(_HermesWebSearchProvider): # type: ignore[misc]
79
+ """TinyFish Search and Fetch provider using API-key REST calls."""
80
+
81
+ @property
82
+ def name(self) -> str:
83
+ return "tinyfish"
84
+
85
+ @property
86
+ def display_name(self) -> str:
87
+ return "TinyFish"
88
+
89
+ def is_available(self) -> bool:
90
+ return bool(_api_key())
91
+
92
+ def supports_search(self) -> bool:
93
+ return True
94
+
95
+ def supports_extract(self) -> bool:
96
+ return True
97
+
98
+ def supports_crawl(self) -> bool:
99
+ return False
100
+
101
+ def get_setup_schema(self) -> dict[str, Any]:
102
+ return {
103
+ "name": "TinyFish",
104
+ "badge": "free search/fetch",
105
+ "tag": "TinyFish Search and Fetch via API key.",
106
+ "env_vars": [
107
+ {
108
+ "key": "TINYFISH_API_KEY",
109
+ "prompt": "TinyFish API key",
110
+ "url": API_KEY_URL,
111
+ }
112
+ ],
113
+ }
114
+
115
+ def search(self, query: str, limit: int = 5) -> dict[str, Any]:
116
+ if _is_interrupted():
117
+ return {"success": False, "error": "Interrupted"}
118
+ api_key = _api_key()
119
+ if not api_key:
120
+ return {"success": False, "error": MISSING_KEY_ERROR}
121
+ try:
122
+ raw = rest_client.search(query, api_key=api_key, **search_options())
123
+ return normalize_search_response(raw, limit=limit)
124
+ except Exception as exc: # noqa: BLE001 - controlled failure envelope
125
+ logger.warning("TinyFish REST search failed (%s)", type(exc).__name__)
126
+ return {"success": False, "error": _safe_rest_failure("search", exc)}
127
+
128
+ def extract(self, urls: list[str], **kwargs: Any) -> list[dict[str, Any]]:
129
+ if _is_interrupted():
130
+ return [_error_document(url, "Interrupted") for url in urls]
131
+ api_key = _api_key()
132
+ if not api_key:
133
+ return [_error_document(url, MISSING_KEY_ERROR) for url in urls]
134
+ output_format = str(
135
+ kwargs.get("format")
136
+ or kwargs.get("output_format")
137
+ or default_fetch_format()
138
+ )
139
+ options = fetch_options()
140
+ documents: list[dict[str, Any]] = []
141
+ # The Fetch API caps a request at 10 URLs; oversized batches 400.
142
+ for start in range(0, len(urls), rest_client.FETCH_MAX_URLS):
143
+ if start and _is_interrupted():
144
+ documents.extend(
145
+ _error_document(url, "Interrupted") for url in urls[start:]
146
+ )
147
+ break
148
+ chunk = urls[start : start + rest_client.FETCH_MAX_URLS]
149
+ try:
150
+ raw = rest_client.fetch(
151
+ chunk,
152
+ api_key=api_key,
153
+ output_format=output_format,
154
+ **options,
155
+ )
156
+ documents.extend(normalize_fetch_documents(raw, fallback_urls=chunk))
157
+ except Exception as exc: # noqa: BLE001 - controlled failure envelope
158
+ logger.warning("TinyFish REST fetch failed (%s)", type(exc).__name__)
159
+ documents.extend(
160
+ _error_document(url, _safe_rest_failure("fetch", exc))
161
+ for url in chunk
162
+ )
163
+ return documents
@@ -0,0 +1,276 @@
1
+ """TinyFish Search and Fetch REST client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from typing import Any, NoReturn, cast
8
+
9
+ import httpx
10
+
11
+ from . import __version__
12
+
13
+ SEARCH_URL = "https://api.search.tinyfish.ai"
14
+ FETCH_URL = "https://api.fetch.tinyfish.ai"
15
+ FETCH_MAX_URLS = 10
16
+ BROWSER_URL = "https://api.browser.tinyfish.ai"
17
+ WALLET_URL = "https://agent.tinyfish.ai/v1/wallet"
18
+ SEARCH_USAGE_URL = f"{SEARCH_URL}/usage"
19
+ FETCH_USAGE_URL = f"{FETCH_URL}/usage"
20
+
21
+ _BROWSER_CLOSE_MAX_ATTEMPTS = 3
22
+ _BROWSER_CLOSE_RETRYABLE_STATUSES = frozenset({409, 429, 500, 503, 504})
23
+ _BROWSER_CLOSE_RETRY_AFTER_CAP_SECONDS = 5.0
24
+ _BROWSER_CLOSE_RETRY_BASE_SECONDS = 0.25
25
+
26
+
27
+ class TinyFishRestError(RuntimeError):
28
+ """Raised for TinyFish REST transport or HTTP failures."""
29
+
30
+
31
+ class TinyFishWalletNotFound(TinyFishRestError):
32
+ """Raised when an account uses legacy billing or has no wallet yet."""
33
+
34
+
35
+ def _headers(api_key: str) -> dict[str, str]:
36
+ # Without these, telemetry cannot tell the plugin from raw curl.
37
+ return {
38
+ "X-API-Key": api_key,
39
+ "Accept": "application/json",
40
+ "X-TF-Client-Name": "hermes",
41
+ "X-TF-Client-Version": __version__,
42
+ }
43
+
44
+
45
+ def _json_headers(api_key: str) -> dict[str, str]:
46
+ headers = _headers(api_key)
47
+ headers["Content-Type"] = "application/json"
48
+ return headers
49
+
50
+
51
+ def _raise_http_error(service: str, exc: httpx.HTTPStatusError) -> NoReturn:
52
+ status = exc.response.status_code
53
+ if status == 402:
54
+ raise TinyFishRestError(
55
+ f"{service} returned HTTP 402. TinyFish credits or billing may be required."
56
+ ) from exc
57
+ raise TinyFishRestError(f"{service} returned HTTP {status}") from exc
58
+
59
+
60
+ def search(
61
+ query: str,
62
+ *,
63
+ api_key: str,
64
+ timeout: float = 30.0,
65
+ location: str | None = None,
66
+ language: str | None = None,
67
+ recency_minutes: int | None = None,
68
+ after_date: str | None = None,
69
+ before_date: str | None = None,
70
+ domain_type: str | None = None,
71
+ page: int | None = None,
72
+ purpose: str | None = None,
73
+ ) -> dict[str, Any]:
74
+ """Run a TinyFish Search API query."""
75
+
76
+ params: dict[str, Any] = {"query": query}
77
+ for key, value in {
78
+ "location": location,
79
+ "language": language,
80
+ "recency_minutes": recency_minutes,
81
+ "after_date": after_date,
82
+ "before_date": before_date,
83
+ "domain_type": domain_type,
84
+ "page": page,
85
+ "purpose": purpose,
86
+ }.items():
87
+ if value is not None and value != "":
88
+ params[key] = value
89
+
90
+ try:
91
+ response = httpx.get(
92
+ SEARCH_URL,
93
+ params=params,
94
+ headers=_headers(api_key),
95
+ timeout=timeout,
96
+ )
97
+ response.raise_for_status()
98
+ return cast(dict[str, Any], response.json())
99
+ except httpx.HTTPStatusError as exc:
100
+ _raise_http_error("TinyFish Search", exc)
101
+ except httpx.RequestError as exc:
102
+ raise TinyFishRestError(f"Could not reach TinyFish Search: {exc}") from exc
103
+ except ValueError as exc:
104
+ raise TinyFishRestError("TinyFish Search returned invalid JSON") from exc
105
+
106
+
107
+ def fetch(
108
+ urls: list[str],
109
+ *,
110
+ api_key: str,
111
+ output_format: str = "markdown",
112
+ links: bool | None = None,
113
+ image_links: bool | None = None,
114
+ ttl: int | None = None,
115
+ per_url_timeout_ms: int | None = None,
116
+ # Batches can run ~120s server-side; docs recommend a client timeout >= 150s.
117
+ timeout: float = 150.0,
118
+ ) -> dict[str, Any]:
119
+ """Run the TinyFish Fetch API for one or more URLs."""
120
+
121
+ body: dict[str, Any] = {"urls": urls, "format": output_format}
122
+ for key, value in {
123
+ "links": links,
124
+ "image_links": image_links,
125
+ "ttl": ttl,
126
+ "per_url_timeout_ms": per_url_timeout_ms,
127
+ }.items():
128
+ if value is not None:
129
+ body[key] = value
130
+
131
+ try:
132
+ response = httpx.post(
133
+ FETCH_URL,
134
+ json=body,
135
+ headers=_json_headers(api_key),
136
+ timeout=timeout,
137
+ )
138
+ response.raise_for_status()
139
+ return cast(dict[str, Any], response.json())
140
+ except httpx.HTTPStatusError as exc:
141
+ _raise_http_error("TinyFish Fetch", exc)
142
+ except httpx.RequestError as exc:
143
+ raise TinyFishRestError(f"Could not reach TinyFish Fetch: {exc}") from exc
144
+ except ValueError as exc:
145
+ raise TinyFishRestError("TinyFish Fetch returned invalid JSON") from exc
146
+
147
+
148
+ def create_browser_session(
149
+ *,
150
+ api_key: str,
151
+ url: str | None = None,
152
+ timeout_seconds: int | None = None,
153
+ timeout: float = 90.0,
154
+ ) -> dict[str, Any]:
155
+ """Create a TinyFish Browser session."""
156
+
157
+ body: dict[str, Any] = {}
158
+ if url:
159
+ body["url"] = url
160
+ if timeout_seconds is not None:
161
+ body["timeout_seconds"] = timeout_seconds
162
+ try:
163
+ response = httpx.post(
164
+ BROWSER_URL,
165
+ json=body,
166
+ headers=_json_headers(api_key),
167
+ timeout=timeout,
168
+ )
169
+ response.raise_for_status()
170
+ return cast(dict[str, Any], response.json())
171
+ except httpx.HTTPStatusError as exc:
172
+ _raise_http_error("TinyFish Browser", exc)
173
+ except httpx.RequestError as exc:
174
+ raise TinyFishRestError(f"Could not reach TinyFish Browser: {exc}") from exc
175
+ except ValueError as exc:
176
+ raise TinyFishRestError("TinyFish Browser returned invalid JSON") from exc
177
+
178
+
179
+ def _browser_close_retry_delay(response: httpx.Response | None, attempt: int) -> float:
180
+ if response is not None:
181
+ retry_after = str(response.headers.get("Retry-After", "") or "").strip()
182
+ if retry_after:
183
+ try:
184
+ seconds = float(retry_after)
185
+ except ValueError:
186
+ seconds = -1.0
187
+ if math.isfinite(seconds) and seconds >= 0:
188
+ return min(seconds, _BROWSER_CLOSE_RETRY_AFTER_CAP_SECONDS)
189
+ return _BROWSER_CLOSE_RETRY_BASE_SECONDS * (2.0**attempt)
190
+
191
+
192
+ def close_browser_session(
193
+ session_id: str, *, api_key: str, timeout: float = 15.0
194
+ ) -> bool:
195
+ """Terminate a Browser session with bounded transient-failure retries."""
196
+
197
+ # A 404 is not success: it can also mean the session belongs to another key.
198
+ url = f"{BROWSER_URL}/{session_id}"
199
+ for attempt in range(_BROWSER_CLOSE_MAX_ATTEMPTS):
200
+ response: httpx.Response | None = None
201
+ try:
202
+ response = httpx.delete(url, headers=_headers(api_key), timeout=timeout)
203
+ except httpx.RequestError:
204
+ retry = True
205
+ else:
206
+ if 200 <= response.status_code < 300:
207
+ return True
208
+ retry = response.status_code in _BROWSER_CLOSE_RETRYABLE_STATUSES
209
+
210
+ if not retry or attempt + 1 >= _BROWSER_CLOSE_MAX_ATTEMPTS:
211
+ return False
212
+ delay = _browser_close_retry_delay(response, attempt)
213
+ if delay > 0:
214
+ time.sleep(delay)
215
+ return False
216
+
217
+
218
+ def _read_usage(
219
+ url: str,
220
+ *,
221
+ service: str,
222
+ api_key: str,
223
+ timeout: float,
224
+ ) -> dict[str, Any]:
225
+ try:
226
+ response = httpx.get(url, headers=_headers(api_key), timeout=timeout)
227
+ response.raise_for_status()
228
+ return cast(dict[str, Any], response.json())
229
+ except httpx.HTTPStatusError as exc:
230
+ _raise_http_error(service, exc)
231
+ except httpx.RequestError as exc:
232
+ raise TinyFishRestError(f"Could not reach {service}: {exc}") from exc
233
+ except ValueError as exc:
234
+ raise TinyFishRestError(f"{service} returned invalid JSON") from exc
235
+
236
+
237
+ def search_usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
238
+ """Return TinyFish Search operation history."""
239
+
240
+ return _read_usage(
241
+ SEARCH_USAGE_URL,
242
+ service="TinyFish Search usage",
243
+ api_key=api_key,
244
+ timeout=timeout,
245
+ )
246
+
247
+
248
+ def fetch_usage(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
249
+ """Return TinyFish Fetch operation history."""
250
+
251
+ return _read_usage(
252
+ FETCH_USAGE_URL,
253
+ service="TinyFish Fetch usage",
254
+ api_key=api_key,
255
+ timeout=timeout,
256
+ )
257
+
258
+
259
+ def wallet(*, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
260
+ """Return the caller's TinyFish wallet balance and billing rates."""
261
+
262
+ try:
263
+ response = httpx.get(WALLET_URL, headers=_headers(api_key), timeout=timeout)
264
+ response.raise_for_status()
265
+ return cast(dict[str, Any], response.json())
266
+ except httpx.HTTPStatusError as exc:
267
+ if exc.response.status_code == 404:
268
+ raise TinyFishWalletNotFound(
269
+ "TinyFish has no wallet for this account "
270
+ "(legacy billing or no Metronome customer yet)."
271
+ ) from exc
272
+ _raise_http_error("TinyFish Wallet", exc)
273
+ except httpx.RequestError as exc:
274
+ raise TinyFishRestError(f"Could not reach TinyFish Wallet: {exc}") from exc
275
+ except ValueError as exc:
276
+ raise TinyFishRestError("TinyFish Wallet returned invalid JSON") from exc