fastaf-sdk 0.1.0__tar.gz

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,80 @@
1
+ Metadata-Version: 2.5
2
+ Name: fastaf-sdk
3
+ Version: 0.1.0
4
+ Summary: FastAF API client and optional agent tools
5
+ Project-URL: Homepage, https://fastaf.co
6
+ Project-URL: Repository, https://github.com/fastaf-co/fastaf-sdk
7
+ Author-email: FastAF <dev@fastaf.co>
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx<1,>=0.27
10
+ Provides-Extra: crewai
11
+ Requires-Dist: crewai<2,>=1; extra == 'crewai'
12
+ Provides-Extra: langchain
13
+ Requires-Dist: langchain-core<2,>=1; extra == 'langchain'
14
+ Provides-Extra: llamaindex
15
+ Requires-Dist: llama-index-core<0.15,>=0.14; extra == 'llamaindex'
16
+ Provides-Extra: test
17
+ Requires-Dist: build<2,>=1; extra == 'test'
18
+ Requires-Dist: pytest<10,>=8; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # fastaf-sdk
22
+
23
+ [FastAF](https://fastaf.co) · Docs: https://fastaf.co/docs/
24
+
25
+
26
+ Distribution name: **fastaf-sdk**. Import: **fastaf**. The unrelated `fastaf`
27
+ PyPI distribution is not this SDK. Nothing here has been published.
28
+
29
+ Python 3.10+. Local installation (from this directory):
30
+
31
+ ```sh
32
+ pip install -e .
33
+ # Install only integrations you use:
34
+ pip install -e '.[langchain,llamaindex,crewai]'
35
+ ```
36
+
37
+ ```python
38
+ import os
39
+ from fastaf import FastAF, FastAFError
40
+
41
+ with FastAF(os.environ["FASTAF_API_KEY"]) as client:
42
+ data = client.search("best espresso machine", results=5)
43
+ markdown = client.request("scrape", {"url": "https://example.com", "formats": "markdown"})
44
+ data = client.request("serp", {"query": "coffee"}, method="POST")
45
+ ```
46
+
47
+ `request(endpoint, params)` covers the live `/v1/*` endpoints. Returns decoded
48
+ JSON, text, or bytes for images. Keys stay in Authorization headers. Errors
49
+ raise `FastAFError` with `status`, `body`, and `headers` (including Retry-After).
50
+ Network/timeout exceptions remain httpx exceptions. Default timeout: 60 seconds;
51
+ override `timeout=30`. There are no automatic retries, redirects or x402 payments.
52
+ A 402 on keyed REST means insufficient credits, not permission to spend a wallet.
53
+ Close the client or use its context manager. Do not put keys in browser code.
54
+
55
+ ## Agent tools
56
+
57
+ ```python
58
+ from fastaf.tools import langchain_tool, llamaindex_tool, crewai_tool
59
+
60
+ # Use inside the client's lifetime. Each accepts the same FastAF client.
61
+ search = langchain_tool(client) # StructuredTool
62
+ search = llamaindex_tool(client) # FunctionTool
63
+ search = crewai_tool(client) # CrewAI tool
64
+ ```
65
+
66
+ Tools accept `query` and `results` (1-10, default 5). They return JSON strings;
67
+ credentials are not part of agent-visible schemas. Each call consumes API
68
+ credits, so configure agent step limits. Integrations import lazily.
69
+
70
+ ## Tests and local packaging
71
+
72
+ ```sh
73
+ pip install -e '.[test,langchain,llamaindex,crewai]'
74
+ pytest
75
+ python -m build
76
+ ```
77
+
78
+ Tests use httpx.MockTransport, never real keys or billable calls. Without optional
79
+ frameworks, their integration tests skip; install all extras to run the full suite.
80
+ The private distribution classifier deliberately blocks uploading to PyPI.
@@ -0,0 +1,60 @@
1
+ # fastaf-sdk
2
+
3
+ [FastAF](https://fastaf.co) · Docs: https://fastaf.co/docs/
4
+
5
+
6
+ Distribution name: **fastaf-sdk**. Import: **fastaf**. The unrelated `fastaf`
7
+ PyPI distribution is not this SDK. Nothing here has been published.
8
+
9
+ Python 3.10+. Local installation (from this directory):
10
+
11
+ ```sh
12
+ pip install -e .
13
+ # Install only integrations you use:
14
+ pip install -e '.[langchain,llamaindex,crewai]'
15
+ ```
16
+
17
+ ```python
18
+ import os
19
+ from fastaf import FastAF, FastAFError
20
+
21
+ with FastAF(os.environ["FASTAF_API_KEY"]) as client:
22
+ data = client.search("best espresso machine", results=5)
23
+ markdown = client.request("scrape", {"url": "https://example.com", "formats": "markdown"})
24
+ data = client.request("serp", {"query": "coffee"}, method="POST")
25
+ ```
26
+
27
+ `request(endpoint, params)` covers the live `/v1/*` endpoints. Returns decoded
28
+ JSON, text, or bytes for images. Keys stay in Authorization headers. Errors
29
+ raise `FastAFError` with `status`, `body`, and `headers` (including Retry-After).
30
+ Network/timeout exceptions remain httpx exceptions. Default timeout: 60 seconds;
31
+ override `timeout=30`. There are no automatic retries, redirects or x402 payments.
32
+ A 402 on keyed REST means insufficient credits, not permission to spend a wallet.
33
+ Close the client or use its context manager. Do not put keys in browser code.
34
+
35
+ ## Agent tools
36
+
37
+ ```python
38
+ from fastaf.tools import langchain_tool, llamaindex_tool, crewai_tool
39
+
40
+ # Use inside the client's lifetime. Each accepts the same FastAF client.
41
+ search = langchain_tool(client) # StructuredTool
42
+ search = llamaindex_tool(client) # FunctionTool
43
+ search = crewai_tool(client) # CrewAI tool
44
+ ```
45
+
46
+ Tools accept `query` and `results` (1-10, default 5). They return JSON strings;
47
+ credentials are not part of agent-visible schemas. Each call consumes API
48
+ credits, so configure agent step limits. Integrations import lazily.
49
+
50
+ ## Tests and local packaging
51
+
52
+ ```sh
53
+ pip install -e '.[test,langchain,llamaindex,crewai]'
54
+ pytest
55
+ python -m build
56
+ ```
57
+
58
+ Tests use httpx.MockTransport, never real keys or billable calls. Without optional
59
+ frameworks, their integration tests skip; install all extras to run the full suite.
60
+ The private distribution classifier deliberately blocks uploading to PyPI.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fastaf-sdk"
7
+ version = "0.1.0"
8
+ description = "FastAF API client and optional agent tools"
9
+ authors = [{name = "FastAF", email = "dev@fastaf.co"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["httpx>=0.27,<1"]
13
+
14
+ [project.optional-dependencies]
15
+ langchain = ["langchain-core>=1,<2"]
16
+ llamaindex = ["llama-index-core>=0.14,<0.15"]
17
+ crewai = ["crewai>=1,<2"]
18
+ test = ["pytest>=8,<10", "build>=1,<2"]
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/fastaf"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://fastaf.co"
28
+ Repository = "https://github.com/fastaf-co/fastaf-sdk"
@@ -0,0 +1,4 @@
1
+ """Install fastaf-sdk, import fastaf. No optional agent dependencies at import time."""
2
+ from .client import FastAF, FastAFError
3
+
4
+ __all__ = ["FastAF", "FastAFError"]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Mapping
5
+ from urllib.parse import urlsplit
6
+
7
+ import httpx
8
+
9
+ Params = Mapping[str, str | int | float | bool | None]
10
+
11
+
12
+ class FastAFError(Exception):
13
+ """HTTP failure, including quota 402; no automatic retry or payment."""
14
+
15
+ def __init__(self, response: httpx.Response) -> None:
16
+ super().__init__(f"FastAF request failed ({response.status_code})")
17
+ self.status = response.status_code
18
+ self.body = _decode(response)
19
+ self.headers = response.headers
20
+
21
+
22
+ def _decode(response: httpx.Response) -> Any:
23
+ content_type = response.headers.get("content-type", "").split(";")[0].strip()
24
+ if content_type == "application/json" or content_type.endswith("+json"):
25
+ try:
26
+ return response.json()
27
+ except ValueError:
28
+ return response.text
29
+ if content_type.startswith("image/") or content_type == "application/octet-stream":
30
+ return response.content
31
+ return response.text
32
+
33
+
34
+ class FastAF:
35
+ """Synchronous httpx client. Owns its connection pool; use as a context manager.
36
+
37
+ Optional transports support mocking/proxies without changing authentication.
38
+ Redirects are not followed and failed calls are never automatically retried.
39
+ """
40
+
41
+ def __init__(self, api_key: str, *, base_url: str = "https://api.fastaf.co",
42
+ timeout: float = 60.0, transport: httpx.BaseTransport | None = None) -> None:
43
+ if not api_key or re.search(r"\s", api_key):
44
+ raise ValueError("A nonempty API key is required")
45
+ url = urlsplit(base_url)
46
+ if (url.scheme not in {"https", "http"} or not url.hostname or url.username or url.password
47
+ or url.path not in {"", "/"} or url.query or url.fragment):
48
+ raise ValueError("base_url must be an HTTP(S) origin without credentials, path, query or fragment")
49
+ if timeout <= 0:
50
+ raise ValueError("timeout must be positive")
51
+ self._http = httpx.Client(base_url=base_url, headers={"Authorization": f"Bearer {api_key}"},
52
+ timeout=timeout, transport=transport, follow_redirects=False)
53
+
54
+ def request(self, endpoint: str, params: Params | None = None, *, method: str = "GET") -> Any:
55
+ if not re.fullmatch(r"[a-z][a-z0-9_-]*", endpoint):
56
+ raise ValueError("endpoint must be a single endpoint name, not a URL or path")
57
+ if method not in {"GET", "POST"}:
58
+ raise ValueError("Use GET or POST")
59
+ values = {key: value for key, value in (params or {}).items() if value is not None}
60
+ kwargs = {"params": values} if method == "GET" else {"json": values}
61
+ response = self._http.request(method, f"/v1/{endpoint}", **kwargs)
62
+ if not response.is_success:
63
+ raise FastAFError(response)
64
+ return _decode(response)
65
+
66
+ def search(self, query: str, **params: str | int | float | bool | None) -> Any:
67
+ return self.request("serp", {**params, "query": query})
68
+
69
+ def close(self) -> None:
70
+ self._http.close()
71
+
72
+ def __enter__(self) -> FastAF:
73
+ return self
74
+
75
+ def __exit__(self, *_: Any) -> None:
76
+ self.close()
@@ -0,0 +1,39 @@
1
+ """Optional framework factories. Credentials live in closures, never tool schemas."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import TYPE_CHECKING, Callable
6
+
7
+ if TYPE_CHECKING:
8
+ from .client import FastAF
9
+
10
+ DESCRIPTION = "Search the public web with FastAF for source links and snippets. Each call spends API credits."
11
+
12
+
13
+ def _search(client: FastAF) -> Callable:
14
+ def fastaf_search(query: str, results: int = 5) -> str:
15
+ """Search the public web. query: nonempty search text; results: 1-10."""
16
+ if not query.strip() or len(query) > 2000:
17
+ raise ValueError("query must contain 1-2000 characters")
18
+ if isinstance(results, bool) or not isinstance(results, int) or not 1 <= results <= 10:
19
+ raise ValueError("results must be an integer from 1 to 10")
20
+ return json.dumps(client.search(query, results=results), ensure_ascii=False)
21
+ return fastaf_search
22
+
23
+
24
+ def langchain_tool(client: FastAF):
25
+ """Requires fastaf-sdk[langchain]."""
26
+ from langchain_core.tools import StructuredTool
27
+ return StructuredTool.from_function(_search(client), name="fastaf_search", description=DESCRIPTION)
28
+
29
+
30
+ def llamaindex_tool(client: FastAF):
31
+ """Requires fastaf-sdk[llamaindex]."""
32
+ from llama_index.core.tools import FunctionTool
33
+ return FunctionTool.from_defaults(fn=_search(client), name="fastaf_search", description=DESCRIPTION)
34
+
35
+
36
+ def crewai_tool(client: FastAF):
37
+ """Requires fastaf-sdk[crewai]."""
38
+ from crewai.tools import tool
39
+ return tool("fastaf_search")(_search(client))
@@ -0,0 +1,5 @@
1
+ """Prevent optional frameworks from sending test telemetry."""
2
+ import os
3
+ os.environ["OTEL_SDK_DISABLED"] = "true"
4
+ os.environ["CREWAI_TRACING_ENABLED"] = "false"
5
+ os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
@@ -0,0 +1,78 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from fastaf import FastAF, FastAFError
7
+
8
+ KEY = "test-key-not-a-secret"
9
+
10
+
11
+ def test_get_encoding_and_auth():
12
+ def handler(request):
13
+ assert request.url.path == "/v1/serp"
14
+ assert request.url.params["query"] == "coffee & tè"
15
+ assert request.url.params["html"] == "false"
16
+ assert "omit" not in request.url.params
17
+ assert request.headers["Authorization"] == f"Bearer {KEY}"
18
+ assert request.extensions["timeout"]["read"] == 60
19
+ return httpx.Response(200, json={"ok": True})
20
+ with FastAF(KEY, transport=httpx.MockTransport(handler)) as client:
21
+ assert client.search("coffee & tè", html=False, omit=None) == {"ok": True}
22
+ assert client._http.is_closed
23
+
24
+
25
+ def test_post_text():
26
+ def handler(request):
27
+ assert request.method == "POST"
28
+ assert not request.url.query
29
+ assert json.loads(request.content) == {"url": "https://example.com", "dynamic": True}
30
+ return httpx.Response(200, text="# Title", headers={"Content-Type": "text/markdown"})
31
+ with FastAF(KEY, transport=httpx.MockTransport(handler)) as client:
32
+ assert client.request("scrape", {"url": "https://example.com", "dynamic": True}, method="POST") == "# Title"
33
+
34
+
35
+ def test_binary():
36
+ with FastAF(KEY, transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"png", headers={"Content-Type": "image/png"}))) as client:
37
+ assert client.request("screenshot") == b"png"
38
+
39
+
40
+ @pytest.mark.parametrize("status", [302, 401, 402, 429, 503])
41
+ def test_errors_and_no_retry_or_redirect(status):
42
+ calls = []
43
+ def handler(request):
44
+ calls.append(request)
45
+ return httpx.Response(status, json={"error": "test"}, headers={"Retry-After": "3", "Location": "https://evil.test"})
46
+ with FastAF(KEY, transport=httpx.MockTransport(handler)) as client:
47
+ with pytest.raises(FastAFError) as error:
48
+ client.search("coffee")
49
+ assert error.value.status == status
50
+ assert error.value.body == {"error": "test"}
51
+ assert error.value.headers["Retry-After"] == "3"
52
+ assert KEY not in str(error.value)
53
+ assert len(calls) == 1
54
+
55
+
56
+ @pytest.mark.parametrize("endpoint", ["../usage", "/serp", "https://evil.test", "serp?x=y", "serp#x"])
57
+ def test_endpoint_injection(endpoint):
58
+ with FastAF(KEY, transport=httpx.MockTransport(lambda _: pytest.fail("must not fetch"))) as client:
59
+ with pytest.raises(ValueError):
60
+ client.request(endpoint)
61
+
62
+
63
+ @pytest.mark.parametrize("base", ["https://user:pass@example.com", "https://example.com/path", "https://example.com?key=x", "file:///tmp"])
64
+ def test_origin_injection(base):
65
+ with pytest.raises(ValueError):
66
+ FastAF(KEY, base_url=base)
67
+
68
+
69
+ def test_timeout_and_invalid_json():
70
+ def timeout(request):
71
+ raise httpx.ReadTimeout("mock timeout", request=request)
72
+ with FastAF(KEY, transport=httpx.MockTransport(timeout)) as client:
73
+ with pytest.raises(httpx.ReadTimeout):
74
+ client.search("coffee")
75
+ with FastAF(KEY, transport=httpx.MockTransport(lambda _: httpx.Response(502, text="not-json", headers={"Content-Type": "application/json"}))) as client:
76
+ with pytest.raises(FastAFError) as error:
77
+ client.search("coffee")
78
+ assert error.value.body == "not-json"
@@ -0,0 +1,48 @@
1
+ import json
2
+ import httpx
3
+ import pytest
4
+ from fastaf import FastAF
5
+ from fastaf.tools import _search, langchain_tool, llamaindex_tool, crewai_tool
6
+
7
+
8
+ @pytest.fixture
9
+ def client():
10
+ def handler(request):
11
+ assert request.url.path == "/v1/serp"
12
+ assert request.url.params["query"] == "coffee"
13
+ assert request.url.params["results"] == "5"
14
+ return httpx.Response(200, json={"organic_results": []})
15
+ with FastAF("test-key", transport=httpx.MockTransport(handler)) as client:
16
+ yield client
17
+
18
+
19
+ def test_search_validation(client):
20
+ search = _search(client)
21
+ for query, results in [("", 5), ("coffee", 0), ("coffee", 11), ("coffee", True)]:
22
+ with pytest.raises(ValueError):
23
+ search(query, results)
24
+ assert json.loads(search("coffee")) == {"organic_results": []}
25
+
26
+
27
+ def test_langchain(client):
28
+ pytest.importorskip("langchain_core")
29
+ tool = langchain_tool(client)
30
+ assert tool.name == "fastaf_search"
31
+ assert json.loads(tool.invoke({"query": "coffee"})) == {"organic_results": []}
32
+ assert "api_key" not in tool.args
33
+
34
+
35
+ def test_llamaindex(client):
36
+ pytest.importorskip("llama_index.core")
37
+ tool = llamaindex_tool(client)
38
+ assert tool.metadata.name == "fastaf_search"
39
+ assert json.loads(tool.call(query="coffee").content) == {"organic_results": []}
40
+ assert "api_key" not in tool.metadata.get_parameters_dict()["properties"]
41
+
42
+
43
+ def test_crewai(client):
44
+ pytest.importorskip("crewai")
45
+ tool = crewai_tool(client)
46
+ assert tool.name == "fastaf_search"
47
+ assert json.loads(tool.run(query="coffee")) == {"organic_results": []}
48
+ assert "api_key" not in tool.args_schema.model_json_schema()["properties"]