@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.
- package/LICENSE +38 -0
- package/README.md +102 -0
- package/__init__.py +10 -0
- package/package.json +33 -0
- package/plugin.yaml +13 -0
- package/pyproject.toml +66 -0
- package/tinyfish_hermes/__init__.py +113 -0
- package/tinyfish_hermes/browser_provider.py +133 -0
- package/tinyfish_hermes/config.py +164 -0
- package/tinyfish_hermes/credit_policy.py +128 -0
- package/tinyfish_hermes/normalize.py +110 -0
- package/tinyfish_hermes/provider.py +163 -0
- package/tinyfish_hermes/rest_client.py +276 -0
- package/tinyfish_hermes/routing_context.py +70 -0
- package/tinyfish_hermes/setup_cli.py +695 -0
|
@@ -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
|