langchain-xdataapi 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,9 @@
1
+ node_modules/
2
+ dist/
3
+ .venv/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ *.tgz
8
+ uv.lock
9
+ package-lock.json
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xdataapi.io
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.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-xdataapi
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for xdataapi.io: read-only public X (Twitter) profiles, tweets, threads, followers and search.
5
+ Project-URL: Homepage, https://xdataapi.io
6
+ Project-URL: Documentation, https://xdataapi.io/docs
7
+ Project-URL: API reference, https://xdataapi.io/reference
8
+ Project-URL: Source code, https://github.com/xdataapi/xdataapi
9
+ Project-URL: Issue tracker, https://github.com/xdataapi/xdataapi/issues
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,langchain,langgraph,llm,mcp,tools,twitter,twitter-api,x,x-api,xdataapi
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx<0.29.0,>=0.27.0
19
+ Requires-Dist: langchain-core<2.0.0,>=0.3.0
20
+ Requires-Dist: pydantic<3.0.0,>=2.7.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # langchain-xdataapi
24
+
25
+ LangChain tools for [xdataapi.io](https://xdataapi.io): read-only public X (Twitter) data.
26
+ Profiles, posts, threads, replies, quotes, reposters, followers, and search with the x.com operators.
27
+
28
+ Read-only by design. No posting, no likes, no direct messages, no private data.
29
+ Not affiliated with X Corp.
30
+
31
+ ```bash
32
+ pip install langchain-xdataapi
33
+ ```
34
+
35
+ ## Use
36
+
37
+ ```python
38
+ from langchain_xdataapi import XdataapiToolkit
39
+
40
+ # Reads XDATAAPI_KEY from the environment.
41
+ tools = XdataapiToolkit.from_api_key().get_tools()
42
+ ```
43
+
44
+ With an agent:
45
+
46
+ ```python
47
+ from langchain.agents import create_agent
48
+ from langchain_xdataapi import XdataapiToolkit
49
+
50
+ agent = create_agent(
51
+ "anthropic:claude-opus-5",
52
+ tools=XdataapiToolkit.from_api_key(max_results=25).get_tools(),
53
+ )
54
+ result = agent.invoke({"messages": [{"role": "user", "content": "What is @vercel posting about this week?"}]})
55
+ ```
56
+
57
+ One tool on its own:
58
+
59
+ ```python
60
+ from langchain_xdataapi import SearchTweets, XdataapiClient
61
+
62
+ search = SearchTweets(client=XdataapiClient())
63
+ print(search.invoke({"q": "from:vercel since:2026-09-01", "count": 10}))
64
+ ```
65
+
66
+ ## Tools
67
+
68
+ | Tool | Reads | Credits |
69
+ |---|---|---|
70
+ | `x_search_tweets` | Search with the x.com operators | 1 per result |
71
+ | `x_get_user` | One profile | 1 |
72
+ | `x_get_users` | Up to 100 profiles in one call | 1 per profile |
73
+ | `x_get_user_tweets` | Latest posts of a user | 1 per post |
74
+ | `x_get_tweet` | One post | 1 |
75
+ | `x_get_tweets` | Up to 100 posts in one call | 1 per post |
76
+ | `x_get_thread` | A post with its thread and replies | 1 per post |
77
+ | `x_get_replies` | Direct replies to a post | 1 per reply |
78
+ | `x_get_quotes` | Posts that quote a post | 1 per post |
79
+ | `x_get_retweeters` | Accounts that reposted a post | 0.5 per profile |
80
+ | `x_get_followers` | Followers, as profiles | 0.1 per profile |
81
+ | `x_get_follower_ids` | Follower ids only | 0.02 per id |
82
+ | `x_get_following` | Accounts a user follows | 0.1 per profile |
83
+ | `x_get_balance` | Credits left and rate limit | free |
84
+
85
+ Empty results and failed requests cost nothing. A repeated read inside the cache window costs half.
86
+ Every response carries `credits_charged` and `balance_remaining`, so the model can see the budget shrink
87
+ and stop on its own.
88
+
89
+ ## Spending guards
90
+
91
+ `max_results` caps the page size of every paging tool, so one agent step cannot ask for more than you allow:
92
+
93
+ ```python
94
+ toolkit = XdataapiToolkit.from_api_key(max_results=25)
95
+ ```
96
+
97
+ Narrow the surface with `include` or `exclude`:
98
+
99
+ ```python
100
+ toolkit = XdataapiToolkit.from_api_key(include=["x_search_tweets", "x_get_user", "x_get_balance"])
101
+ ```
102
+
103
+ ## Errors
104
+
105
+ A failure the model can act on — no credits, not found, a search product X refused — comes back as JSON
106
+ with `error.code`, so the model can change plan. None of those are charged.
107
+ A missing or revoked key raises `XdataapiAuthError`, because no model can repair it.
108
+ Rate limits and upstream faults raise `XdataapiError`, so your framework's retry policy sees them.
109
+
110
+ ## MCP instead
111
+
112
+ An agent that speaks MCP needs no package. Connect the hosted server:
113
+
114
+ ```bash
115
+ claude mcp add --transport http xdataapi https://api.xdataapi.io/mcp
116
+ ```
117
+
118
+ It signs in with OAuth on first use. It is on the official MCP Registry as `io.xdataapi/xdataapi`.
119
+
120
+ ## Keys and pricing
121
+
122
+ Get a key at [xdataapi.io/dashboard](https://xdataapi.io/dashboard). 5,000 credits to start, no card.
123
+ Prepaid packs from $10; credits never expire. Live status: [xdataapi.io/status](https://xdataapi.io/status).
124
+
125
+ MIT licensed.
@@ -0,0 +1,103 @@
1
+ # langchain-xdataapi
2
+
3
+ LangChain tools for [xdataapi.io](https://xdataapi.io): read-only public X (Twitter) data.
4
+ Profiles, posts, threads, replies, quotes, reposters, followers, and search with the x.com operators.
5
+
6
+ Read-only by design. No posting, no likes, no direct messages, no private data.
7
+ Not affiliated with X Corp.
8
+
9
+ ```bash
10
+ pip install langchain-xdataapi
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```python
16
+ from langchain_xdataapi import XdataapiToolkit
17
+
18
+ # Reads XDATAAPI_KEY from the environment.
19
+ tools = XdataapiToolkit.from_api_key().get_tools()
20
+ ```
21
+
22
+ With an agent:
23
+
24
+ ```python
25
+ from langchain.agents import create_agent
26
+ from langchain_xdataapi import XdataapiToolkit
27
+
28
+ agent = create_agent(
29
+ "anthropic:claude-opus-5",
30
+ tools=XdataapiToolkit.from_api_key(max_results=25).get_tools(),
31
+ )
32
+ result = agent.invoke({"messages": [{"role": "user", "content": "What is @vercel posting about this week?"}]})
33
+ ```
34
+
35
+ One tool on its own:
36
+
37
+ ```python
38
+ from langchain_xdataapi import SearchTweets, XdataapiClient
39
+
40
+ search = SearchTweets(client=XdataapiClient())
41
+ print(search.invoke({"q": "from:vercel since:2026-09-01", "count": 10}))
42
+ ```
43
+
44
+ ## Tools
45
+
46
+ | Tool | Reads | Credits |
47
+ |---|---|---|
48
+ | `x_search_tweets` | Search with the x.com operators | 1 per result |
49
+ | `x_get_user` | One profile | 1 |
50
+ | `x_get_users` | Up to 100 profiles in one call | 1 per profile |
51
+ | `x_get_user_tweets` | Latest posts of a user | 1 per post |
52
+ | `x_get_tweet` | One post | 1 |
53
+ | `x_get_tweets` | Up to 100 posts in one call | 1 per post |
54
+ | `x_get_thread` | A post with its thread and replies | 1 per post |
55
+ | `x_get_replies` | Direct replies to a post | 1 per reply |
56
+ | `x_get_quotes` | Posts that quote a post | 1 per post |
57
+ | `x_get_retweeters` | Accounts that reposted a post | 0.5 per profile |
58
+ | `x_get_followers` | Followers, as profiles | 0.1 per profile |
59
+ | `x_get_follower_ids` | Follower ids only | 0.02 per id |
60
+ | `x_get_following` | Accounts a user follows | 0.1 per profile |
61
+ | `x_get_balance` | Credits left and rate limit | free |
62
+
63
+ Empty results and failed requests cost nothing. A repeated read inside the cache window costs half.
64
+ Every response carries `credits_charged` and `balance_remaining`, so the model can see the budget shrink
65
+ and stop on its own.
66
+
67
+ ## Spending guards
68
+
69
+ `max_results` caps the page size of every paging tool, so one agent step cannot ask for more than you allow:
70
+
71
+ ```python
72
+ toolkit = XdataapiToolkit.from_api_key(max_results=25)
73
+ ```
74
+
75
+ Narrow the surface with `include` or `exclude`:
76
+
77
+ ```python
78
+ toolkit = XdataapiToolkit.from_api_key(include=["x_search_tweets", "x_get_user", "x_get_balance"])
79
+ ```
80
+
81
+ ## Errors
82
+
83
+ A failure the model can act on — no credits, not found, a search product X refused — comes back as JSON
84
+ with `error.code`, so the model can change plan. None of those are charged.
85
+ A missing or revoked key raises `XdataapiAuthError`, because no model can repair it.
86
+ Rate limits and upstream faults raise `XdataapiError`, so your framework's retry policy sees them.
87
+
88
+ ## MCP instead
89
+
90
+ An agent that speaks MCP needs no package. Connect the hosted server:
91
+
92
+ ```bash
93
+ claude mcp add --transport http xdataapi https://api.xdataapi.io/mcp
94
+ ```
95
+
96
+ It signs in with OAuth on first use. It is on the official MCP Registry as `io.xdataapi/xdataapi`.
97
+
98
+ ## Keys and pricing
99
+
100
+ Get a key at [xdataapi.io/dashboard](https://xdataapi.io/dashboard). 5,000 credits to start, no card.
101
+ Prepaid packs from $10; credits never expire. Live status: [xdataapi.io/status](https://xdataapi.io/status).
102
+
103
+ MIT licensed.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "langchain-xdataapi"
3
+ version = "0.1.0"
4
+ description = "LangChain tools for xdataapi.io: read-only public X (Twitter) profiles, tweets, threads, followers and search."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ keywords = [
10
+ "langchain", "langgraph", "agents", "tools", "twitter", "x", "twitter-api",
11
+ "x-api", "mcp", "llm", "xdataapi",
12
+ ]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ ]
19
+ dependencies = [
20
+ "langchain-core>=0.3.0,<2.0.0",
21
+ "httpx>=0.27.0,<0.29.0",
22
+ "pydantic>=2.7.0,<3.0.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://xdataapi.io"
27
+ Documentation = "https://xdataapi.io/docs"
28
+ "API reference" = "https://xdataapi.io/reference"
29
+ "Source code" = "https://github.com/xdataapi/xdataapi"
30
+ "Issue tracker" = "https://github.com/xdataapi/xdataapi/issues"
31
+
32
+ [dependency-groups]
33
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "respx>=0.21"]
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/langchain_xdataapi"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
@@ -0,0 +1,54 @@
1
+ """LangChain tools for xdataapi.io: read-only public X (Twitter) data.
2
+
3
+ from langchain_xdataapi import XdataapiToolkit
4
+
5
+ tools = XdataapiToolkit.from_api_key().get_tools()
6
+
7
+ Read-only by design. No posting, no likes, no direct messages, no private data.
8
+ Docs: https://xdataapi.io/docs
9
+ """
10
+
11
+ from .client import XdataapiAuthError, XdataapiClient, XdataapiError
12
+ from .toolkit import XdataapiToolkit
13
+ from .tools import (
14
+ ALL_TOOLS,
15
+ GetBalance,
16
+ GetFollowerIds,
17
+ GetFollowers,
18
+ GetFollowing,
19
+ GetQuotes,
20
+ GetReplies,
21
+ GetRetweeters,
22
+ GetThread,
23
+ GetTweet,
24
+ GetTweets,
25
+ GetUser,
26
+ GetUserTweets,
27
+ GetUsers,
28
+ SearchTweets,
29
+ )
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ __all__ = [
34
+ "ALL_TOOLS",
35
+ "GetBalance",
36
+ "GetFollowerIds",
37
+ "GetFollowers",
38
+ "GetFollowing",
39
+ "GetQuotes",
40
+ "GetReplies",
41
+ "GetRetweeters",
42
+ "GetThread",
43
+ "GetTweet",
44
+ "GetTweets",
45
+ "GetUser",
46
+ "GetUserTweets",
47
+ "GetUsers",
48
+ "SearchTweets",
49
+ "XdataapiAuthError",
50
+ "XdataapiClient",
51
+ "XdataapiError",
52
+ "XdataapiToolkit",
53
+ "__version__",
54
+ ]
@@ -0,0 +1,149 @@
1
+ """HTTP client for the xdataapi.io REST API.
2
+
3
+ The tools hand the model the JSON the API returns, unchanged, so it sees
4
+ ``credits_charged`` and ``balance_remaining`` on every call and can stop before
5
+ the balance runs out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Mapping
12
+
13
+ import httpx
14
+
15
+ BASE_URL = "https://api.xdataapi.io"
16
+
17
+ #: Failures the model can act on: try another handle, ask for a smaller page,
18
+ #: tell the user the balance is empty. These come back as data.
19
+ _REPORTED = frozenset({400, 402, 403, 404})
20
+
21
+
22
+ class XdataapiError(RuntimeError):
23
+ """A call failed in a way the model cannot repair."""
24
+
25
+ def __init__(self, message: str, *, code: str = "", status: int = 0, request_id: str = "") -> None:
26
+ super().__init__(message)
27
+ self.code = code
28
+ self.status = status
29
+ self.request_id = request_id
30
+
31
+
32
+ class XdataapiAuthError(XdataapiError):
33
+ """The key is missing, unknown or revoked. A configuration fault."""
34
+
35
+
36
+ def _problem(response: httpx.Response) -> dict[str, Any]:
37
+ try:
38
+ body = response.json()
39
+ except ValueError:
40
+ body = {}
41
+ return body if isinstance(body, dict) else {}
42
+
43
+
44
+ class XdataapiClient:
45
+ """Thin wrapper over the REST API. Sync and async, one key.
46
+
47
+ Args:
48
+ api_key: Key from https://xdataapi.io/dashboard. Read from
49
+ ``XDATAAPI_KEY`` when not given.
50
+ base_url: Override for testing or for a private deployment.
51
+ timeout: Seconds per request.
52
+ max_results: Ceiling on ``count`` for every paging tool. Set it to cap
53
+ what one agent step can spend. ``None`` leaves the API default.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ api_key: str | None = None,
59
+ *,
60
+ base_url: str = BASE_URL,
61
+ timeout: float = 30.0,
62
+ max_results: int | None = None,
63
+ client: httpx.Client | None = None,
64
+ async_client: httpx.AsyncClient | None = None,
65
+ ) -> None:
66
+ key = api_key or os.environ.get("XDATAAPI_KEY")
67
+ if not key:
68
+ raise XdataapiAuthError(
69
+ "No API key. Pass api_key=... or set XDATAAPI_KEY. "
70
+ "Get one at https://xdataapi.io/dashboard."
71
+ )
72
+ self.api_key = key
73
+ self.base_url = base_url.rstrip("/")
74
+ self.timeout = timeout
75
+ self.max_results = max_results
76
+ self._client = client
77
+ self._async_client = async_client
78
+
79
+ @property
80
+ def _headers(self) -> dict[str, str]:
81
+ return {
82
+ "x-api-key": self.api_key,
83
+ "accept": "application/json",
84
+ "user-agent": "langchain-xdataapi",
85
+ }
86
+
87
+ def cap(self, count: int | None) -> int | None:
88
+ """Apply ``max_results`` to a requested page size."""
89
+ if count is None:
90
+ return self.max_results
91
+ if self.max_results is None:
92
+ return count
93
+ return min(count, self.max_results)
94
+
95
+ def _handle(self, response: httpx.Response) -> dict[str, Any]:
96
+ if response.status_code < 400:
97
+ return _problem(response)
98
+
99
+ body = _problem(response)
100
+ error = body.get("error") or {}
101
+ code = str(error.get("code") or "")
102
+ message = str(error.get("message") or response.reason_phrase or "request failed")
103
+ request_id = str(body.get("request_id") or "")
104
+
105
+ if response.status_code == 401:
106
+ raise XdataapiAuthError(
107
+ f"{message} (code {code or 'invalid_key'}). "
108
+ "Check XDATAAPI_KEY against https://xdataapi.io/dashboard.",
109
+ code=code,
110
+ status=401,
111
+ request_id=request_id,
112
+ )
113
+ if response.status_code in _REPORTED:
114
+ # Nothing was charged for these. Let the model read and decide.
115
+ return body
116
+ raise XdataapiError(
117
+ f"{message} (HTTP {response.status_code}, code {code}, request {request_id})",
118
+ code=code,
119
+ status=response.status_code,
120
+ request_id=request_id,
121
+ )
122
+
123
+ @staticmethod
124
+ def _clean(params: Mapping[str, Any] | None) -> dict[str, Any]:
125
+ return {k: v for k, v in (params or {}).items() if v is not None}
126
+
127
+ def get(self, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
128
+ client = self._client
129
+ if client is None:
130
+ with httpx.Client(timeout=self.timeout) as fresh:
131
+ return self._handle(
132
+ fresh.get(f"{self.base_url}{path}", params=self._clean(params), headers=self._headers)
133
+ )
134
+ return self._handle(
135
+ client.get(f"{self.base_url}{path}", params=self._clean(params), headers=self._headers)
136
+ )
137
+
138
+ async def aget(self, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
139
+ client = self._async_client
140
+ if client is None:
141
+ async with httpx.AsyncClient(timeout=self.timeout) as fresh:
142
+ return self._handle(
143
+ await fresh.get(
144
+ f"{self.base_url}{path}", params=self._clean(params), headers=self._headers
145
+ )
146
+ )
147
+ return self._handle(
148
+ await client.get(f"{self.base_url}{path}", params=self._clean(params), headers=self._headers)
149
+ )
@@ -0,0 +1,64 @@
1
+ """The whole read surface as one toolkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from langchain_core.tools import BaseTool
8
+ from langchain_core.tools.base import BaseToolkit
9
+ from pydantic import ConfigDict
10
+
11
+ from .client import XdataapiClient
12
+ from .tools import ALL_TOOLS
13
+
14
+
15
+ class XdataapiToolkit(BaseToolkit):
16
+ """Every read tool, sharing one client and one credit budget.
17
+
18
+ Example:
19
+ >>> from langchain_xdataapi import XdataapiToolkit
20
+ >>> toolkit = XdataapiToolkit.from_api_key(max_results=25)
21
+ >>> tools = toolkit.get_tools()
22
+
23
+ Args:
24
+ client: A configured :class:`XdataapiClient`.
25
+ include: Tool names to keep. ``None`` keeps them all.
26
+ exclude: Tool names to drop, applied after ``include``.
27
+ """
28
+
29
+ model_config = ConfigDict(arbitrary_types_allowed=True)
30
+
31
+ client: XdataapiClient
32
+ include: tuple[str, ...] | None = None
33
+ exclude: tuple[str, ...] = ()
34
+
35
+ @classmethod
36
+ def from_api_key(
37
+ cls,
38
+ api_key: str | None = None,
39
+ *,
40
+ max_results: int | None = None,
41
+ include: Iterable[str] | None = None,
42
+ exclude: Iterable[str] = (),
43
+ **client_kwargs: object,
44
+ ) -> "XdataapiToolkit":
45
+ """Build a toolkit from a key, or from ``XDATAAPI_KEY``.
46
+
47
+ ``max_results`` caps the page size of every paging tool, which caps what
48
+ one agent step can spend.
49
+ """
50
+ return cls(
51
+ client=XdataapiClient(api_key, max_results=max_results, **client_kwargs), # type: ignore[arg-type]
52
+ include=tuple(include) if include is not None else None,
53
+ exclude=tuple(exclude),
54
+ )
55
+
56
+ def get_tools(self) -> list[BaseTool]:
57
+ tools = [tool(client=self.client) for tool in ALL_TOOLS]
58
+ if self.include is not None:
59
+ wanted = set(self.include)
60
+ tools = [t for t in tools if t.name in wanted]
61
+ if self.exclude:
62
+ unwanted = set(self.exclude)
63
+ tools = [t for t in tools if t.name not in unwanted]
64
+ return tools
@@ -0,0 +1,348 @@
1
+ """LangChain tools over the xdataapi.io read endpoints.
2
+
3
+ Every tool is read-only. The credit cost is written into each description, so a
4
+ model that plans a multi-step read knows what the plan costs before it runs it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Literal, Sequence
11
+
12
+ from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
13
+ from langchain_core.tools import BaseTool
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ from .client import XdataapiClient
17
+
18
+
19
+ def _handle(value: str) -> str:
20
+ return value.strip().lstrip("@")
21
+
22
+
23
+ def _dump(payload: dict[str, Any]) -> str:
24
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
25
+
26
+
27
+ class _Base(BaseTool):
28
+ """Shared plumbing: one client, JSON in and JSON out."""
29
+
30
+ model_config = ConfigDict(arbitrary_types_allowed=True)
31
+
32
+ client: XdataapiClient
33
+ response_format: str = "content"
34
+
35
+ def _call(self, **kwargs: Any) -> tuple[str, dict[str, Any] | None]:
36
+ raise NotImplementedError
37
+
38
+ def _run(self, run_manager: CallbackManagerForToolRun | None = None, **kwargs: Any) -> str:
39
+ path, params = self._call(**kwargs)
40
+ return _dump(self.client.get(path, params))
41
+
42
+ async def _arun(self, run_manager: AsyncCallbackManagerForToolRun | None = None, **kwargs: Any) -> str:
43
+ path, params = self._call(**kwargs)
44
+ return _dump(await self.client.aget(path, params))
45
+
46
+
47
+ # --- users ---------------------------------------------------------------
48
+
49
+
50
+ class _UserArgs(BaseModel):
51
+ handle: str = Field(description="X handle, with or without the leading @.")
52
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
53
+
54
+
55
+ class GetUser(_Base):
56
+ name: str = "x_get_user"
57
+ description: str = (
58
+ "Public profile of one X (Twitter) user by handle: bio, counts, verified state, "
59
+ "created date. Costs 1 credit."
60
+ )
61
+ args_schema: type[BaseModel] = _UserArgs
62
+
63
+ def _call(self, handle: str, fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
64
+ return f"/v1/users/{_handle(handle)}", {"fresh": fresh}
65
+
66
+
67
+ class _UsersArgs(BaseModel):
68
+ handles: Sequence[str] = Field(description="Up to 100 handles in one call.")
69
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
70
+
71
+
72
+ class GetUsers(_Base):
73
+ name: str = "x_get_users"
74
+ description: str = (
75
+ "Profiles of up to 100 X users in ONE call. Prefer this over repeated single reads: "
76
+ "it is one request and one rate-limit slot. Costs 1 credit per profile found; "
77
+ "handles that do not exist are listed in errors and cost nothing."
78
+ )
79
+ args_schema: type[BaseModel] = _UsersArgs
80
+
81
+ def _call(self, handles: Sequence[str], fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
82
+ return "/v1/users/batch", {"handles": ",".join(_handle(h) for h in handles), "fresh": fresh}
83
+
84
+
85
+ class _PageArgs(BaseModel):
86
+ user: str = Field(description="X handle (with or without @) or numeric user id.")
87
+ count: int | None = Field(default=None, description="Results on this page. 1 to 50.")
88
+ cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
89
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
90
+
91
+
92
+ class GetUserTweets(_Base):
93
+ name: str = "x_get_user_tweets"
94
+ description: str = (
95
+ "Latest posts of one X user, newest first. Pages with next_cursor. Costs 1 credit per post."
96
+ )
97
+ args_schema: type[BaseModel] = _PageArgs
98
+
99
+ def _call(
100
+ self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
101
+ ) -> tuple[str, dict[str, Any] | None]:
102
+ return f"/v1/users/{_handle(user)}/tweets", {
103
+ "count": self.client.cap(count),
104
+ "cursor": cursor,
105
+ "fresh": fresh,
106
+ }
107
+
108
+
109
+ class GetFollowers(_Base):
110
+ name: str = "x_get_followers"
111
+ description: str = (
112
+ "Followers of an X user, as full profiles. Costs 0.1 credit per profile. "
113
+ "Some accounts refuse this read; a 403 is free. Use x_get_follower_ids when ids are enough."
114
+ )
115
+ args_schema: type[BaseModel] = _PageArgs
116
+
117
+ def _call(
118
+ self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
119
+ ) -> tuple[str, dict[str, Any] | None]:
120
+ return f"/v1/users/{_handle(user)}/followers", {
121
+ "count": self.client.cap(count),
122
+ "cursor": cursor,
123
+ "fresh": fresh,
124
+ }
125
+
126
+
127
+ class GetFollowerIds(_Base):
128
+ name: str = "x_get_follower_ids"
129
+ description: str = (
130
+ "Numeric ids of an X user's followers, without the profiles. Costs 0.02 credit per id, "
131
+ "five times less than x_get_followers. Use it to size or intersect an audience."
132
+ )
133
+ args_schema: type[BaseModel] = _PageArgs
134
+
135
+ def _call(
136
+ self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
137
+ ) -> tuple[str, dict[str, Any] | None]:
138
+ return f"/v1/users/{_handle(user)}/followers/ids", {
139
+ "count": self.client.cap(count),
140
+ "cursor": cursor,
141
+ "fresh": fresh,
142
+ }
143
+
144
+
145
+ class GetFollowing(_Base):
146
+ name: str = "x_get_following"
147
+ description: str = (
148
+ "Accounts an X user follows. Costs 0.1 credit per profile. Some accounts refuse this read; "
149
+ "a 403 is free."
150
+ )
151
+ args_schema: type[BaseModel] = _PageArgs
152
+
153
+ def _call(
154
+ self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
155
+ ) -> tuple[str, dict[str, Any] | None]:
156
+ return f"/v1/users/{_handle(user)}/following", {
157
+ "count": self.client.cap(count),
158
+ "cursor": cursor,
159
+ "fresh": fresh,
160
+ }
161
+
162
+
163
+ # --- tweets --------------------------------------------------------------
164
+
165
+
166
+ class _SearchArgs(BaseModel):
167
+ q: str = Field(
168
+ description=(
169
+ "Query in x.com search syntax. Operators: from:handle, to:handle, since:YYYY-MM-DD, "
170
+ 'until:YYYY-MM-DD, min_faves:N, lang:xx, "exact phrase", OR, and - to exclude. '
171
+ 'Example: from:vercel since:2026-09-01 -filter:replies'
172
+ )
173
+ )
174
+ product: Literal["Latest", "Top", "People", "Photos", "Videos"] | None = Field(
175
+ default=None,
176
+ description=(
177
+ "Latest (default, always available), Top (may answer 403, free), People (returns "
178
+ "profiles), Photos, Videos."
179
+ ),
180
+ )
181
+ count: int | None = Field(default=None, description="Results on this page. 1 to 50.")
182
+ cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
183
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
184
+
185
+
186
+ class SearchTweets(_Base):
187
+ name: str = "x_search_tweets"
188
+ description: str = (
189
+ "Search public X posts with the x.com operators. The main entry point when you do not "
190
+ "already know a handle or a post id. Costs 1 credit per result returned; an empty result "
191
+ "is free. Pages with next_cursor."
192
+ )
193
+ args_schema: type[BaseModel] = _SearchArgs
194
+
195
+ def _call(
196
+ self,
197
+ q: str,
198
+ product: str | None = None,
199
+ count: int | None = None,
200
+ cursor: str | None = None,
201
+ fresh: bool | None = None,
202
+ ) -> tuple[str, dict[str, Any] | None]:
203
+ return "/v1/tweets/search", {
204
+ "q": q,
205
+ "product": product,
206
+ "count": self.client.cap(count),
207
+ "cursor": cursor,
208
+ "fresh": fresh,
209
+ }
210
+
211
+
212
+ class _TweetArgs(BaseModel):
213
+ id: str = Field(description="Numeric post id, as a string.")
214
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
215
+
216
+
217
+ class GetTweet(_Base):
218
+ name: str = "x_get_tweet"
219
+ description: str = "One X post by numeric id, with its author. Costs 1 credit."
220
+ args_schema: type[BaseModel] = _TweetArgs
221
+
222
+ def _call(self, id: str, fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
223
+ return f"/v1/tweets/{id}", {"fresh": fresh}
224
+
225
+
226
+ class _TweetsArgs(BaseModel):
227
+ ids: Sequence[str] = Field(description="Up to 100 numeric post ids in one call.")
228
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
229
+
230
+
231
+ class GetTweets(_Base):
232
+ name: str = "x_get_tweets"
233
+ description: str = (
234
+ "Up to 100 X posts by id in ONE call. Prefer this over repeated single reads. "
235
+ "Costs 1 credit per post found; deleted or private posts are listed in errors and cost nothing."
236
+ )
237
+ args_schema: type[BaseModel] = _TweetsArgs
238
+
239
+ def _call(self, ids: Sequence[str], fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
240
+ return "/v1/tweets/batch", {"ids": ",".join(str(i) for i in ids), "fresh": fresh}
241
+
242
+
243
+ class _ThreadArgs(BaseModel):
244
+ id: str = Field(description="Numeric post id, as a string.")
245
+ cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
246
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
247
+
248
+
249
+ class GetThread(_Base):
250
+ name: str = "x_get_thread"
251
+ description: str = (
252
+ "One X post with its thread and replies, the focal post first. Use it to read a whole "
253
+ "conversation in one call. Costs 1 credit per post returned."
254
+ )
255
+ args_schema: type[BaseModel] = _ThreadArgs
256
+
257
+ def _call(
258
+ self, id: str, cursor: str | None = None, fresh: bool | None = None
259
+ ) -> tuple[str, dict[str, Any] | None]:
260
+ return f"/v1/tweets/{id}/thread", {"cursor": cursor, "fresh": fresh}
261
+
262
+
263
+ class GetReplies(_Base):
264
+ name: str = "x_get_replies"
265
+ description: str = "Direct replies to one X post, one page. Costs 1 credit per reply."
266
+ args_schema: type[BaseModel] = _ThreadArgs
267
+
268
+ def _call(
269
+ self, id: str, cursor: str | None = None, fresh: bool | None = None
270
+ ) -> tuple[str, dict[str, Any] | None]:
271
+ return f"/v1/tweets/{id}/replies", {"cursor": cursor, "fresh": fresh}
272
+
273
+
274
+ class _CountedTweetArgs(BaseModel):
275
+ id: str = Field(description="Numeric post id, as a string.")
276
+ count: int | None = Field(default=None, description="Results on this page. 1 to 50.")
277
+ cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
278
+ fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
279
+
280
+
281
+ class GetQuotes(_Base):
282
+ name: str = "x_get_quotes"
283
+ description: str = "Posts that quote one X post, newest first. Costs 1 credit per post."
284
+ args_schema: type[BaseModel] = _CountedTweetArgs
285
+
286
+ def _call(
287
+ self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
288
+ ) -> tuple[str, dict[str, Any] | None]:
289
+ return f"/v1/tweets/{id}/quotes", {
290
+ "count": self.client.cap(count),
291
+ "cursor": cursor,
292
+ "fresh": fresh,
293
+ }
294
+
295
+
296
+ class GetRetweeters(_Base):
297
+ name: str = "x_get_retweeters"
298
+ description: str = (
299
+ "Accounts that reposted one X post. Costs 0.5 credit per profile. Some accounts refuse "
300
+ "this read; a 403 is free."
301
+ )
302
+ args_schema: type[BaseModel] = _CountedTweetArgs
303
+
304
+ def _call(
305
+ self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
306
+ ) -> tuple[str, dict[str, Any] | None]:
307
+ return f"/v1/tweets/{id}/retweeters", {
308
+ "count": self.client.cap(count),
309
+ "cursor": cursor,
310
+ "fresh": fresh,
311
+ }
312
+
313
+
314
+ # --- account -------------------------------------------------------------
315
+
316
+
317
+ class _NoArgs(BaseModel):
318
+ pass
319
+
320
+
321
+ class GetBalance(_Base):
322
+ name: str = "x_get_balance"
323
+ description: str = (
324
+ "Credits left on this key and its rate limit. Free. Call it before a large read to check "
325
+ "the budget covers the plan."
326
+ )
327
+ args_schema: type[BaseModel] = _NoArgs
328
+
329
+ def _call(self) -> tuple[str, dict[str, Any] | None]:
330
+ return "/v1/me", None
331
+
332
+
333
+ ALL_TOOLS: tuple[type[_Base], ...] = (
334
+ SearchTweets,
335
+ GetUser,
336
+ GetUsers,
337
+ GetUserTweets,
338
+ GetTweet,
339
+ GetTweets,
340
+ GetThread,
341
+ GetReplies,
342
+ GetQuotes,
343
+ GetRetweeters,
344
+ GetFollowers,
345
+ GetFollowerIds,
346
+ GetFollowing,
347
+ GetBalance,
348
+ )
@@ -0,0 +1,171 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from langchain_xdataapi import (
8
+ GetBalance,
9
+ GetTweets,
10
+ GetUser,
11
+ SearchTweets,
12
+ XdataapiAuthError,
13
+ XdataapiClient,
14
+ XdataapiError,
15
+ XdataapiToolkit,
16
+ )
17
+
18
+ BASE = "https://api.xdataapi.io"
19
+
20
+
21
+ def client(**kwargs):
22
+ return XdataapiClient("xd_live_test", **kwargs)
23
+
24
+
25
+ @respx.mock
26
+ def test_get_user_strips_the_at_sign():
27
+ route = respx.get(f"{BASE}/v1/users/vercel").mock(
28
+ return_value=httpx.Response(200, json={"data": {"handle": "vercel"}, "credits_charged": 1})
29
+ )
30
+ out = GetUser(client=client()).invoke({"handle": "@vercel"})
31
+ assert route.called
32
+ assert json.loads(out)["credits_charged"] == 1
33
+
34
+
35
+ @respx.mock
36
+ def test_search_passes_the_operators_through_untouched():
37
+ route = respx.get(f"{BASE}/v1/tweets/search").mock(
38
+ return_value=httpx.Response(200, json={"data": [], "credits_charged": 0})
39
+ )
40
+ SearchTweets(client=client()).invoke({"q": "from:x since:2026-09-01 -filter:replies", "count": 10})
41
+ assert route.calls.last.request.url.params["q"] == "from:x since:2026-09-01 -filter:replies"
42
+ assert route.calls.last.request.url.params["count"] == "10"
43
+
44
+
45
+ @respx.mock
46
+ def test_none_valued_arguments_are_not_sent():
47
+ route = respx.get(f"{BASE}/v1/tweets/search").mock(return_value=httpx.Response(200, json={"data": []}))
48
+ SearchTweets(client=client()).invoke({"q": "hello"})
49
+ params = route.calls.last.request.url.params
50
+ assert "cursor" not in params and "fresh" not in params and "product" not in params
51
+
52
+
53
+ @respx.mock
54
+ def test_max_results_caps_a_bigger_request():
55
+ route = respx.get(f"{BASE}/v1/tweets/search").mock(return_value=httpx.Response(200, json={"data": []}))
56
+ SearchTweets(client=client(max_results=25)).invoke({"q": "hello", "count": 50})
57
+ assert route.calls.last.request.url.params["count"] == "25"
58
+
59
+
60
+ @respx.mock
61
+ def test_max_results_applies_when_the_model_names_no_count():
62
+ route = respx.get(f"{BASE}/v1/tweets/search").mock(return_value=httpx.Response(200, json={"data": []}))
63
+ SearchTweets(client=client(max_results=5)).invoke({"q": "hello"})
64
+ assert route.calls.last.request.url.params["count"] == "5"
65
+
66
+
67
+ @respx.mock
68
+ def test_max_results_never_raises_a_smaller_request():
69
+ route = respx.get(f"{BASE}/v1/tweets/search").mock(return_value=httpx.Response(200, json={"data": []}))
70
+ SearchTweets(client=client(max_results=25)).invoke({"q": "hello", "count": 5})
71
+ assert route.calls.last.request.url.params["count"] == "5"
72
+
73
+
74
+ @respx.mock
75
+ def test_batch_sends_one_comma_separated_list():
76
+ route = respx.get(f"{BASE}/v1/tweets/batch").mock(return_value=httpx.Response(200, json={"data": []}))
77
+ GetTweets(client=client()).invoke({"ids": ["20", "21"]})
78
+ assert route.calls.last.request.url.params["ids"] == "20,21"
79
+
80
+
81
+ @respx.mock
82
+ def test_no_credits_comes_back_as_data_so_the_model_can_stop():
83
+ respx.get(f"{BASE}/v1/users/x").mock(
84
+ return_value=httpx.Response(402, json={"error": {"code": "no_credits", "message": "balance is zero"}})
85
+ )
86
+ out = json.loads(GetUser(client=client()).invoke({"handle": "x"}))
87
+ assert out["error"]["code"] == "no_credits"
88
+
89
+
90
+ @respx.mock
91
+ def test_not_found_comes_back_as_data():
92
+ respx.get(f"{BASE}/v1/users/nope").mock(
93
+ return_value=httpx.Response(404, json={"error": {"code": "not_found", "message": "no such object"}})
94
+ )
95
+ out = json.loads(GetUser(client=client()).invoke({"handle": "nope"}))
96
+ assert out["error"]["code"] == "not_found"
97
+
98
+
99
+ @respx.mock
100
+ def test_a_bad_key_raises_because_no_model_can_repair_it():
101
+ respx.get(f"{BASE}/v1/users/x").mock(
102
+ return_value=httpx.Response(401, json={"error": {"code": "invalid_key", "message": "unknown key"}})
103
+ )
104
+ with pytest.raises(XdataapiAuthError) as caught:
105
+ GetUser(client=client()).invoke({"handle": "x"})
106
+ assert caught.value.code == "invalid_key"
107
+
108
+
109
+ @respx.mock
110
+ def test_a_rate_limit_raises_so_the_framework_can_retry():
111
+ respx.get(f"{BASE}/v1/users/x").mock(
112
+ return_value=httpx.Response(429, json={"error": {"code": "too_many_requests", "message": "slow down"}})
113
+ )
114
+ with pytest.raises(XdataapiError) as caught:
115
+ GetUser(client=client()).invoke({"handle": "x"})
116
+ assert caught.value.status == 429
117
+
118
+
119
+ @respx.mock
120
+ def test_an_upstream_fault_raises():
121
+ respx.get(f"{BASE}/v1/users/x").mock(return_value=httpx.Response(503, json={"error": {"code": "upstream"}}))
122
+ with pytest.raises(XdataapiError):
123
+ GetUser(client=client()).invoke({"handle": "x"})
124
+
125
+
126
+ def test_a_missing_key_is_reported_before_any_request():
127
+ with pytest.raises(XdataapiAuthError):
128
+ XdataapiClient(None, base_url=BASE)
129
+
130
+
131
+ @respx.mock
132
+ def test_balance_takes_no_arguments_and_reads_me():
133
+ route = respx.get(f"{BASE}/v1/me").mock(return_value=httpx.Response(200, json={"balance": 4679.87}))
134
+ out = json.loads(GetBalance(client=client()).invoke({}))
135
+ assert route.called and out["balance"] == 4679.87
136
+
137
+
138
+ @respx.mock
139
+ async def test_the_async_path_calls_the_same_url():
140
+ route = respx.get(f"{BASE}/v1/users/x").mock(return_value=httpx.Response(200, json={"data": {}}))
141
+ await GetUser(client=client()).ainvoke({"handle": "x"})
142
+ assert route.called
143
+
144
+
145
+ def test_the_toolkit_offers_every_tool_once():
146
+ tools = XdataapiToolkit.from_api_key("xd_live_test").get_tools()
147
+ names = [t.name for t in tools]
148
+ assert len(names) == 14
149
+ assert len(set(names)) == 14
150
+ assert "x_search_tweets" in names
151
+
152
+
153
+ def test_include_narrows_the_surface():
154
+ tools = XdataapiToolkit.from_api_key("xd_live_test", include=["x_search_tweets"]).get_tools()
155
+ assert [t.name for t in tools] == ["x_search_tweets"]
156
+
157
+
158
+ def test_exclude_drops_the_expensive_reads():
159
+ tools = XdataapiToolkit.from_api_key("xd_live_test", exclude=["x_get_followers"]).get_tools()
160
+ assert "x_get_followers" not in [t.name for t in tools]
161
+
162
+
163
+ def test_the_toolkit_shares_one_capped_client():
164
+ tools = XdataapiToolkit.from_api_key("xd_live_test", max_results=10).get_tools()
165
+ assert {t.client for t in tools}.__len__() == 1
166
+ assert tools[0].client.max_results == 10
167
+
168
+
169
+ def test_every_tool_describes_what_it_costs():
170
+ for tool in XdataapiToolkit.from_api_key("xd_live_test").get_tools():
171
+ assert "credit" in tool.description.lower() or "free" in tool.description.lower()