@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 ADDED
@@ -0,0 +1,38 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tiny Fish, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10
+
11
+ ---
12
+
13
+ Third-party notices
14
+
15
+ The provider architecture is ported from hermes-plugin-tinyfish
16
+ (https://github.com/gabeosx/hermes-plugin-tinyfish), used under the MIT License:
17
+
18
+ MIT License
19
+
20
+ Copyright (c) 2026 gabeosx
21
+
22
+ Permission is hereby granted, free of charge, to any person obtaining a copy
23
+ of this software and associated documentation files (the "Software"), to deal
24
+ in the Software without restriction, including without limitation the rights
25
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
26
+ copies of the Software, and to permit persons to whom the Software is
27
+ furnished to do so, subject to the following conditions:
28
+
29
+ The above copyright notice and this permission notice shall be included in all
30
+ copies or substantial portions of the Software.
31
+
32
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # TinyFish for Hermes Agent
2
+
3
+ `tinyfish-hermes` is the first-party TinyFish plugin for [Hermes Agent](https://github.com/NousResearch/Hermes-Agent). It registers a `tinyfish` web provider that serves Hermes' web search and extract tools through the TinyFish Search and Fetch REST APIs, plus a `tinyfish` cloud browser provider for credit-gated remote browser sessions.
4
+
5
+ ## Install
6
+
7
+ The supported path is the TinyFish CLI, which installs the plugin from npm
8
+ (`@tiny-fish/hermes@latest`), seeds the API key, and points Hermes' web backends at it:
9
+
10
+ ```bash
11
+ tinyfish connect hermes
12
+ ```
13
+
14
+ To install just the plugin straight from this repo (no key seeding, no backend wiring):
15
+
16
+ ```bash
17
+ hermes plugins install tinyfish-io/tinyfish-web-agent-integrations/hermes --enable
18
+ ```
19
+
20
+ ## Authentication
21
+
22
+ The plugin authenticates with a TinyFish API key — create one at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys). Keys are resolved in order:
23
+
24
+ | Env var | Source |
25
+ | ------- | ------ |
26
+ | `TINYFISH_API_KEY` | Set it yourself (shell env, or `hermes tinyfish setup --api-key`) |
27
+ | `MCP_TINYFISH_API_KEY` | Seeded by the TinyFish npm CLI during `tinyfish connect hermes` |
28
+
29
+ ## CLI
30
+
31
+ The plugin registers a `hermes tinyfish` command and an in-session `/tinyfish-status [live]` command:
32
+
33
+ | Subcommand | Purpose |
34
+ | ---------- | ------- |
35
+ | `setup [--yes] [--api-key KEY] [--no-web-backend] [--live]` | Route web tools to TinyFish and save the API key |
36
+ | `status [--json]` | Non-secret configuration status |
37
+ | `doctor [--json] [--live] [--live-paid]` | Diagnostics; `--live` runs one real search+fetch, `--live-paid` a browser session per credit policy |
38
+ | `credits {status,set,reset}` | Inspect or change `tinyfish.credit_policy.browser` |
39
+ | `browser {enable,disable,status}` | Toggle `browser.cloud_provider: tinyfish` |
40
+ | `usage [--json]` | Wallet balance, auto-reload, and billing rates |
41
+
42
+ ## Configuration
43
+
44
+ `hermes tinyfish setup` routes Hermes' web tools to TinyFish; the equivalent Hermes configuration is:
45
+
46
+ ```yaml
47
+ web:
48
+ search_backend: tinyfish
49
+ extract_backend: tinyfish
50
+ ```
51
+
52
+ Optional request tuning under a `tinyfish` section:
53
+
54
+ ```yaml
55
+ tinyfish:
56
+ search:
57
+ location: US # also: language, recency_minutes, after_date,
58
+ domain_type: news # before_date, page, purpose
59
+ fetch:
60
+ format: markdown # default output format for extract
61
+ ttl: 300 # also: per_url_timeout_ms, links, image_links
62
+ ```
63
+
64
+ ## Browser sessions
65
+
66
+ Setting `browser.cloud_provider: tinyfish` routes Hermes' browser tools through TinyFish remote browser sessions. Each session consumes TinyFish credits, so sessions are policy-gated via `tinyfish.credit_policy.browser`:
67
+
68
+ | Policy | Behavior |
69
+ | ------ | -------- |
70
+ | `request` (default) | Every session goes through Hermes' approval gate |
71
+ | `allow` | Sessions start without per-session approval |
72
+ | `deny` | Browser tools are blocked while routed to TinyFish |
73
+
74
+ ```yaml
75
+ browser:
76
+ cloud_provider: tinyfish
77
+ tinyfish:
78
+ credit_policy:
79
+ browser: request
80
+ browser:
81
+ timeout_seconds: 300 # optional session timeout
82
+ ```
83
+
84
+ ## Tool-routing guidance
85
+
86
+ When the `tinyfish` MCP server is also configured in Hermes (`mcp_servers.tinyfish.url: https://agent.tinyfish.ai/mcp`), the plugin injects once-per-context guidance steering the model between the generic web tools and the native MCP tools. Disable with `tinyfish.routing_context: false`.
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ python -m pip install -e . -r requirements-dev.txt # dev deps are version-bounded in requirements-dev.txt
92
+ make lint
93
+ make test
94
+ ```
95
+
96
+ ## Credits
97
+
98
+ The provider architecture is ported from [gabeosx/hermes-plugin-tinyfish](https://github.com/gabeosx/hermes-plugin-tinyfish) (MIT), an independent community plugin. Both plugins register a web provider named `tinyfish`, so install only one of them.
99
+
100
+ ## License
101
+
102
+ MIT
package/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Directory-plugin entry point loaded by ``hermes plugins install``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from .tinyfish_hermes import __version__, register
7
+ except ImportError: # pip installs import the packaged module directly
8
+ from tinyfish_hermes import __version__, register
9
+
10
+ __all__ = ["__version__", "register"]
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@tiny-fish/hermes",
3
+ "version": "0.1.1",
4
+ "description": "TinyFish web provider plugin for Hermes Agent, delivered via npm for the TinyFish CLI. Installed by `tinyfish connect hermes`; not a JavaScript library.",
5
+ "license": "MIT",
6
+ "homepage": "https://docs.tinyfish.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/tinyfish-io/tinyfish-web-agent-integrations.git",
10
+ "directory": "hermes"
11
+ },
12
+ "author": {
13
+ "name": "TinyFish",
14
+ "email": "support@tinyfish.io",
15
+ "url": "https://tinyfish.ai"
16
+ },
17
+ "keywords": [
18
+ "hermes",
19
+ "tinyfish",
20
+ "web-search",
21
+ "web-fetch",
22
+ "ai-agent"
23
+ ],
24
+ "files": [
25
+ "tinyfish_hermes/*.py",
26
+ "plugin.yaml",
27
+ "pyproject.toml",
28
+ "__init__.py"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }
package/plugin.yaml ADDED
@@ -0,0 +1,13 @@
1
+ name: tinyfish
2
+ version: 0.1.1
3
+ description: "First-party TinyFish provider plugin for Hermes Agent: Search and Fetch over the TinyFish REST APIs with API-key auth, plus credit-gated Browser sessions."
4
+ author: TinyFish
5
+ kind: backend
6
+ provides_web_providers:
7
+ - tinyfish
8
+ provides_browser_providers:
9
+ - tinyfish
10
+ requires_env:
11
+ - TINYFISH_API_KEY
12
+ homepage: "https://tinyfish.ai"
13
+ license: MIT
package/pyproject.toml ADDED
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tinyfish-hermes"
7
+ version = "0.1.1"
8
+ description = "TinyFish web provider plugin for Hermes Agent - Search and Fetch over the TinyFish REST APIs"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "TinyFish", email = "support@tinyfish.io" }]
13
+ keywords = ["hermes", "tinyfish", "web-search", "web-fetch", "ai-agent"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries",
24
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
25
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.24,<1.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://tinyfish.ai"
33
+ Documentation = "https://docs.tinyfish.ai"
34
+ Repository = "https://github.com/tinyfish-io/tinyfish-web-agent-integrations"
35
+
36
+ [project.entry-points."hermes_agent.plugins"]
37
+ tinyfish = "tinyfish_hermes"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["tinyfish_hermes"]
41
+
42
+ [tool.ruff]
43
+ target-version = "py310"
44
+ line-length = 88
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "T201", "W"]
48
+
49
+ [tool.ruff.lint.per-file-ignores]
50
+ # ROUTING_GUIDANCE is injected verbatim into LLM context, one bullet per line.
51
+ "tinyfish_hermes/routing_context.py" = ["E501"]
52
+ # The CLI's output medium is stdout.
53
+ "tinyfish_hermes/setup_cli.py" = ["T201"]
54
+
55
+ [tool.mypy]
56
+ python_version = "3.10"
57
+ # The directory-install shim (./__init__.py) would otherwise make mypy see the package twice.
58
+ explicit_package_bases = true
59
+ warn_return_any = true
60
+ warn_unused_configs = true
61
+ disallow_untyped_defs = true
62
+ ignore_missing_imports = true
63
+
64
+ [tool.pytest.ini_options]
65
+ testpaths = ["tests"]
66
+ addopts = "--strict-markers --strict-config --durations=5"
@@ -0,0 +1,113 @@
1
+ """First-party TinyFish web provider plugin for Hermes Agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ _DISTRIBUTION_NAME = "tinyfish-hermes"
10
+ _PLUGIN_MANIFEST = Path(__file__).resolve().parents[1] / "plugin.yaml"
11
+
12
+
13
+ def _version_from_plugin_manifest(path: Path | None = None) -> str | None:
14
+ path = _PLUGIN_MANIFEST if path is None else path
15
+ try:
16
+ lines = path.read_text(encoding="utf-8").splitlines()
17
+ except (OSError, UnicodeError):
18
+ return None
19
+ for line in lines:
20
+ if not line.startswith("version:"):
21
+ continue
22
+ value = line.partition(":")[2].split("#", 1)[0].strip().strip("'\"")
23
+ return value or None
24
+ return None
25
+
26
+
27
+ def _version_from_metadata() -> str | None:
28
+ try:
29
+ return metadata.version(_DISTRIBUTION_NAME)
30
+ except Exception: # directory installs must not depend on package metadata
31
+ return None
32
+
33
+
34
+ def _resolve_version() -> str:
35
+ # httpx headers must be ASCII; wheels omit plugin.yaml.
36
+ for candidate in (_version_from_plugin_manifest(), _version_from_metadata()):
37
+ if candidate and candidate.isascii():
38
+ return candidate
39
+ return "0+unknown"
40
+
41
+
42
+ __version__ = _resolve_version()
43
+
44
+
45
+ def register(ctx: Any) -> None:
46
+ """Register TinyFish providers, lifecycle hooks, and CLI commands with Hermes."""
47
+
48
+ from .browser_provider import TinyFishBrowserProvider
49
+ from .credit_policy import pre_tool_call_policy
50
+ from .provider import TinyFishWebSearchProvider
51
+ from .routing_context import routing_context_hook
52
+ from .setup_cli import (
53
+ dispatch_tinyfish_cli,
54
+ setup_tinyfish_cli,
55
+ tinyfish_status_command,
56
+ )
57
+
58
+ provider = TinyFishWebSearchProvider()
59
+ ctx.register_web_search_provider(provider)
60
+ if hasattr(ctx, "register_hook"):
61
+ # Only offer the browser provider where the pre_tool_call credit gate lands.
62
+ if hasattr(ctx, "register_browser_provider"):
63
+ ctx.register_browser_provider(TinyFishBrowserProvider())
64
+ ctx.register_hook("pre_tool_call", pre_tool_call_policy)
65
+ ctx.register_hook("pre_llm_call", routing_context_hook)
66
+
67
+ if hasattr(ctx, "register_cli_command"):
68
+
69
+ def _dispatch_cli(args: Any) -> int:
70
+ # Hermes' CLI main() propagates a nonzero int return to sys.exit.
71
+ return dispatch_tinyfish_cli(args, provider=provider)
72
+
73
+ ctx.register_cli_command(
74
+ name="tinyfish",
75
+ help="Configure and diagnose the TinyFish web provider",
76
+ setup_fn=setup_tinyfish_cli,
77
+ handler_fn=_dispatch_cli,
78
+ description=(
79
+ "Route Hermes web tools to TinyFish and manage the API key, "
80
+ "credit policies, and browser sessions."
81
+ ),
82
+ )
83
+ if hasattr(ctx, "register_command"):
84
+ ctx.register_command(
85
+ name="tinyfish-status",
86
+ handler=lambda raw_args: tinyfish_status_command(
87
+ raw_args, provider=provider
88
+ ),
89
+ description=(
90
+ "Show TinyFish provider status; pass 'live' to test Search and Fetch."
91
+ ),
92
+ args_hint="[live]",
93
+ )
94
+
95
+
96
+ def __getattr__(name: str) -> Any:
97
+ if name == "TinyFishWebSearchProvider":
98
+ from .provider import TinyFishWebSearchProvider
99
+
100
+ return TinyFishWebSearchProvider
101
+ if name == "TinyFishBrowserProvider":
102
+ from .browser_provider import TinyFishBrowserProvider
103
+
104
+ return TinyFishBrowserProvider
105
+ raise AttributeError(name)
106
+
107
+
108
+ __all__ = [
109
+ "TinyFishBrowserProvider",
110
+ "TinyFishWebSearchProvider",
111
+ "__version__",
112
+ "register",
113
+ ]
@@ -0,0 +1,133 @@
1
+ """Hermes BrowserProvider implementation for TinyFish Browser sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import uuid
7
+ from typing import Any
8
+
9
+ try:
10
+ from agent.browser_provider import BrowserProvider as _HermesBrowserProvider
11
+ except Exception: # pragma: no cover - lets the package import outside Hermes
12
+
13
+ class _HermesBrowserProvider: # type: ignore[no-redef]
14
+ pass
15
+
16
+
17
+ from . import rest_client
18
+ from .config import credit_policy, tinyfish_config
19
+ from .credit_policy import block_message
20
+ from .provider import API_KEY_URL, MISSING_KEY_ERROR, _api_key
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class TinyFishBrowserProvider(_HermesBrowserProvider): # type: ignore[misc]
26
+ """TinyFish cloud browser backend for Hermes browser tools."""
27
+
28
+ @property
29
+ def name(self) -> str:
30
+ return "tinyfish"
31
+
32
+ @property
33
+ def display_name(self) -> str:
34
+ return "TinyFish"
35
+
36
+ def is_available(self) -> bool:
37
+ return bool(credit_policy("browser") in {"request", "allow"} and _api_key())
38
+
39
+ def create_session(self, task_id: str) -> dict[str, object]:
40
+ api_key = _api_key()
41
+ if not api_key:
42
+ # Hermes falls back to a local browser on raise; the hook is the real gate.
43
+ logger.warning(
44
+ "TinyFish Browser create_session refused (missing API key); "
45
+ "Hermes will fall back to a local browser"
46
+ )
47
+ raise ValueError(MISSING_KEY_ERROR)
48
+ if credit_policy("browser") == "deny":
49
+ logger.warning(
50
+ "TinyFish Browser create_session refused (policy deny); "
51
+ "Hermes will fall back to a local browser"
52
+ )
53
+ raise ValueError(block_message("browser"))
54
+ browser_cfg = tinyfish_config().get("browser") or {}
55
+ timeout_seconds = None
56
+ if isinstance(browser_cfg, dict) and browser_cfg.get("timeout_seconds") not in (
57
+ None,
58
+ "",
59
+ ):
60
+ try:
61
+ timeout_seconds = int(browser_cfg["timeout_seconds"])
62
+ except (TypeError, ValueError):
63
+ logger.warning(
64
+ "Ignoring malformed tinyfish.browser.timeout_seconds %r",
65
+ browser_cfg["timeout_seconds"],
66
+ )
67
+ timeout_seconds = None
68
+
69
+ data = rest_client.create_browser_session(
70
+ api_key=api_key, timeout_seconds=timeout_seconds
71
+ )
72
+ session_id = str(data.get("session_id") or data.get("id") or "")
73
+ cdp_url = str(data.get("cdp_url") or data.get("cdpUrl") or "")
74
+ if not session_id or not cdp_url:
75
+ if session_id:
76
+ # A half-created session bills until the server's inactivity GC.
77
+ try:
78
+ rest_client.close_browser_session(session_id, api_key=api_key)
79
+ except Exception:
80
+ pass
81
+ raise RuntimeError(
82
+ "TinyFish Browser did not return session_id and cdp_url."
83
+ )
84
+ session_name = f"tinyfish_{task_id}_{uuid.uuid4().hex[:8]}"
85
+ logger.info("Created TinyFish browser session")
86
+ return {
87
+ "session_name": session_name,
88
+ "bb_session_id": session_id,
89
+ "cdp_url": cdp_url,
90
+ "features": {
91
+ "tinyfish": True,
92
+ "credit_policy": credit_policy("browser"),
93
+ "base_url": str(data.get("base_url") or ""),
94
+ },
95
+ }
96
+
97
+ def close_session(self, session_id: str) -> bool:
98
+ # Hermes' BrowserProvider contract: close_session must never raise.
99
+ try:
100
+ api_key = _api_key()
101
+ if not api_key:
102
+ logger.warning("Cannot close TinyFish browser session: missing API key")
103
+ return False
104
+ return rest_client.close_browser_session(session_id, api_key=api_key)
105
+ except Exception as exc:
106
+ logger.debug("TinyFish browser close failed (%s)", type(exc).__name__)
107
+ return False
108
+
109
+ def emergency_cleanup(self, session_id: str) -> None:
110
+ try:
111
+ self.close_session(session_id)
112
+ except Exception as exc:
113
+ logger.debug(
114
+ "TinyFish browser emergency cleanup failed (%s)", type(exc).__name__
115
+ )
116
+
117
+ def get_setup_schema(self) -> dict[str, Any]:
118
+ return {
119
+ "name": "TinyFish",
120
+ "badge": "credit-gated",
121
+ "tag": (
122
+ "Remote browser sessions; each session consumes TinyFish credits. "
123
+ "Default policy 'request' asks for approval per session."
124
+ ),
125
+ "env_vars": [
126
+ {
127
+ "key": "TINYFISH_API_KEY",
128
+ "prompt": "TinyFish API key",
129
+ "url": API_KEY_URL,
130
+ }
131
+ ],
132
+ "post_setup": "agent_browser",
133
+ }
@@ -0,0 +1,164 @@
1
+ """Read the ``tinyfish`` section of Hermes' user configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ CreditFeature = Literal["browser"]
8
+ CreditPolicy = Literal["deny", "request", "allow"]
9
+
10
+ CREDIT_FEATURES: tuple[CreditFeature, ...] = ("browser",)
11
+ CREDIT_POLICIES: tuple[CreditPolicy, ...] = ("deny", "request", "allow")
12
+ # 'request' is inert until browser.cloud_provider is tinyfish, and still
13
+ # routes each session through Hermes approval once it is.
14
+ DEFAULT_CREDIT_POLICY: CreditPolicy = "request"
15
+
16
+ SearchOptions = dict[str, Any]
17
+ FetchOptions = dict[str, Any]
18
+
19
+
20
+ def load_config() -> dict[str, Any]:
21
+ try:
22
+ from hermes_cli.config import load_config as _load_config
23
+
24
+ return dict(_load_config() or {})
25
+ except Exception: # config layer is optional outside Hermes
26
+ return {}
27
+
28
+
29
+ def save_config(config: dict[str, Any]) -> None:
30
+ from hermes_cli.config import save_config as _save_config
31
+
32
+ _save_config(config)
33
+
34
+
35
+ def tinyfish_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
36
+ cfg = load_config() if config is None else config
37
+ section = cfg.get("tinyfish") or {}
38
+ return section if isinstance(section, dict) else {}
39
+
40
+
41
+ def normalize_feature(value: str) -> CreditFeature:
42
+ if value.strip().lower().replace("_", "-") == "browser":
43
+ return "browser"
44
+ raise ValueError(
45
+ f"Unknown TinyFish credit feature '{value}'. Valid features: browser"
46
+ )
47
+
48
+
49
+ def normalize_policy(value: Any) -> CreditPolicy:
50
+ policy = str(value or DEFAULT_CREDIT_POLICY).strip().lower()
51
+ if policy not in CREDIT_POLICIES:
52
+ valid = ", ".join(CREDIT_POLICIES)
53
+ raise ValueError(
54
+ f"Unknown TinyFish credit policy '{value}'. Valid policies: {valid}"
55
+ )
56
+ return policy
57
+
58
+
59
+ def credit_policy(
60
+ feature: CreditFeature | str, config: dict[str, Any] | None = None
61
+ ) -> CreditPolicy:
62
+ normalized = normalize_feature(str(feature))
63
+ policies = tinyfish_config(config).get("credit_policy") or {}
64
+ if not isinstance(policies, dict):
65
+ return DEFAULT_CREDIT_POLICY
66
+ return normalize_policy(policies.get(normalized, DEFAULT_CREDIT_POLICY))
67
+
68
+
69
+ def set_credit_policy(
70
+ config: dict[str, Any], feature: CreditFeature | str, policy: CreditPolicy | str
71
+ ) -> None:
72
+ normalized_feature = normalize_feature(str(feature))
73
+ normalized_policy = normalize_policy(policy)
74
+ section = config.setdefault("tinyfish", {})
75
+ if not isinstance(section, dict):
76
+ section = {}
77
+ config["tinyfish"] = section
78
+ policies = section.setdefault("credit_policy", {})
79
+ if not isinstance(policies, dict):
80
+ policies = {}
81
+ section["credit_policy"] = policies
82
+ policies[normalized_feature] = normalized_policy
83
+
84
+
85
+ def credit_policy_summary(
86
+ config: dict[str, Any] | None = None,
87
+ ) -> dict[CreditFeature, CreditPolicy]:
88
+ return {feature: credit_policy(feature, config) for feature in CREDIT_FEATURES}
89
+
90
+
91
+ def routing_context_enabled(config: dict[str, Any] | None = None) -> bool:
92
+ value = _bool_option(tinyfish_config(config).get("routing_context"))
93
+ return True if value is None else value
94
+
95
+
96
+ def browser_cloud_provider(config: dict[str, Any] | None = None) -> str:
97
+ cfg = load_config() if config is None else config
98
+ section = cfg.get("browser") or {}
99
+ if not isinstance(section, dict):
100
+ return ""
101
+ return str(section.get("cloud_provider") or "").strip().lower()
102
+
103
+
104
+ def _int_option(value: Any) -> int | None:
105
+ if value is None or value == "":
106
+ return None
107
+ try:
108
+ return int(value)
109
+ except (TypeError, ValueError):
110
+ return None
111
+
112
+
113
+ def _bool_option(value: Any) -> bool | None:
114
+ if value is None or value == "":
115
+ return None
116
+ if isinstance(value, bool):
117
+ return value
118
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
119
+
120
+
121
+ def search_options(config: dict[str, Any] | None = None) -> SearchOptions:
122
+ section = tinyfish_config(config).get("search") or {}
123
+ if not isinstance(section, dict):
124
+ return {}
125
+ options: SearchOptions = {}
126
+ for key in (
127
+ "location",
128
+ "language",
129
+ "after_date",
130
+ "before_date",
131
+ "domain_type",
132
+ "purpose",
133
+ ):
134
+ value = section.get(key)
135
+ if value not in (None, ""):
136
+ options[key] = str(value)
137
+ for key in ("recency_minutes", "page"):
138
+ value = _int_option(section.get(key))
139
+ if value is not None:
140
+ options[key] = value
141
+ return options
142
+
143
+
144
+ def fetch_options(config: dict[str, Any] | None = None) -> FetchOptions:
145
+ section = tinyfish_config(config).get("fetch") or {}
146
+ if not isinstance(section, dict):
147
+ return {}
148
+ options: FetchOptions = {}
149
+ for key in ("ttl", "per_url_timeout_ms"):
150
+ value = _int_option(section.get(key))
151
+ if value is not None:
152
+ options[key] = value
153
+ for key in ("links", "image_links"):
154
+ value = _bool_option(section.get(key))
155
+ if value is not None:
156
+ options[key] = value
157
+ return options
158
+
159
+
160
+ def default_fetch_format(config: dict[str, Any] | None = None) -> str:
161
+ section = tinyfish_config(config).get("fetch") or {}
162
+ if isinstance(section, dict) and section.get("format"):
163
+ return str(section["format"])
164
+ return "markdown"