@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,695 @@
|
|
|
1
|
+
"""CLI helpers for ``hermes tinyfish``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import rest_client
|
|
12
|
+
from .config import (
|
|
13
|
+
CREDIT_FEATURES,
|
|
14
|
+
CREDIT_POLICIES,
|
|
15
|
+
DEFAULT_CREDIT_POLICY,
|
|
16
|
+
browser_cloud_provider,
|
|
17
|
+
credit_policy_summary,
|
|
18
|
+
normalize_feature,
|
|
19
|
+
normalize_policy,
|
|
20
|
+
routing_context_enabled,
|
|
21
|
+
set_credit_policy,
|
|
22
|
+
)
|
|
23
|
+
from .credit_policy import request_credit_approval
|
|
24
|
+
from .provider import (
|
|
25
|
+
API_KEY_ENV_VARS,
|
|
26
|
+
API_KEY_URL,
|
|
27
|
+
MISSING_KEY_ERROR,
|
|
28
|
+
TinyFishWebSearchProvider,
|
|
29
|
+
_api_key,
|
|
30
|
+
)
|
|
31
|
+
from .routing_context import tinyfish_mcp_configured
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
|
|
35
|
+
parser.description = "Configure and diagnose the TinyFish provider plugin."
|
|
36
|
+
sub = parser.add_subparsers(dest="tinyfish_command")
|
|
37
|
+
|
|
38
|
+
setup = sub.add_parser("setup", help="Route Hermes web search/extract to TinyFish")
|
|
39
|
+
setup.add_argument(
|
|
40
|
+
"--yes", "-y", action="store_true", help="Accept config changes without prompts"
|
|
41
|
+
)
|
|
42
|
+
setup.add_argument("--api-key", help="Save TINYFISH_API_KEY in Hermes .env")
|
|
43
|
+
setup.add_argument(
|
|
44
|
+
"--no-web-backend",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="Do not set web.search_backend/extract_backend",
|
|
47
|
+
)
|
|
48
|
+
setup.add_argument(
|
|
49
|
+
"--live", action="store_true", help="Run live doctor checks after setup"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
status = sub.add_parser("status", help="Print non-secret TinyFish status")
|
|
53
|
+
status.add_argument(
|
|
54
|
+
"--json", action="store_true", help="Print machine-readable JSON"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
doctor = sub.add_parser("doctor", help="Check TinyFish plugin configuration")
|
|
58
|
+
doctor.add_argument(
|
|
59
|
+
"--json", action="store_true", help="Print machine-readable JSON"
|
|
60
|
+
)
|
|
61
|
+
doctor.add_argument(
|
|
62
|
+
"--live", action="store_true", help="Run a live search/fetch check"
|
|
63
|
+
)
|
|
64
|
+
doctor.add_argument(
|
|
65
|
+
"--live-paid",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Create and close a TinyFish Browser session per its credit policy",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
credits = sub.add_parser(
|
|
71
|
+
"credits", help="Inspect or update TinyFish credit policies"
|
|
72
|
+
)
|
|
73
|
+
credits_sub = credits.add_subparsers(dest="credits_command")
|
|
74
|
+
credits_status = credits_sub.add_parser(
|
|
75
|
+
"status", help="Show TinyFish credit policies"
|
|
76
|
+
)
|
|
77
|
+
credits_status.add_argument(
|
|
78
|
+
"--json", action="store_true", help="Print machine-readable JSON"
|
|
79
|
+
)
|
|
80
|
+
credits_set = credits_sub.add_parser(
|
|
81
|
+
"set", help="Set a feature policy: deny, request, or allow"
|
|
82
|
+
)
|
|
83
|
+
credits_set.add_argument("feature", choices=list(CREDIT_FEATURES))
|
|
84
|
+
credits_set.add_argument("policy", choices=list(CREDIT_POLICIES))
|
|
85
|
+
credits_sub.add_parser(
|
|
86
|
+
"reset", help=f"Restore the default policy ({DEFAULT_CREDIT_POLICY})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
browser = sub.add_parser("browser", help="Route Hermes browser tools to TinyFish")
|
|
90
|
+
browser_sub = browser.add_subparsers(dest="browser_command")
|
|
91
|
+
browser_sub.add_parser("enable", help="Set browser.cloud_provider to tinyfish")
|
|
92
|
+
browser_sub.add_parser("disable", help="Unset browser.cloud_provider if tinyfish")
|
|
93
|
+
browser_sub.add_parser("status", help="Show browser provider and credit policy")
|
|
94
|
+
|
|
95
|
+
usage = sub.add_parser(
|
|
96
|
+
"usage", help="Read TinyFish wallet balance and billing rates"
|
|
97
|
+
)
|
|
98
|
+
usage.add_argument(
|
|
99
|
+
"--json", action="store_true", help="Print machine-readable JSON"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def dispatch_tinyfish_cli(
|
|
104
|
+
args: argparse.Namespace,
|
|
105
|
+
*,
|
|
106
|
+
provider: TinyFishWebSearchProvider | None = None,
|
|
107
|
+
) -> int:
|
|
108
|
+
command = getattr(args, "tinyfish_command", None) or "status"
|
|
109
|
+
if command == "setup":
|
|
110
|
+
return cmd_setup(args, provider=provider)
|
|
111
|
+
if command == "status":
|
|
112
|
+
return cmd_status(args, provider=provider)
|
|
113
|
+
if command == "doctor":
|
|
114
|
+
return cmd_doctor(args, provider=provider)
|
|
115
|
+
if command == "credits":
|
|
116
|
+
return cmd_credits(args)
|
|
117
|
+
if command == "browser":
|
|
118
|
+
return cmd_browser(args)
|
|
119
|
+
if command == "usage":
|
|
120
|
+
return cmd_usage(args)
|
|
121
|
+
print(
|
|
122
|
+
"Usage: hermes tinyfish {setup,status,doctor,credits,browser,usage}",
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
)
|
|
125
|
+
return 2
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_config() -> dict[str, Any]:
|
|
129
|
+
from hermes_cli.config import load_config
|
|
130
|
+
|
|
131
|
+
return dict(load_config() or {})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _save_config(config: dict[str, Any]) -> None:
|
|
135
|
+
from hermes_cli.config import save_config
|
|
136
|
+
|
|
137
|
+
save_config(config)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_env(name: str) -> str:
|
|
141
|
+
try:
|
|
142
|
+
from hermes_cli.config import get_env_value
|
|
143
|
+
|
|
144
|
+
return str(get_env_value(name) or "").strip()
|
|
145
|
+
except Exception:
|
|
146
|
+
return str(os.getenv(name, "") or "").strip()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _save_env_secure(name: str, value: str) -> dict[str, Any]:
|
|
150
|
+
from hermes_cli.config import save_env_value_secure
|
|
151
|
+
|
|
152
|
+
return dict(save_env_value_secure(name, value) or {})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _read_saved_env(name: str) -> str:
|
|
156
|
+
try:
|
|
157
|
+
from hermes_cli.config import get_env_value_prefer_dotenv
|
|
158
|
+
|
|
159
|
+
return str(get_env_value_prefer_dotenv(name) or "").strip()
|
|
160
|
+
except Exception:
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _api_key_env_var() -> str:
|
|
165
|
+
for name in API_KEY_ENV_VARS:
|
|
166
|
+
if _get_env(name):
|
|
167
|
+
return name
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _confirm(question: str, *, default: bool = True, assume_yes: bool = False) -> bool:
|
|
172
|
+
if assume_yes:
|
|
173
|
+
return True
|
|
174
|
+
if not sys.stdin.isatty():
|
|
175
|
+
# A piped run must not accept config changes without an explicit --yes.
|
|
176
|
+
return False
|
|
177
|
+
suffix = "Y/n" if default else "y/N"
|
|
178
|
+
try:
|
|
179
|
+
answer = input(f"{question} [{suffix}]: ").strip().lower()
|
|
180
|
+
except (EOFError, KeyboardInterrupt):
|
|
181
|
+
print()
|
|
182
|
+
return default
|
|
183
|
+
if not answer:
|
|
184
|
+
return default
|
|
185
|
+
return answer in {"y", "yes"}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _prompt_secret(question: str) -> str:
|
|
189
|
+
import getpass
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
return getpass.getpass(question).strip()
|
|
193
|
+
except (EOFError, KeyboardInterrupt):
|
|
194
|
+
print()
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_web_backend_config(config: dict[str, Any]) -> None:
|
|
199
|
+
web = config.setdefault("web", {})
|
|
200
|
+
web["search_backend"] = "tinyfish"
|
|
201
|
+
web["extract_backend"] = "tinyfish"
|
|
202
|
+
if not web.get("backend"):
|
|
203
|
+
web["backend"] = "tinyfish"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _store_api_key(api_key: str) -> bool:
|
|
207
|
+
try:
|
|
208
|
+
outcome = _save_env_secure("TINYFISH_API_KEY", api_key)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
outcome = {"success": False, "error": type(exc).__name__}
|
|
211
|
+
# The secure save reports success even on managed-install refusal; trust read-back.
|
|
212
|
+
if outcome.get("success") and _read_saved_env("TINYFISH_API_KEY") == api_key:
|
|
213
|
+
print("Saved TINYFISH_API_KEY in Hermes .env")
|
|
214
|
+
shell_value = str(os.environ.get("TINYFISH_API_KEY", "") or "").strip()
|
|
215
|
+
if shell_value and shell_value != api_key:
|
|
216
|
+
print(
|
|
217
|
+
"Warning: the TINYFISH_API_KEY exported in this shell differs "
|
|
218
|
+
"from the saved key and shadows it at runtime."
|
|
219
|
+
)
|
|
220
|
+
return True
|
|
221
|
+
print(
|
|
222
|
+
"Could not save TINYFISH_API_KEY (managed install or managed key); "
|
|
223
|
+
"set it in your environment instead.",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_setup(
|
|
230
|
+
args: argparse.Namespace,
|
|
231
|
+
*,
|
|
232
|
+
provider: TinyFishWebSearchProvider | None = None,
|
|
233
|
+
) -> int:
|
|
234
|
+
config = _load_config()
|
|
235
|
+
|
|
236
|
+
if not getattr(args, "no_web_backend", False) and _confirm(
|
|
237
|
+
"Set Hermes web.search_backend and web.extract_backend to tinyfish?",
|
|
238
|
+
default=True,
|
|
239
|
+
assume_yes=bool(getattr(args, "yes", False)),
|
|
240
|
+
):
|
|
241
|
+
_apply_web_backend_config(config)
|
|
242
|
+
_save_config(config)
|
|
243
|
+
print("Configured Hermes web backends to use TinyFish")
|
|
244
|
+
|
|
245
|
+
api_key = (getattr(args, "api_key", None) or "").strip()
|
|
246
|
+
if (
|
|
247
|
+
not api_key
|
|
248
|
+
and not _api_key_env_var()
|
|
249
|
+
and sys.stdin.isatty()
|
|
250
|
+
and _confirm("Add TINYFISH_API_KEY now?", default=True, assume_yes=False)
|
|
251
|
+
):
|
|
252
|
+
api_key = _prompt_secret(f"TinyFish API key (create at {API_KEY_URL}): ")
|
|
253
|
+
if api_key and not _store_api_key(api_key):
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
if getattr(args, "live", False):
|
|
257
|
+
doctor_args = argparse.Namespace(json=False, live=True, live_paid=False)
|
|
258
|
+
return cmd_doctor(doctor_args, provider=provider)
|
|
259
|
+
|
|
260
|
+
print("Run `hermes tinyfish doctor --live` to verify the setup.")
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def collect_status(
|
|
265
|
+
*,
|
|
266
|
+
live: bool = False,
|
|
267
|
+
provider: TinyFishWebSearchProvider | None = None,
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
from . import __version__
|
|
270
|
+
|
|
271
|
+
config = _load_config()
|
|
272
|
+
web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {}
|
|
273
|
+
provider = provider or TinyFishWebSearchProvider()
|
|
274
|
+
api_key_env = _api_key_env_var()
|
|
275
|
+
|
|
276
|
+
checks: dict[str, Any] = {
|
|
277
|
+
"plugin_version": __version__,
|
|
278
|
+
"provider_available": provider.is_available(),
|
|
279
|
+
"api_key_env_var": api_key_env or None,
|
|
280
|
+
"api_key_configured": bool(api_key_env),
|
|
281
|
+
"web_search_backend": (web_cfg or {}).get("search_backend")
|
|
282
|
+
or (web_cfg or {}).get("backend")
|
|
283
|
+
or "",
|
|
284
|
+
"web_extract_backend": (web_cfg or {}).get("extract_backend")
|
|
285
|
+
or (web_cfg or {}).get("backend")
|
|
286
|
+
or "",
|
|
287
|
+
"browser_cloud_provider": browser_cloud_provider(config),
|
|
288
|
+
"credit_policy": credit_policy_summary(config),
|
|
289
|
+
"routing_context_enabled": routing_context_enabled(config),
|
|
290
|
+
"routing_context_active": bool(
|
|
291
|
+
routing_context_enabled(config) and tinyfish_mcp_configured(config)
|
|
292
|
+
),
|
|
293
|
+
"mcp_configured": tinyfish_mcp_configured(config),
|
|
294
|
+
}
|
|
295
|
+
checks["web_backend_configured"] = (
|
|
296
|
+
checks["web_search_backend"] == "tinyfish"
|
|
297
|
+
and checks["web_extract_backend"] == "tinyfish"
|
|
298
|
+
)
|
|
299
|
+
configured_ok = bool(
|
|
300
|
+
checks["web_backend_configured"] and checks["api_key_configured"]
|
|
301
|
+
)
|
|
302
|
+
checks["ok"] = configured_ok
|
|
303
|
+
|
|
304
|
+
if live:
|
|
305
|
+
try:
|
|
306
|
+
search = provider.search("TinyFish web agent", limit=1)
|
|
307
|
+
checks["live_search_ok"] = bool(
|
|
308
|
+
search.get("success") and search.get("data", {}).get("web")
|
|
309
|
+
)
|
|
310
|
+
if not checks["live_search_ok"]:
|
|
311
|
+
checks["live_search_error"] = str(
|
|
312
|
+
search.get("error") or "TinyFish Search returned no web results."
|
|
313
|
+
)
|
|
314
|
+
except Exception as exc: # noqa: BLE001 - diagnostics must not crash
|
|
315
|
+
checks["live_search_ok"] = False
|
|
316
|
+
checks["live_search_error"] = (
|
|
317
|
+
f"TinyFish Search check failed ({type(exc).__name__})."
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
fetch = provider.extract(["https://docs.tinyfish.ai/"], format="markdown")
|
|
322
|
+
content = ""
|
|
323
|
+
if fetch:
|
|
324
|
+
content = str(
|
|
325
|
+
fetch[0].get("content") or fetch[0].get("raw_content") or ""
|
|
326
|
+
).strip()
|
|
327
|
+
checks["live_fetch_ok"] = bool(
|
|
328
|
+
fetch and not fetch[0].get("error") and content
|
|
329
|
+
)
|
|
330
|
+
if not checks["live_fetch_ok"]:
|
|
331
|
+
error = fetch[0].get("error") if fetch else None
|
|
332
|
+
checks["live_fetch_error"] = str(
|
|
333
|
+
error or "TinyFish Fetch returned no extracted content."
|
|
334
|
+
)
|
|
335
|
+
except Exception as exc: # noqa: BLE001 - diagnostics must not crash
|
|
336
|
+
checks["live_fetch_ok"] = False
|
|
337
|
+
checks["live_fetch_error"] = (
|
|
338
|
+
f"TinyFish Fetch check failed ({type(exc).__name__})."
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
checks["ok"] = bool(
|
|
342
|
+
configured_ok and checks["live_search_ok"] and checks["live_fetch_ok"]
|
|
343
|
+
)
|
|
344
|
+
return checks
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _status_lines(status: dict[str, Any]) -> list[str]:
|
|
348
|
+
lines = ["TinyFish Hermes plugin status"]
|
|
349
|
+
for key in sorted(status):
|
|
350
|
+
value = status[key]
|
|
351
|
+
if key == "credit_policy" and isinstance(value, dict):
|
|
352
|
+
lines.append(" credit_policy:")
|
|
353
|
+
for feature in CREDIT_FEATURES:
|
|
354
|
+
lines.append(
|
|
355
|
+
f" {feature}: {value.get(feature, DEFAULT_CREDIT_POLICY)}"
|
|
356
|
+
)
|
|
357
|
+
continue
|
|
358
|
+
if isinstance(value, bool):
|
|
359
|
+
value = "yes" if value else "no"
|
|
360
|
+
if value is None:
|
|
361
|
+
value = "none"
|
|
362
|
+
lines.append(f" {key}: {value}")
|
|
363
|
+
return lines
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _print_status(status: dict[str, Any]) -> None:
|
|
367
|
+
print("\n".join(_status_lines(status)))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_status(
|
|
371
|
+
args: argparse.Namespace,
|
|
372
|
+
*,
|
|
373
|
+
provider: TinyFishWebSearchProvider | None = None,
|
|
374
|
+
) -> int:
|
|
375
|
+
status = collect_status(live=False, provider=provider)
|
|
376
|
+
if getattr(args, "json", False):
|
|
377
|
+
print(json.dumps(status, indent=2, sort_keys=True))
|
|
378
|
+
else:
|
|
379
|
+
_print_status(status)
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _recommended_next_step(status: dict[str, Any]) -> str:
|
|
384
|
+
configured = bool(
|
|
385
|
+
status.get("web_backend_configured") and status.get("api_key_configured")
|
|
386
|
+
)
|
|
387
|
+
if not configured:
|
|
388
|
+
return "run `hermes tinyfish setup`."
|
|
389
|
+
if not status.get("live_search_ok", True) or not status.get("live_fetch_ok", True):
|
|
390
|
+
return (
|
|
391
|
+
"verify your TinyFish API key and service availability, then retry "
|
|
392
|
+
"`hermes tinyfish doctor --live`."
|
|
393
|
+
)
|
|
394
|
+
return (
|
|
395
|
+
"review the browser credit policy and API key, then retry "
|
|
396
|
+
"`hermes tinyfish doctor --live-paid`."
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def cmd_doctor(
|
|
401
|
+
args: argparse.Namespace,
|
|
402
|
+
*,
|
|
403
|
+
provider: TinyFishWebSearchProvider | None = None,
|
|
404
|
+
) -> int:
|
|
405
|
+
status = collect_status(live=bool(getattr(args, "live", False)), provider=provider)
|
|
406
|
+
if getattr(args, "live_paid", False):
|
|
407
|
+
paid_ok = _run_live_paid_checks(status)
|
|
408
|
+
status["ok"] = bool(status["ok"] and paid_ok)
|
|
409
|
+
if getattr(args, "json", False):
|
|
410
|
+
print(json.dumps(status, indent=2, sort_keys=True))
|
|
411
|
+
else:
|
|
412
|
+
_print_status(status)
|
|
413
|
+
if not status["ok"]:
|
|
414
|
+
print()
|
|
415
|
+
print(f"Recommended next step: {_recommended_next_step(status)}")
|
|
416
|
+
return 0 if status["ok"] else 1
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _run_live_paid_checks(status: dict[str, Any]) -> bool:
|
|
420
|
+
policy_status = status.get("credit_policy") or {}
|
|
421
|
+
browser_policy = (
|
|
422
|
+
policy_status.get("browser", "deny")
|
|
423
|
+
if isinstance(policy_status, dict)
|
|
424
|
+
else "deny"
|
|
425
|
+
)
|
|
426
|
+
if browser_policy == "deny":
|
|
427
|
+
status["live_paid_ok"] = False
|
|
428
|
+
status["live_paid_browser_ok"] = False
|
|
429
|
+
status["live_paid_error"] = "TinyFish browser policy is deny."
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
approved, message = request_credit_approval(
|
|
433
|
+
"browser", "doctor-live-paid", "TinyFish Browser session"
|
|
434
|
+
)
|
|
435
|
+
if not approved:
|
|
436
|
+
status["live_paid_ok"] = False
|
|
437
|
+
status["live_paid_browser_ok"] = False
|
|
438
|
+
status["live_paid_error"] = message
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
api_key = _api_key()
|
|
442
|
+
if not api_key:
|
|
443
|
+
status["live_paid_ok"] = False
|
|
444
|
+
status["live_paid_browser_ok"] = False
|
|
445
|
+
status["live_paid_error"] = (
|
|
446
|
+
"A TinyFish API key is required for paid live checks."
|
|
447
|
+
)
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
session_id = ""
|
|
451
|
+
try:
|
|
452
|
+
session = rest_client.create_browser_session(api_key=api_key)
|
|
453
|
+
session_id = str(session.get("session_id") or session.get("id") or "").strip()
|
|
454
|
+
if not session_id:
|
|
455
|
+
status["live_paid_error"] = (
|
|
456
|
+
"TinyFish Browser returned no session ID; a session it may still "
|
|
457
|
+
"have created cannot be closed and will expire via the server's "
|
|
458
|
+
"1h inactivity timeout."
|
|
459
|
+
)
|
|
460
|
+
except Exception as exc: # noqa: BLE001 - diagnostics must not crash
|
|
461
|
+
status["live_paid_error"] = (
|
|
462
|
+
f"TinyFish Browser session creation failed ({type(exc).__name__})."
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
cleanup_ok = False
|
|
466
|
+
if session_id:
|
|
467
|
+
try:
|
|
468
|
+
cleanup_ok = bool(
|
|
469
|
+
rest_client.close_browser_session(session_id, api_key=api_key)
|
|
470
|
+
)
|
|
471
|
+
except Exception: # noqa: BLE001 - cleanup result is reported below
|
|
472
|
+
cleanup_ok = False
|
|
473
|
+
if not cleanup_ok:
|
|
474
|
+
status["live_paid_error"] = "TinyFish Browser session cleanup failed."
|
|
475
|
+
|
|
476
|
+
status["live_paid_browser_cleanup_ok"] = cleanup_ok
|
|
477
|
+
status["live_paid_browser_ok"] = bool(session_id and cleanup_ok)
|
|
478
|
+
status["live_paid_ok"] = status["live_paid_browser_ok"]
|
|
479
|
+
return bool(status["live_paid_ok"])
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def cmd_credits(args: argparse.Namespace) -> int:
|
|
483
|
+
subcommand = getattr(args, "credits_command", None) or "status"
|
|
484
|
+
config = _load_config()
|
|
485
|
+
if subcommand == "status":
|
|
486
|
+
payload = {"credit_policy": credit_policy_summary(config)}
|
|
487
|
+
if getattr(args, "json", False):
|
|
488
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
489
|
+
else:
|
|
490
|
+
print("TinyFish credit policies")
|
|
491
|
+
for feature in CREDIT_FEATURES:
|
|
492
|
+
print(f" {feature}: {payload['credit_policy'][feature]}")
|
|
493
|
+
return 0
|
|
494
|
+
if subcommand == "set":
|
|
495
|
+
feature = normalize_feature(args.feature)
|
|
496
|
+
policy = normalize_policy(args.policy)
|
|
497
|
+
set_credit_policy(config, feature, policy)
|
|
498
|
+
_save_config(config)
|
|
499
|
+
print(f"Set TinyFish {feature} credit policy to {policy}.")
|
|
500
|
+
return 0
|
|
501
|
+
if subcommand == "reset":
|
|
502
|
+
for feature in CREDIT_FEATURES:
|
|
503
|
+
set_credit_policy(config, feature, DEFAULT_CREDIT_POLICY)
|
|
504
|
+
_save_config(config)
|
|
505
|
+
print(
|
|
506
|
+
f"Reset TinyFish credit policies to {DEFAULT_CREDIT_POLICY} (the default)."
|
|
507
|
+
)
|
|
508
|
+
return 0
|
|
509
|
+
print("Usage: hermes tinyfish credits {status,set,reset}", file=sys.stderr)
|
|
510
|
+
return 2
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _policy_effect_line(policy: str) -> str:
|
|
514
|
+
if policy == "allow":
|
|
515
|
+
return "Policy 'allow': sessions start without per-session approval."
|
|
516
|
+
if policy == "deny":
|
|
517
|
+
return "Policy 'deny': TinyFish browser sessions are blocked."
|
|
518
|
+
return "Policy 'request': each TinyFish browser session asks for approval."
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def cmd_browser(args: argparse.Namespace) -> int:
|
|
522
|
+
subcommand = getattr(args, "browser_command", None) or "status"
|
|
523
|
+
config = _load_config()
|
|
524
|
+
current = browser_cloud_provider(config)
|
|
525
|
+
policy = credit_policy_summary(config)["browser"]
|
|
526
|
+
if subcommand == "enable":
|
|
527
|
+
if current == "tinyfish":
|
|
528
|
+
print("TinyFish is already Hermes' browser cloud provider.")
|
|
529
|
+
else:
|
|
530
|
+
section = config.setdefault("browser", {})
|
|
531
|
+
if not isinstance(section, dict):
|
|
532
|
+
section = {}
|
|
533
|
+
config["browser"] = section
|
|
534
|
+
section["cloud_provider"] = "tinyfish"
|
|
535
|
+
_save_config(config)
|
|
536
|
+
print("Set browser.cloud_provider to tinyfish.")
|
|
537
|
+
print(_policy_effect_line(policy))
|
|
538
|
+
return 0
|
|
539
|
+
if subcommand == "disable":
|
|
540
|
+
if current != "tinyfish":
|
|
541
|
+
print("TinyFish is not Hermes' browser cloud provider; nothing to change.")
|
|
542
|
+
return 0
|
|
543
|
+
section = config.get("browser")
|
|
544
|
+
if isinstance(section, dict):
|
|
545
|
+
section.pop("cloud_provider", None)
|
|
546
|
+
_save_config(config)
|
|
547
|
+
print("Removed browser.cloud_provider (was tinyfish).")
|
|
548
|
+
return 0
|
|
549
|
+
if subcommand == "status":
|
|
550
|
+
print(f"browser.cloud_provider: {current or 'unset'}")
|
|
551
|
+
print(f"tinyfish.credit_policy.browser: {policy}")
|
|
552
|
+
print(_policy_effect_line(policy))
|
|
553
|
+
return 0
|
|
554
|
+
print("Usage: hermes tinyfish browser {enable,disable,status}", file=sys.stderr)
|
|
555
|
+
return 2
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _api_key_or_error(*, as_json: bool = False) -> str | None:
|
|
559
|
+
api_key = _api_key()
|
|
560
|
+
if not api_key:
|
|
561
|
+
if as_json:
|
|
562
|
+
payload = {
|
|
563
|
+
"success": False,
|
|
564
|
+
"wallet_available": None,
|
|
565
|
+
"error": MISSING_KEY_ERROR,
|
|
566
|
+
}
|
|
567
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
568
|
+
else:
|
|
569
|
+
print(MISSING_KEY_ERROR, file=sys.stderr)
|
|
570
|
+
return None
|
|
571
|
+
return api_key
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _wallet_amount(value: Any, currency: Any) -> str:
|
|
575
|
+
amount = str(value) if value is not None else "unavailable"
|
|
576
|
+
code = str(currency or "").strip()
|
|
577
|
+
if amount == "unavailable":
|
|
578
|
+
return amount
|
|
579
|
+
if code == "USD":
|
|
580
|
+
return f"${amount} USD"
|
|
581
|
+
return f"{amount} {code}".rstrip()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _wallet_text_lines(wallet: dict[str, Any]) -> list[str]:
|
|
585
|
+
currency = wallet.get("currency")
|
|
586
|
+
balance = _wallet_amount(wallet.get("available_balance"), currency)
|
|
587
|
+
lines = [
|
|
588
|
+
"TinyFish usage",
|
|
589
|
+
f" Available balance: {balance}",
|
|
590
|
+
f" As of: {wallet.get('as_of') or 'unavailable'}",
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
auto_reload = wallet.get("auto_reload")
|
|
594
|
+
if auto_reload is None:
|
|
595
|
+
lines.append(" Auto-reload: unavailable")
|
|
596
|
+
elif isinstance(auto_reload, dict):
|
|
597
|
+
state = str(auto_reload.get("state") or "unknown").replace("_", " ")
|
|
598
|
+
if state == "unconfigured":
|
|
599
|
+
lines.append(" Auto-reload: not configured")
|
|
600
|
+
else:
|
|
601
|
+
threshold = _wallet_amount(auto_reload.get("threshold"), currency)
|
|
602
|
+
recharge_to = _wallet_amount(auto_reload.get("recharge_to"), currency)
|
|
603
|
+
lines.append(
|
|
604
|
+
f" Auto-reload: {state} (at {threshold}, recharge to {recharge_to})"
|
|
605
|
+
)
|
|
606
|
+
else:
|
|
607
|
+
lines.append(" Auto-reload: invalid response")
|
|
608
|
+
|
|
609
|
+
pending = wallet.get("pending_top_up")
|
|
610
|
+
if pending is None:
|
|
611
|
+
lines.append(" Pending top-up: none")
|
|
612
|
+
elif isinstance(pending, dict):
|
|
613
|
+
amount = _wallet_amount(pending.get("amount"), currency)
|
|
614
|
+
lines.append(
|
|
615
|
+
f" Pending top-up: {amount} since {pending.get('started_at') or 'unknown'}"
|
|
616
|
+
)
|
|
617
|
+
else:
|
|
618
|
+
lines.append(" Pending top-up: invalid response")
|
|
619
|
+
|
|
620
|
+
rates = wallet.get("rates")
|
|
621
|
+
meters = rates.get("meters") if isinstance(rates, dict) else None
|
|
622
|
+
if isinstance(meters, list) and meters:
|
|
623
|
+
lines.append(" Rates:")
|
|
624
|
+
for meter in meters:
|
|
625
|
+
if not isinstance(meter, dict):
|
|
626
|
+
continue
|
|
627
|
+
label = meter.get("label") or meter.get("product_id") or "Unnamed meter"
|
|
628
|
+
amount = _wallet_amount(meter.get("unit_amount"), meter.get("currency"))
|
|
629
|
+
per = f" per {meter['per']}" if meter.get("per") else ""
|
|
630
|
+
lines.append(f" {label}: {amount}{per}")
|
|
631
|
+
elif rates is None:
|
|
632
|
+
lines.append(" Rates: unavailable")
|
|
633
|
+
else:
|
|
634
|
+
lines.append(" Rates: none")
|
|
635
|
+
|
|
636
|
+
return lines
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def cmd_usage(args: argparse.Namespace) -> int:
|
|
640
|
+
api_key = _api_key_or_error(as_json=bool(getattr(args, "json", False)))
|
|
641
|
+
if not api_key:
|
|
642
|
+
return 1
|
|
643
|
+
payload: dict[str, Any]
|
|
644
|
+
try:
|
|
645
|
+
wallet = rest_client.wallet(api_key=api_key)
|
|
646
|
+
except rest_client.TinyFishWalletNotFound:
|
|
647
|
+
payload = {
|
|
648
|
+
"success": True,
|
|
649
|
+
"wallet_available": False,
|
|
650
|
+
"reason": "legacy_billing_or_no_metronome_customer",
|
|
651
|
+
"message": (
|
|
652
|
+
"This account uses legacy billing or does not have a Metronome "
|
|
653
|
+
"wallet yet."
|
|
654
|
+
),
|
|
655
|
+
"billing_url": API_KEY_URL,
|
|
656
|
+
}
|
|
657
|
+
text_lines = [
|
|
658
|
+
"TinyFish usage",
|
|
659
|
+
" Wallet balance unavailable.",
|
|
660
|
+
f" {payload['message']}",
|
|
661
|
+
f" Billing: {API_KEY_URL}",
|
|
662
|
+
]
|
|
663
|
+
except Exception as exc: # noqa: BLE001 - CLI reports the failure and exits 1
|
|
664
|
+
payload = {"success": False, "wallet_available": None, "error": str(exc)}
|
|
665
|
+
text_lines = ["TinyFish usage", f" Error: {exc}"]
|
|
666
|
+
else:
|
|
667
|
+
if not isinstance(wallet, dict):
|
|
668
|
+
payload = {
|
|
669
|
+
"success": False,
|
|
670
|
+
"wallet_available": None,
|
|
671
|
+
"error": "TinyFish Wallet returned an invalid response",
|
|
672
|
+
}
|
|
673
|
+
text_lines = ["TinyFish usage", f" Error: {payload['error']}"]
|
|
674
|
+
else:
|
|
675
|
+
payload = {"success": True, "wallet_available": True, "wallet": wallet}
|
|
676
|
+
text_lines = _wallet_text_lines(wallet)
|
|
677
|
+
if bool(getattr(args, "json", False)):
|
|
678
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
679
|
+
else:
|
|
680
|
+
print("\n".join(text_lines))
|
|
681
|
+
return 0 if payload["success"] else 1
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def tinyfish_status_command(
|
|
685
|
+
raw_args: str,
|
|
686
|
+
*,
|
|
687
|
+
provider: TinyFishWebSearchProvider,
|
|
688
|
+
) -> str:
|
|
689
|
+
"""Serve the in-session ``/tinyfish-status`` command."""
|
|
690
|
+
|
|
691
|
+
option = (raw_args or "").strip().lower()
|
|
692
|
+
if option not in {"", "live"}:
|
|
693
|
+
return "Usage: /tinyfish-status [live]"
|
|
694
|
+
status = collect_status(live=option == "live", provider=provider)
|
|
695
|
+
return "\n".join(_status_lines(status))
|