diff --git a/.github/workflows/hermes-ci.yml b/.github/workflows/hermes-ci.yml index 4da9316..1e35023 100644 --- a/.github/workflows/hermes-ci.yml +++ b/.github/workflows/hermes-ci.yml @@ -32,6 +32,25 @@ jobs: with: python-version: "3.12" + # plugin.yaml is authoritative for X-TF-Client-Version on directory installs, + # so pyproject.toml drifting past it ships a stale version header silently. + - name: plugin.yaml version matches pyproject.toml + run: | + set -euo pipefail + manifest=$(sed -n 's/^version:[[:space:]]*//p' plugin.yaml | head -1 | tr -d "\"' ") + project=$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\(.*\)"/\1/p' pyproject.toml | head -1) + echo "plugin.yaml=$manifest pyproject.toml=$project" + # Both parsers are regex-based: an unparsed file yields "" on both sides + # and would compare equal, passing the check it is meant to fail. + if [ -z "$manifest" ] || [ -z "$project" ]; then + echo "::error file=hermes/plugin.yaml::could not parse a version from plugin.yaml or pyproject.toml" + exit 1 + fi + if [ "$manifest" != "$project" ]; then + echo "::error file=hermes/plugin.yaml::plugin.yaml is $manifest but pyproject.toml is $project" + exit 1 + fi + - run: python -m pip install --upgrade pip - run: python -m pip install -e . -r requirements-dev.txt diff --git a/README.md b/README.md index fa5e631..511f4f1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,17 @@ TinyFish Web Agent provides AI-powered web automation using natural language ins Want to contribute to these integrations? We love adding TinyFish Web Agent to new ecosystems! Check out individual integration directories for setup and development instructions. +## Identifying your integration to TinyFish + +Every integration that calls the REST API directly must send two headers on every request. Today `hermes` and `n8n` do; `dify` does not yet. + +| header | value | +|---|---| +| `X-TF-Client-Name` | the bare integration token — the same value used for `api_integration` where one exists: `hermes`, `n8n` | +| `X-TF-Client-Version` | the integration's own published version | + +Do not send `X-TF-Request-Origin` yourself; `api` is the transport and the server derives it, and the `tinyfish` SDK already sets its own value. Integrations that wrap the SDK cannot send the two headers today — `tinyfish==0.2.5` builds a fixed header set with no passthrough and ignores `TF_CLIENT_NAME` / `TF_CLIENT_VERSION` — so `langchain` and `google-adk` identify with `TF_API_INTEGRATION` alone until the SDK exposes them. Without any of this, telemetry cannot tell the integration from a hand-written curl. + ## License These integrations are licensed under the MIT License. diff --git a/hermes/.gitignore b/hermes/.gitignore index 3ace3ce..d8ec8f0 100644 --- a/hermes/.gitignore +++ b/hermes/.gitignore @@ -10,3 +10,4 @@ __pycache__/ .mypy_cache/ .ruff_cache/ .pytest_cache/ +uv.lock diff --git a/hermes/plugin.yaml b/hermes/plugin.yaml index 2b045c5..5e0a647 100644 --- a/hermes/plugin.yaml +++ b/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: tinyfish -version: 0.1.0 +version: 0.1.1 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." author: TinyFish kind: backend diff --git a/hermes/pyproject.toml b/hermes/pyproject.toml index 69ddf58..0088e02 100644 --- a/hermes/pyproject.toml +++ b/hermes/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tinyfish-hermes" -version = "0.1.0" +version = "0.1.1" description = "TinyFish web provider plugin for Hermes Agent - Search and Fetch over the TinyFish REST APIs" readme = "README.md" license = { text = "MIT" } diff --git a/hermes/tests/test_rest_client.py b/hermes/tests/test_rest_client.py index 81a3685..3b78f9a 100644 --- a/hermes/tests/test_rest_client.py +++ b/hermes/tests/test_rest_client.py @@ -8,6 +8,13 @@ from tinyfish_hermes import rest_client +_AUTH_HEADERS = { + "X-API-Key": "tf_test", + "Accept": "application/json", + "X-TF-Client-Name": "hermes", + "X-TF-Client-Version": "0.1.1", +} + def _response( method: str, @@ -61,7 +68,7 @@ def fake_get(url: str, **kwargs: Any) -> httpx.Response: "page": 2, "purpose": "research", }, - "headers": {"X-API-Key": "tf_test", "Accept": "application/json"}, + "headers": _AUTH_HEADERS, "timeout": 12.5, } @@ -115,11 +122,7 @@ def fake_post(url: str, **kwargs: Any) -> httpx.Response: "ttl": 300, "per_url_timeout_ms": 2500, }, - "headers": { - "X-API-Key": "tf_test", - "Accept": "application/json", - "Content-Type": "application/json", - }, + "headers": {**_AUTH_HEADERS, "Content-Type": "application/json"}, "timeout": 22.0, } @@ -283,7 +286,7 @@ def fake_delete(url: str, **kwargs: Any) -> httpx.Response: is True ) assert captured["url"] == f"{rest_client.BROWSER_URL}/sess_123" - assert captured["headers"] == {"X-API-Key": "tf_test", "Accept": "application/json"} + assert captured["headers"] == _AUTH_HEADERS assert captured["timeout"] == 9.0 @@ -424,7 +427,7 @@ def fake_get(url: str, **kwargs: Any) -> httpx.Response: assert call() == {"items": []} assert captured == { "url": expected_url, - "headers": {"X-API-Key": "tf_test", "Accept": "application/json"}, + "headers": _AUTH_HEADERS, "timeout": 11.0, } diff --git a/hermes/tests/test_version.py b/hermes/tests/test_version.py index 1fbf6e3..5ef69b2 100644 --- a/hermes/tests/test_version.py +++ b/hermes/tests/test_version.py @@ -8,7 +8,7 @@ import tinyfish_hermes as plugin -def test_version_prefers_installed_distribution_metadata( +def test_version_prefers_adjacent_manifest_over_stale_metadata( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: manifest = tmp_path / "plugin.yaml" @@ -16,6 +16,15 @@ def test_version_prefers_installed_distribution_metadata( monkeypatch.setattr(plugin, "_PLUGIN_MANIFEST", manifest) monkeypatch.setattr(plugin.metadata, "version", lambda name: "9.8.7") + assert plugin._resolve_version() == "1.2.3" + + +def test_version_uses_installed_metadata_when_no_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(plugin, "_PLUGIN_MANIFEST", tmp_path / "missing.yaml") + monkeypatch.setattr(plugin.metadata, "version", lambda name: "9.8.7") + assert plugin._resolve_version() == "9.8.7" @@ -53,3 +62,34 @@ def test_public_version_is_exported() -> None: assert isinstance(plugin.__version__, str) assert plugin.__version__ assert "__version__" in plugin.__all__ + + +def test_version_rejects_non_ascii_manifest_value( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + manifest = tmp_path / "plugin.yaml" + manifest.write_text("version: 0.1.1é\n", encoding="utf-8") + monkeypatch.setattr(plugin, "_PLUGIN_MANIFEST", manifest) + + def missing_distribution(name: str) -> str: + raise metadata.PackageNotFoundError(name) + + monkeypatch.setattr(plugin.metadata, "version", missing_distribution) + + assert plugin._resolve_version() == "0+unknown" + + +def test_version_degrades_to_metadata_when_manifest_is_non_ascii( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + manifest = tmp_path / "plugin.yaml" + manifest.write_text("version: 0.1.1é\n", encoding="utf-8") + monkeypatch.setattr(plugin, "_PLUGIN_MANIFEST", manifest) + monkeypatch.setattr(plugin.metadata, "version", lambda name: "9.8.7") + + assert plugin._resolve_version() == "9.8.7" + + +def test_manifest_version_matches_the_pinned_header_value() -> None: + manifest = Path(plugin.__file__).resolve().parents[1] / "plugin.yaml" + assert plugin._version_from_plugin_manifest(manifest) == "0.1.1" diff --git a/hermes/tinyfish_hermes/__init__.py b/hermes/tinyfish_hermes/__init__.py index 6c4db0e..81e8046 100644 --- a/hermes/tinyfish_hermes/__init__.py +++ b/hermes/tinyfish_hermes/__init__.py @@ -24,12 +24,21 @@ def _version_from_plugin_manifest(path: Path | None = None) -> str | None: return None -def _resolve_version() -> str: +def _version_from_metadata() -> str | None: try: - installed_version = metadata.version(_DISTRIBUTION_NAME) + return metadata.version(_DISTRIBUTION_NAME) except Exception: # directory installs must not depend on package metadata - installed_version = "" - return installed_version or _version_from_plugin_manifest() or "0+unknown" + return None + + +def _resolve_version() -> str: + # Wheels omit plugin.yaml: an adjacent manifest beats stale dist metadata. + # httpx sends headers as ASCII, so one stray byte fails every request; a + # garbled source degrades to the next candidate, not straight to the fallback. + for candidate in (_version_from_plugin_manifest(), _version_from_metadata()): + if candidate and candidate.isascii(): + return candidate + return "0+unknown" __version__ = _resolve_version() diff --git a/hermes/tinyfish_hermes/rest_client.py b/hermes/tinyfish_hermes/rest_client.py index f71ce9a..5579f04 100644 --- a/hermes/tinyfish_hermes/rest_client.py +++ b/hermes/tinyfish_hermes/rest_client.py @@ -8,6 +8,8 @@ import httpx +from . import __version__ + SEARCH_URL = "https://api.search.tinyfish.ai" FETCH_URL = "https://api.fetch.tinyfish.ai" FETCH_MAX_URLS = 10 @@ -31,7 +33,13 @@ class TinyFishWalletNotFound(TinyFishRestError): def _headers(api_key: str) -> dict[str, str]: - return {"X-API-Key": api_key, "Accept": "application/json"} + # Without these, telemetry cannot tell the plugin from raw curl. + return { + "X-API-Key": api_key, + "Accept": "application/json", + "X-TF-Client-Name": "hermes", + "X-TF-Client-Version": __version__, + } def _json_headers(api_key: str) -> dict[str, str]: