llama-index-tools-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.
- llama_index_tools_xdataapi-0.1.0/.gitignore +9 -0
- llama_index_tools_xdataapi-0.1.0/LICENSE +9 -0
- llama_index_tools_xdataapi-0.1.0/PKG-INFO +118 -0
- llama_index_tools_xdataapi-0.1.0/README.md +97 -0
- llama_index_tools_xdataapi-0.1.0/llama_index/tools/xdataapi/__init__.py +8 -0
- llama_index_tools_xdataapi-0.1.0/llama_index/tools/xdataapi/base.py +243 -0
- llama_index_tools_xdataapi-0.1.0/llama_index/tools/xdataapi/client.py +149 -0
- llama_index_tools_xdataapi-0.1.0/pyproject.toml +46 -0
- llama_index_tools_xdataapi-0.1.0/tests/test_tools.py +105 -0
|
@@ -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,118 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: llama-index-tools-xdataapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LlamaIndex tool spec 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,llama-index,llamahub,llamaindex,llm,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: <4.0,>=3.9
|
|
18
|
+
Requires-Dist: httpx<0.29.0,>=0.27.0
|
|
19
|
+
Requires-Dist: llama-index-core<0.15,>=0.12.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# llama-index-tools-xdataapi
|
|
23
|
+
|
|
24
|
+
LlamaIndex tool spec for [xdataapi.io](https://xdataapi.io): read-only public X (Twitter) data.
|
|
25
|
+
Profiles, posts, threads, replies, quotes, reposters, followers, and search with the x.com operators.
|
|
26
|
+
|
|
27
|
+
Read-only by design. No posting, no likes, no direct messages, no private data.
|
|
28
|
+
Not affiliated with X Corp.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install llama-index-tools-xdataapi
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from llama_index.tools.xdataapi import XdataapiToolSpec
|
|
38
|
+
|
|
39
|
+
# Reads XDATAAPI_KEY from the environment.
|
|
40
|
+
tool_spec = XdataapiToolSpec()
|
|
41
|
+
tools = tool_spec.to_tool_list()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
With an agent:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from llama_index.core.agent.workflow import FunctionAgent
|
|
48
|
+
from llama_index.llms.anthropic import Anthropic
|
|
49
|
+
from llama_index.tools.xdataapi import XdataapiToolSpec
|
|
50
|
+
|
|
51
|
+
agent = FunctionAgent(
|
|
52
|
+
tools=XdataapiToolSpec(max_results=25).to_tool_list(),
|
|
53
|
+
llm=Anthropic(model="claude-opus-5"),
|
|
54
|
+
)
|
|
55
|
+
print(await agent.run("What is @vercel posting about this week?"))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
One call on its own:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
XdataapiToolSpec().search_tweets(q="from:vercel since:2026-09-01", count=10)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Tools
|
|
65
|
+
|
|
66
|
+
| Tool | Reads | Credits |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `search_tweets` | Search with the x.com operators | 1 per result |
|
|
69
|
+
| `get_user` | One profile | 1 |
|
|
70
|
+
| `get_users` | Up to 100 profiles in one call | 1 per profile |
|
|
71
|
+
| `get_user_tweets` | Latest posts of a user | 1 per post |
|
|
72
|
+
| `get_tweet` | One post | 1 |
|
|
73
|
+
| `get_tweets` | Up to 100 posts in one call | 1 per post |
|
|
74
|
+
| `get_thread` | A post with its thread and replies | 1 per post |
|
|
75
|
+
| `get_replies` | Direct replies to a post | 1 per reply |
|
|
76
|
+
| `get_quotes` | Posts that quote a post | 1 per post |
|
|
77
|
+
| `get_retweeters` | Accounts that reposted a post | 0.5 per profile |
|
|
78
|
+
| `get_followers` | Followers, as profiles | 0.1 per profile |
|
|
79
|
+
| `get_follower_ids` | Follower ids only | 0.02 per id |
|
|
80
|
+
| `get_following` | Accounts a user follows | 0.1 per profile |
|
|
81
|
+
| `get_balance` | Credits left and rate limit | free |
|
|
82
|
+
|
|
83
|
+
Empty results and failed requests cost nothing. A repeated read inside the cache window costs half.
|
|
84
|
+
Every response carries `credits_charged` and `balance_remaining`, so the model can watch the budget
|
|
85
|
+
and stop on its own.
|
|
86
|
+
|
|
87
|
+
## Spending guards
|
|
88
|
+
|
|
89
|
+
`max_results` caps the page size of every paging tool:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
tool_spec = XdataapiToolSpec(max_results=25)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Narrow the surface with the LlamaIndex argument:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
tools = XdataapiToolSpec().to_tool_list(spec_functions=["search_tweets", "get_user", "get_balance"])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Errors
|
|
102
|
+
|
|
103
|
+
A failure the model can act on — no credits, not found, a search product X refused — comes back as a
|
|
104
|
+
dict with `error.code`, so the model can change plan. None of those are charged.
|
|
105
|
+
A missing or revoked key raises `XdataapiAuthError`, because no model can repair it.
|
|
106
|
+
Rate limits and upstream faults raise `XdataapiError`.
|
|
107
|
+
|
|
108
|
+
## MCP instead
|
|
109
|
+
|
|
110
|
+
An agent that speaks MCP needs no package. The hosted server is at `https://api.xdataapi.io/mcp`
|
|
111
|
+
(Streamable HTTP, OAuth or `x-api-key`), on the official MCP Registry as `io.xdataapi/xdataapi`.
|
|
112
|
+
|
|
113
|
+
## Keys and pricing
|
|
114
|
+
|
|
115
|
+
Get a key at [xdataapi.io/dashboard](https://xdataapi.io/dashboard). 5,000 credits to start, no card.
|
|
116
|
+
Prepaid packs from $10; credits never expire. Live status: [xdataapi.io/status](https://xdataapi.io/status).
|
|
117
|
+
|
|
118
|
+
MIT licensed.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# llama-index-tools-xdataapi
|
|
2
|
+
|
|
3
|
+
LlamaIndex tool spec 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 llama-index-tools-xdataapi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from llama_index.tools.xdataapi import XdataapiToolSpec
|
|
17
|
+
|
|
18
|
+
# Reads XDATAAPI_KEY from the environment.
|
|
19
|
+
tool_spec = XdataapiToolSpec()
|
|
20
|
+
tools = tool_spec.to_tool_list()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
With an agent:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from llama_index.core.agent.workflow import FunctionAgent
|
|
27
|
+
from llama_index.llms.anthropic import Anthropic
|
|
28
|
+
from llama_index.tools.xdataapi import XdataapiToolSpec
|
|
29
|
+
|
|
30
|
+
agent = FunctionAgent(
|
|
31
|
+
tools=XdataapiToolSpec(max_results=25).to_tool_list(),
|
|
32
|
+
llm=Anthropic(model="claude-opus-5"),
|
|
33
|
+
)
|
|
34
|
+
print(await agent.run("What is @vercel posting about this week?"))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
One call on its own:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
XdataapiToolSpec().search_tweets(q="from:vercel since:2026-09-01", count=10)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Tools
|
|
44
|
+
|
|
45
|
+
| Tool | Reads | Credits |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `search_tweets` | Search with the x.com operators | 1 per result |
|
|
48
|
+
| `get_user` | One profile | 1 |
|
|
49
|
+
| `get_users` | Up to 100 profiles in one call | 1 per profile |
|
|
50
|
+
| `get_user_tweets` | Latest posts of a user | 1 per post |
|
|
51
|
+
| `get_tweet` | One post | 1 |
|
|
52
|
+
| `get_tweets` | Up to 100 posts in one call | 1 per post |
|
|
53
|
+
| `get_thread` | A post with its thread and replies | 1 per post |
|
|
54
|
+
| `get_replies` | Direct replies to a post | 1 per reply |
|
|
55
|
+
| `get_quotes` | Posts that quote a post | 1 per post |
|
|
56
|
+
| `get_retweeters` | Accounts that reposted a post | 0.5 per profile |
|
|
57
|
+
| `get_followers` | Followers, as profiles | 0.1 per profile |
|
|
58
|
+
| `get_follower_ids` | Follower ids only | 0.02 per id |
|
|
59
|
+
| `get_following` | Accounts a user follows | 0.1 per profile |
|
|
60
|
+
| `get_balance` | Credits left and rate limit | free |
|
|
61
|
+
|
|
62
|
+
Empty results and failed requests cost nothing. A repeated read inside the cache window costs half.
|
|
63
|
+
Every response carries `credits_charged` and `balance_remaining`, so the model can watch the budget
|
|
64
|
+
and stop on its own.
|
|
65
|
+
|
|
66
|
+
## Spending guards
|
|
67
|
+
|
|
68
|
+
`max_results` caps the page size of every paging tool:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
tool_spec = XdataapiToolSpec(max_results=25)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Narrow the surface with the LlamaIndex argument:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
tools = XdataapiToolSpec().to_tool_list(spec_functions=["search_tweets", "get_user", "get_balance"])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Errors
|
|
81
|
+
|
|
82
|
+
A failure the model can act on — no credits, not found, a search product X refused — comes back as a
|
|
83
|
+
dict with `error.code`, so the model can change plan. None of those are charged.
|
|
84
|
+
A missing or revoked key raises `XdataapiAuthError`, because no model can repair it.
|
|
85
|
+
Rate limits and upstream faults raise `XdataapiError`.
|
|
86
|
+
|
|
87
|
+
## MCP instead
|
|
88
|
+
|
|
89
|
+
An agent that speaks MCP needs no package. The hosted server is at `https://api.xdataapi.io/mcp`
|
|
90
|
+
(Streamable HTTP, OAuth or `x-api-key`), on the official MCP Registry as `io.xdataapi/xdataapi`.
|
|
91
|
+
|
|
92
|
+
## Keys and pricing
|
|
93
|
+
|
|
94
|
+
Get a key at [xdataapi.io/dashboard](https://xdataapi.io/dashboard). 5,000 credits to start, no card.
|
|
95
|
+
Prepaid packs from $10; credits never expire. Live status: [xdataapi.io/status](https://xdataapi.io/status).
|
|
96
|
+
|
|
97
|
+
MIT licensed.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""LlamaIndex tool spec for xdataapi.io: read-only public X (Twitter) data.
|
|
2
|
+
|
|
3
|
+
Every method is a tool. The docstring is what the model reads, so each one says
|
|
4
|
+
what the call costs in credits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, List, Optional
|
|
10
|
+
|
|
11
|
+
from llama_index.core.tools.tool_spec.base import BaseToolSpec
|
|
12
|
+
|
|
13
|
+
from .client import XdataapiAuthError, XdataapiClient, XdataapiError
|
|
14
|
+
|
|
15
|
+
__all__ = ["XdataapiToolSpec", "XdataapiClient", "XdataapiError", "XdataapiAuthError"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _handle(value: str) -> str:
|
|
19
|
+
return value.strip().lstrip("@")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class XdataapiToolSpec(BaseToolSpec):
|
|
23
|
+
"""Read public X (Twitter) data: profiles, posts, threads, followers, search.
|
|
24
|
+
|
|
25
|
+
Read-only. No posting, no likes, no direct messages, no private data.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
api_key: Key from https://xdataapi.io/dashboard. Read from
|
|
29
|
+
``XDATAAPI_KEY`` when not given.
|
|
30
|
+
max_results: Ceiling on the page size of every paging tool, so one agent
|
|
31
|
+
step cannot spend more than you allow.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
spec_functions = [
|
|
35
|
+
"search_tweets",
|
|
36
|
+
"get_user",
|
|
37
|
+
"get_users",
|
|
38
|
+
"get_user_tweets",
|
|
39
|
+
"get_tweet",
|
|
40
|
+
"get_tweets",
|
|
41
|
+
"get_thread",
|
|
42
|
+
"get_replies",
|
|
43
|
+
"get_quotes",
|
|
44
|
+
"get_retweeters",
|
|
45
|
+
"get_followers",
|
|
46
|
+
"get_follower_ids",
|
|
47
|
+
"get_following",
|
|
48
|
+
"get_balance",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
api_key: Optional[str] = None,
|
|
54
|
+
*,
|
|
55
|
+
max_results: Optional[int] = None,
|
|
56
|
+
**client_kwargs: Any,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.client = XdataapiClient(api_key, max_results=max_results, **client_kwargs)
|
|
59
|
+
|
|
60
|
+
# --- tweets ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def search_tweets(
|
|
63
|
+
self,
|
|
64
|
+
q: str,
|
|
65
|
+
product: Optional[str] = None,
|
|
66
|
+
count: Optional[int] = None,
|
|
67
|
+
cursor: Optional[str] = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""Search public X posts with the x.com operators.
|
|
70
|
+
|
|
71
|
+
The entry point when you do not already know a handle or a post id.
|
|
72
|
+
Costs 1 credit per result; an empty result is free.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
q: x.com search syntax. Operators: from:handle, to:handle,
|
|
76
|
+
since:YYYY-MM-DD, until:YYYY-MM-DD, min_faves:N, lang:xx,
|
|
77
|
+
"exact phrase", OR, and - to exclude.
|
|
78
|
+
Example: from:vercel since:2026-09-01 -filter:replies
|
|
79
|
+
product: Latest (default, always available), Top (may answer 403,
|
|
80
|
+
free), People (returns profiles), Photos, Videos.
|
|
81
|
+
count: Results on this page, 1 to 50.
|
|
82
|
+
cursor: next_cursor from the previous page.
|
|
83
|
+
"""
|
|
84
|
+
return self.client.get(
|
|
85
|
+
"/v1/tweets/search",
|
|
86
|
+
{"q": q, "product": product, "count": self.client.cap(count), "cursor": cursor},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def get_tweet(self, id: str) -> dict:
|
|
90
|
+
"""Read one X post by numeric id, with its author. Costs 1 credit.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
id: Numeric post id, as a string.
|
|
94
|
+
"""
|
|
95
|
+
return self.client.get(f"/v1/tweets/{id}")
|
|
96
|
+
|
|
97
|
+
def get_tweets(self, ids: List[str]) -> dict:
|
|
98
|
+
"""Read up to 100 X posts by id in ONE call.
|
|
99
|
+
|
|
100
|
+
Prefer this over repeated single reads: one request, one rate-limit slot.
|
|
101
|
+
Costs 1 credit per post found; deleted or private posts are listed in
|
|
102
|
+
``errors`` and cost nothing.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
ids: Numeric post ids, at most 100.
|
|
106
|
+
"""
|
|
107
|
+
return self.client.get("/v1/tweets/batch", {"ids": ",".join(str(i) for i in ids)})
|
|
108
|
+
|
|
109
|
+
def get_thread(self, id: str, cursor: Optional[str] = None) -> dict:
|
|
110
|
+
"""Read one X post with its thread and replies, focal post first.
|
|
111
|
+
|
|
112
|
+
Reads a whole conversation in one call. Costs 1 credit per post returned.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
id: Numeric post id, as a string.
|
|
116
|
+
cursor: next_cursor from the previous page.
|
|
117
|
+
"""
|
|
118
|
+
return self.client.get(f"/v1/tweets/{id}/thread", {"cursor": cursor})
|
|
119
|
+
|
|
120
|
+
def get_replies(self, id: str, cursor: Optional[str] = None) -> dict:
|
|
121
|
+
"""Read the direct replies to one X post, one page. Costs 1 credit per reply.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
id: Numeric post id, as a string.
|
|
125
|
+
cursor: next_cursor from the previous page.
|
|
126
|
+
"""
|
|
127
|
+
return self.client.get(f"/v1/tweets/{id}/replies", {"cursor": cursor})
|
|
128
|
+
|
|
129
|
+
def get_quotes(self, id: str, count: Optional[int] = None, cursor: Optional[str] = None) -> dict:
|
|
130
|
+
"""Read the posts that quote one X post, newest first. Costs 1 credit per post.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
id: Numeric post id, as a string.
|
|
134
|
+
count: Results on this page, 1 to 50.
|
|
135
|
+
cursor: next_cursor from the previous page.
|
|
136
|
+
"""
|
|
137
|
+
return self.client.get(
|
|
138
|
+
f"/v1/tweets/{id}/quotes", {"count": self.client.cap(count), "cursor": cursor}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def get_retweeters(self, id: str, count: Optional[int] = None, cursor: Optional[str] = None) -> dict:
|
|
142
|
+
"""Read the accounts that reposted one X post. Costs 0.5 credit per profile.
|
|
143
|
+
|
|
144
|
+
Some reading accounts are refused this by X; a 403 is free.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
id: Numeric post id, as a string.
|
|
148
|
+
count: Results on this page, 1 to 50.
|
|
149
|
+
cursor: next_cursor from the previous page.
|
|
150
|
+
"""
|
|
151
|
+
return self.client.get(
|
|
152
|
+
f"/v1/tweets/{id}/retweeters", {"count": self.client.cap(count), "cursor": cursor}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# --- users -----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def get_user(self, handle: str) -> dict:
|
|
158
|
+
"""Read the public profile of one X user. Costs 1 credit.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
handle: X handle, with or without the leading @.
|
|
162
|
+
"""
|
|
163
|
+
return self.client.get(f"/v1/users/{_handle(handle)}")
|
|
164
|
+
|
|
165
|
+
def get_users(self, handles: List[str]) -> dict:
|
|
166
|
+
"""Read up to 100 X profiles in ONE call.
|
|
167
|
+
|
|
168
|
+
Prefer this over repeated single reads. Costs 1 credit per profile found;
|
|
169
|
+
handles that do not exist are listed in ``errors`` and cost nothing.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
handles: X handles, at most 100.
|
|
173
|
+
"""
|
|
174
|
+
return self.client.get("/v1/users/batch", {"handles": ",".join(_handle(h) for h in handles)})
|
|
175
|
+
|
|
176
|
+
def get_user_tweets(
|
|
177
|
+
self, user: str, count: Optional[int] = None, cursor: Optional[str] = None
|
|
178
|
+
) -> dict:
|
|
179
|
+
"""Read the latest posts of one X user, newest first. Costs 1 credit per post.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
user: X handle (with or without @) or numeric user id.
|
|
183
|
+
count: Results on this page, 1 to 50.
|
|
184
|
+
cursor: next_cursor from the previous page.
|
|
185
|
+
"""
|
|
186
|
+
return self.client.get(
|
|
187
|
+
f"/v1/users/{_handle(user)}/tweets", {"count": self.client.cap(count), "cursor": cursor}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def get_followers(self, user: str, count: Optional[int] = None, cursor: Optional[str] = None) -> dict:
|
|
191
|
+
"""Read the followers of an X user as full profiles. Costs 0.1 credit per profile.
|
|
192
|
+
|
|
193
|
+
Use get_follower_ids when ids are enough: it costs five times less.
|
|
194
|
+
Some reading accounts are refused this by X; a 403 is free.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
user: X handle (with or without @) or numeric user id.
|
|
198
|
+
count: Results on this page, 1 to 50.
|
|
199
|
+
cursor: next_cursor from the previous page.
|
|
200
|
+
"""
|
|
201
|
+
return self.client.get(
|
|
202
|
+
f"/v1/users/{_handle(user)}/followers", {"count": self.client.cap(count), "cursor": cursor}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def get_follower_ids(
|
|
206
|
+
self, user: str, count: Optional[int] = None, cursor: Optional[str] = None
|
|
207
|
+
) -> dict:
|
|
208
|
+
"""Read the numeric ids of an X user's followers, without profiles.
|
|
209
|
+
|
|
210
|
+
Costs 0.02 credit per id. Use it to size or intersect an audience.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
user: X handle (with or without @) or numeric user id.
|
|
214
|
+
count: Results on this page, 1 to 50.
|
|
215
|
+
cursor: next_cursor from the previous page.
|
|
216
|
+
"""
|
|
217
|
+
return self.client.get(
|
|
218
|
+
f"/v1/users/{_handle(user)}/followers/ids",
|
|
219
|
+
{"count": self.client.cap(count), "cursor": cursor},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def get_following(self, user: str, count: Optional[int] = None, cursor: Optional[str] = None) -> dict:
|
|
223
|
+
"""Read the accounts an X user follows. Costs 0.1 credit per profile.
|
|
224
|
+
|
|
225
|
+
Some reading accounts are refused this by X; a 403 is free.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
user: X handle (with or without @) or numeric user id.
|
|
229
|
+
count: Results on this page, 1 to 50.
|
|
230
|
+
cursor: next_cursor from the previous page.
|
|
231
|
+
"""
|
|
232
|
+
return self.client.get(
|
|
233
|
+
f"/v1/users/{_handle(user)}/following", {"count": self.client.cap(count), "cursor": cursor}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# --- account ---------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def get_balance(self) -> dict:
|
|
239
|
+
"""Read the credits left on this key and its rate limit. Free.
|
|
240
|
+
|
|
241
|
+
Call it before a large read to check the budget covers the plan.
|
|
242
|
+
"""
|
|
243
|
+
return self.client.get("/v1/me")
|
|
@@ -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": "llama-index-tools-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,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "llama-index-tools-xdataapi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "LlamaIndex tool spec 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.9,<4.0"
|
|
9
|
+
keywords = [
|
|
10
|
+
"llama-index", "llamaindex", "llamahub", "agents", "tools", "twitter", "x",
|
|
11
|
+
"twitter-api", "x-api", "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
|
+
"llama-index-core>=0.12.0,<0.15",
|
|
21
|
+
"httpx>=0.27.0,<0.29.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://xdataapi.io"
|
|
26
|
+
Documentation = "https://xdataapi.io/docs"
|
|
27
|
+
"API reference" = "https://xdataapi.io/reference"
|
|
28
|
+
"Source code" = "https://github.com/xdataapi/xdataapi"
|
|
29
|
+
"Issue tracker" = "https://github.com/xdataapi/xdataapi/issues"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=8.0", "respx>=0.21"]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["llama_index"]
|
|
40
|
+
|
|
41
|
+
[tool.llamahub]
|
|
42
|
+
contains_example = false
|
|
43
|
+
import_path = "llama_index.tools.xdataapi"
|
|
44
|
+
|
|
45
|
+
[tool.llamahub.class_authors]
|
|
46
|
+
XdataapiToolSpec = "xdataapi"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
import respx
|
|
4
|
+
from llama_index.core.tools.tool_spec.base import BaseToolSpec
|
|
5
|
+
|
|
6
|
+
from llama_index.tools.xdataapi import XdataapiAuthError, XdataapiError, XdataapiToolSpec
|
|
7
|
+
|
|
8
|
+
BASE = "https://api.xdataapi.io"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def spec(**kwargs):
|
|
12
|
+
return XdataapiToolSpec("xd_live_test", **kwargs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_it_is_a_tool_spec():
|
|
16
|
+
assert isinstance(spec(), BaseToolSpec)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_every_named_function_exists():
|
|
20
|
+
s = spec()
|
|
21
|
+
for name in s.spec_functions:
|
|
22
|
+
assert callable(getattr(s, name)), name
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_the_tool_list_carries_all_of_them():
|
|
26
|
+
tools = spec().to_tool_list()
|
|
27
|
+
assert len(tools) == 14
|
|
28
|
+
assert {t.metadata.name for t in tools} == set(XdataapiToolSpec.spec_functions)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_every_tool_describes_what_it_costs():
|
|
32
|
+
for tool in spec().to_tool_list():
|
|
33
|
+
text = (tool.metadata.description or "").lower()
|
|
34
|
+
assert "credit" in text or "free" in text, tool.metadata.name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@respx.mock
|
|
38
|
+
def test_get_user_strips_the_at_sign():
|
|
39
|
+
route = respx.get(f"{BASE}/v1/users/vercel").mock(
|
|
40
|
+
return_value=httpx.Response(200, json={"data": {"handle": "vercel"}, "credits_charged": 1})
|
|
41
|
+
)
|
|
42
|
+
assert spec().get_user("@vercel")["credits_charged"] == 1
|
|
43
|
+
assert route.called
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@respx.mock
|
|
47
|
+
def test_search_passes_the_operators_through_untouched():
|
|
48
|
+
route = respx.get(f"{BASE}/v1/tweets/search").mock(return_value=httpx.Response(200, json={"data": []}))
|
|
49
|
+
spec().search_tweets(q="from:x since:2026-09-01 -filter:replies", count=10)
|
|
50
|
+
assert route.calls.last.request.url.params["q"] == "from:x since:2026-09-01 -filter:replies"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@respx.mock
|
|
54
|
+
def test_none_valued_arguments_are_not_sent():
|
|
55
|
+
route = respx.get(f"{BASE}/v1/tweets/search").mock(return_value=httpx.Response(200, json={"data": []}))
|
|
56
|
+
spec().search_tweets(q="hello")
|
|
57
|
+
params = route.calls.last.request.url.params
|
|
58
|
+
assert "cursor" not in params and "product" not in params
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@respx.mock
|
|
62
|
+
def test_max_results_caps_a_bigger_request():
|
|
63
|
+
route = respx.get(f"{BASE}/v1/tweets/search").mock(return_value=httpx.Response(200, json={"data": []}))
|
|
64
|
+
spec(max_results=25).search_tweets(q="hello", count=50)
|
|
65
|
+
assert route.calls.last.request.url.params["count"] == "25"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@respx.mock
|
|
69
|
+
def test_batch_sends_one_comma_separated_list():
|
|
70
|
+
route = respx.get(f"{BASE}/v1/users/batch").mock(return_value=httpx.Response(200, json={"data": []}))
|
|
71
|
+
spec().get_users(["@x", "github"])
|
|
72
|
+
assert route.calls.last.request.url.params["handles"] == "x,github"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@respx.mock
|
|
76
|
+
def test_no_credits_comes_back_as_data_so_the_model_can_stop():
|
|
77
|
+
respx.get(f"{BASE}/v1/users/x").mock(
|
|
78
|
+
return_value=httpx.Response(402, json={"error": {"code": "no_credits", "message": "balance is zero"}})
|
|
79
|
+
)
|
|
80
|
+
assert spec().get_user("x")["error"]["code"] == "no_credits"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@respx.mock
|
|
84
|
+
def test_a_bad_key_raises_because_no_model_can_repair_it():
|
|
85
|
+
respx.get(f"{BASE}/v1/users/x").mock(
|
|
86
|
+
return_value=httpx.Response(401, json={"error": {"code": "invalid_key", "message": "unknown key"}})
|
|
87
|
+
)
|
|
88
|
+
with pytest.raises(XdataapiAuthError):
|
|
89
|
+
spec().get_user("x")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@respx.mock
|
|
93
|
+
def test_a_rate_limit_raises_so_the_framework_can_retry():
|
|
94
|
+
respx.get(f"{BASE}/v1/users/x").mock(
|
|
95
|
+
return_value=httpx.Response(429, json={"error": {"code": "too_many_requests"}})
|
|
96
|
+
)
|
|
97
|
+
with pytest.raises(XdataapiError):
|
|
98
|
+
spec().get_user("x")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@respx.mock
|
|
102
|
+
def test_balance_reads_me():
|
|
103
|
+
route = respx.get(f"{BASE}/v1/me").mock(return_value=httpx.Response(200, json={"balance": 4679.87}))
|
|
104
|
+
assert spec().get_balance()["balance"] == 4679.87
|
|
105
|
+
assert route.called
|