pi-twitterapi.io 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -1
- package/README.md +57 -4
- package/docs/pricing-comparison.md +167 -0
- package/package.json +6 -2
- package/src/backend/media.ts +25 -7
- package/src/backend/model.ts +3 -0
- package/src/backend/runs.ts +39 -6
- package/src/backend/synthesis.ts +67 -26
- package/src/backend/video.ts +1007 -0
- package/src/config.ts +181 -7
- package/src/index.ts +13 -0
- package/src/synthesize.ts +183 -45
- package/src/tool.ts +15 -3
- package/src/twitterapi/core.ts +5 -0
- package/src/twitterapi/tweet.ts +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,60 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.2.0] - 2026-10-04
|
|
9
|
+
|
|
10
|
+
Opt-in real video understanding. **Off by default:** without
|
|
11
|
+
`enableVideoProcessing`, behaviour is unchanged and a video post is still
|
|
12
|
+
represented by its poster frame.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Real video processing (`enableVideoProcessing`, which also requires
|
|
17
|
+
`enableVideoUnderstanding`): native video through a Gemini endpoint
|
|
18
|
+
(`videoEndpointType: gemini-files`, `videoModel`, `videoApiKeyEnv`), and/or
|
|
19
|
+
frames via `ffmpegPath` plus a transcript from a remote STT endpoint
|
|
20
|
+
(`sttEndpoint`, `sttModel`, `sttApiKeyEnv`, `sttLanguage`) or local whisper.cpp
|
|
21
|
+
(`whisperCppBinary`, `whisperModelPath`).
|
|
22
|
+
- Bounds: `maxVideoSeconds` (120), `maxVideoBytes` (32 MiB), `maxFrames` (8),
|
|
23
|
+
`maxVideosPerSearch` (1), `videoBudgetMs` (180 s, max 300 s).
|
|
24
|
+
- The method actually used is disclosed in the answer: `gemini-native`,
|
|
25
|
+
`frames+stt`, `stt-only`, `frames-only` or `transcript-only`.
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
|
|
29
|
+
- Executable paths, endpoints and credential env names are read from **user
|
|
30
|
+
settings only**; project-level values for those keys are ignored and disclosed.
|
|
31
|
+
- A custom `videoEndpoint` is used only when `videoApiKeyEnv` is set explicitly
|
|
32
|
+
and the URL is `https://`, and that authorization is re-checked at the adapter
|
|
33
|
+
boundary.
|
|
34
|
+
- ffmpeg is never handed a URL (`-nostdin`, `-protocol_whitelist file`) and media
|
|
35
|
+
downloads stay on the SSRF allowlist, with redirects refused on authenticated
|
|
36
|
+
requests.
|
|
37
|
+
|
|
38
|
+
### Behaviour notes
|
|
39
|
+
|
|
40
|
+
- A native upload happens only when the clip is provably inside
|
|
41
|
+
`maxVideoSeconds`: it is trimmed locally first, and the native path is skipped
|
|
42
|
+
with a disclosure when it cannot be bounded. A trimming failure never falls
|
|
43
|
+
back to uploading the whole clip.
|
|
44
|
+
- The native reply is requested as structured JSON. A reply that ignores that is
|
|
45
|
+
kept whole as visual evidence, so a transcript is never inferred from prose;
|
|
46
|
+
STT can still recover the speech.
|
|
47
|
+
- Gemini Files uploads are deleted on a best-effort basis, including when
|
|
48
|
+
generation failed or was cancelled; a file that could not be deleted is
|
|
49
|
+
disclosed as possibly retained (Google keeps undeleted uploads for ~48 hours).
|
|
50
|
+
- Worst case a single video call can take several minutes.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- Video variants are now selected from a real `HEAD` request instead of the
|
|
55
|
+
advertised bitrate. That bitrate is a target rather than an average and
|
|
56
|
+
overstates the file by roughly 3x, so the old estimate picked a needlessly low
|
|
57
|
+
resolution (640x360 where 1280x720 fitted) and could misjudge both caps.
|
|
58
|
+
- The video-phase budget defaults to 180 s and is capped at 300 s, up from 90 s
|
|
59
|
+
and 120 s. Live measurement showed one provider call over a 65 s clip taking
|
|
60
|
+
33 s to ~71 s with run-to-run variance, so the old ceiling aborted long videos
|
|
61
|
+
and silently fell back to the poster frame.
|
|
9
62
|
|
|
10
63
|
## [0.1.1] - 2026-10-03
|
|
11
64
|
|
package/README.md
CHANGED
|
@@ -61,6 +61,51 @@ override — and set only the keys you need:
|
|
|
61
61
|
| `maxPagesCeiling` | no | Hard cap that `maxPages` is clamped to (default 20). |
|
|
62
62
|
| `minRequestIntervalMs` | no | Minimum spacing between upstream requests (default 5000). twitterapi.io allows 0.2 QPS on unpaid accounts; raise it if you are being throttled, lower it for a higher-QPS tier, or set it to 0 to disable pacing. |
|
|
63
63
|
| `retryBaseDelayMs` | no | Base delay for retry backoff (default 5000). |
|
|
64
|
+
| `enableVideoProcessing` | no | Run **real** video processing (native video and/or frames + transcript). Requires `enableVideoUnderstanding`. Off by default. |
|
|
65
|
+
| `videoEndpointType` | no | Native-video wire format: `gemini-files` (default) or `openai-compatible` (unverified). |
|
|
66
|
+
| `videoEndpoint` / `videoModel` / `videoApiKeyEnv` | no | Native-video endpoint, model, and the **env var name** holding the key (default `GOOGLE_API_KEY`). `gemini-files` targets Google unless `videoEndpoint` is set. |
|
|
67
|
+
| `sttEndpoint` / `sttModel` / `sttApiKeyEnv` | no | OpenAI-compatible speech-to-text endpoint, model, and key env var (default `STT_API_KEY`). No hidden default provider. |
|
|
68
|
+
| `sttLanguage` | no | ISO-639-1 language for STT, or `auto` (default). |
|
|
69
|
+
| `ffmpegPath` | no | ffmpeg binary override; otherwise `ffmpeg` is searched on `PATH`. ffmpeg must be installed locally (no bundled binary). |
|
|
70
|
+
| `whisperCppBinary` / `whisperModelPath` | no | Local whisper.cpp binary and GGML model (both user-installed). |
|
|
71
|
+
| `maxVideoSeconds` / `maxVideoBytes` / `maxFrames` / `maxVideosPerSearch` / `videoBudgetMs` | no | Video bounds: duration guard (120), download cap (32 MiB), frames per video (8), videos per search (1), time budget (180 s, max 300 s). |
|
|
72
|
+
|
|
73
|
+
> **Video processing is opt-in and local-tooling first.** It needs
|
|
74
|
+
> `enableVideoUnderstanding: true` **and** `enableVideoProcessing: true`, plus a
|
|
75
|
+
> locally installed `ffmpeg` (for frames/audio) and optionally whisper.cpp, or a
|
|
76
|
+
> configured native-video / STT endpoint. In v1 native video goes to **Gemini
|
|
77
|
+
> only** (`gemini-files`); frames are sent to your pi model; `openai-compatible`
|
|
78
|
+
> video is **not** enabled pending verification (Grok cannot take video input at
|
|
79
|
+
> all). Sending video/audio to a third-party endpoint is disclosed in the answer.
|
|
80
|
+
> The native request asks for structured JSON (`responseMimeType:
|
|
81
|
+
> application/json`), so the model names the visual/transcript sections itself. A
|
|
82
|
+
> reply that ignores that is kept whole as visual evidence: the transcript is
|
|
83
|
+
> never inferred from prose (an invented transcript would be published as
|
|
84
|
+
> evidence), and STT still recovers the real speech when it is configured.
|
|
85
|
+
> Variants are chosen from a real `HEAD` request rather than the advertised
|
|
86
|
+
> bitrate, which overstates the file by roughly 3x (a nominal 2176 kbps clip
|
|
87
|
+
> measured 6.16 MB where the bitrate suggests 19.6 MB).
|
|
88
|
+
> Executable paths, endpoints and credential names are read from **user
|
|
89
|
+
> settings only** — project `.pi/settings.json` values for those keys are
|
|
90
|
+
> ignored and disclosed. A custom `videoEndpoint` is only honoured when
|
|
91
|
+
> `videoApiKeyEnv` is set explicitly (and must be `https://`), so the default
|
|
92
|
+
> key is never sent to another host.
|
|
93
|
+
>
|
|
94
|
+
> **Retention and duration.** When native video uses the Gemini Files API the
|
|
95
|
+
> upload is deleted on a **best-effort** basis once the call finishes, with its
|
|
96
|
+
> own short timeout so a cancelled request cannot skip it. If deletion fails — or
|
|
97
|
+
> an upload happened but generation failed, returned nothing, or was cancelled —
|
|
98
|
+
> the answer says the file may be retained. Google's Files API keeps undeleted
|
|
99
|
+
> uploads for roughly **48 hours**, so treat such a file as readable by that
|
|
100
|
+
> project for about that long. Only the first `maxVideoSeconds` (default 120 s)
|
|
101
|
+
> are analysed: the video is trimmed locally when possible, and a clip that
|
|
102
|
+
> **cannot** be trimmed is not uploaded whole — the native path is skipped and
|
|
103
|
+
> disclosed, while frames and audio stay limited to that window. Worst case a
|
|
104
|
+
> single video call can take several minutes (retrieval pacing + 60 s media phase
|
|
105
|
+
> + up to `videoBudgetMs` video phase + synthesis), and the budget defaults to
|
|
106
|
+
> 180 s and caps at 300 s. Provider video analysis is the slow part and its
|
|
107
|
+
> latency varies: measured live, one call over a 65 s clip took 33 s once and
|
|
108
|
+
> ~71 s another time, so expect a long tool call on a media-heavy query.
|
|
64
109
|
|
|
65
110
|
> **Important:** the extension needs a pi version whose `ModelRegistry.complete`
|
|
66
111
|
> exists — it is absent on pi 0.80.6, present from pi 0.99.2, and verified on
|
|
@@ -126,8 +171,11 @@ than silently ignored.
|
|
|
126
171
|
dropped from `Sources` and counted in the `## Notes` section. Non-X links are
|
|
127
172
|
outside the citation contract: they are neither published as sources nor
|
|
128
173
|
counted as invented citations.
|
|
129
|
-
- **Media is best-effort.**
|
|
130
|
-
|
|
174
|
+
- **Media is best-effort.** By default a video post is represented by its poster
|
|
175
|
+
frame and the limitation is disclosed, because a chat model cannot ingest
|
|
176
|
+
video. With `enableVideoProcessing` (see above) the video itself is analysed —
|
|
177
|
+
locally trimmed frames/audio, and/or the configured native-video or STT
|
|
178
|
+
endpoint — and the poster is kept only as the fallback when that yields nothing.
|
|
131
179
|
- **Partial retrieval is disclosed.** If paging stops early (page cap, cursor
|
|
132
180
|
cycle, or a missing cursor while more results remain), the answer carries a
|
|
133
181
|
note saying the results may be incomplete.
|
|
@@ -204,12 +252,17 @@ version, including parameter mapping, lives in
|
|
|
204
252
|
| Result order | chosen by the model | `queryType` (`Latest`/`Top`), `replySort` |
|
|
205
253
|
| Item-count control | ❌ | ✅ `count`, `limit` |
|
|
206
254
|
| Image understanding | ✅ `enable_image_understanding` | ✅ `enableImageUnderstanding` (attached when the model accepts images) |
|
|
207
|
-
| Video understanding | ✅ `enable_video_understanding` | ⚠️ poster frame
|
|
255
|
+
| Video understanding | ✅ `enable_video_understanding` | ⚠️ poster frame by default; opt-in `enableVideoProcessing` adds native video (Gemini) and/or frames + STT |
|
|
208
256
|
| Answer generation | Grok (xAI) | any pi model: `twitter.synthesisModel`, else the session model |
|
|
209
257
|
| Citations | xAI annotations/citations | derived from fetched permalinks; unmatched X links dropped and disclosed |
|
|
210
|
-
| Cost | xAI tokens + per post/profile | twitterapi.io credits + your model's tokens |
|
|
258
|
+
| Cost | xAI tokens + per post/profile | twitterapi.io credits + your model's tokens — [cost comparison](docs/pricing-comparison.md) |
|
|
211
259
|
| Shape | one `x_search` request | one `twitter` tool with 17 modes |
|
|
212
260
|
|
|
261
|
+
Per-item costs differ by more than an order of magnitude, and the two routes
|
|
262
|
+
meter different things: [a dated, sourced cost comparison](docs/pricing-comparison.md)
|
|
263
|
+
covers the unit prices, the counting rules that change the bill, cost per mode,
|
|
264
|
+
and how to add the answer model's tokens.
|
|
265
|
+
|
|
213
266
|
**Summary.** `pi-twitterapi.io` matches `x_search` on keyword search, user search,
|
|
214
267
|
thread fetch, handle filters, date ranges and image understanding, and adds a
|
|
215
268
|
dedicated account timeline, trends, replies, quotes, mentions, followers,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# What each route costs
|
|
2
|
+
|
|
3
|
+
A per-item cost comparison between [twitterapi.io](https://twitterapi.io/pricing)
|
|
4
|
+
(retrieval behind this extension's `twitter` tool) and
|
|
5
|
+
[xAI's `x_search`](https://docs.x.ai/developers/pricing#tool-invocation-costs),
|
|
6
|
+
including the counting rules that change the bill and how to add the cost of the
|
|
7
|
+
answer itself.
|
|
8
|
+
|
|
9
|
+
**Verified on 2026-10-03** against the two pricing pages and the per-endpoint
|
|
10
|
+
documentation linked below. Prices change: re-verify before relying on any number
|
|
11
|
+
here, and treat every ratio as a snapshot rather than a guarantee.
|
|
12
|
+
|
|
13
|
+
## The two routes bundle different things
|
|
14
|
+
|
|
15
|
+
This matters more than any per-item rate, because the totals are not
|
|
16
|
+
like-for-like:
|
|
17
|
+
|
|
18
|
+
| | `pi-twitterapi.io` | xAI `x_search` |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Retrieval | twitterapi.io REST API, billed per item | xAI's server-side X index, billed per item |
|
|
21
|
+
| The answer | **your** pi model (`twitter.synthesisModel`, else the session model) | Grok, in the same request |
|
|
22
|
+
| Billing boundary | retrieval + your model's tokens, separately | retrieval + Grok's tokens, together |
|
|
23
|
+
| Who decides how much is fetched | you (`count`, `limit`, `maxPages`, `pageSize`) | the model, autonomously |
|
|
24
|
+
|
|
25
|
+
Both sides bill the answer as tokens. The difference is *whose* tokens: here you
|
|
26
|
+
choose the model and therefore the rate, while `x_search` uses a Grok model and
|
|
27
|
+
the docs note that "since the agent autonomously decides how many tools to call,
|
|
28
|
+
costs scale with query complexity."
|
|
29
|
+
|
|
30
|
+
## Unit prices
|
|
31
|
+
|
|
32
|
+
twitterapi.io prices in credits, where **100,000 credits = $1.00**
|
|
33
|
+
(1 credit ≈ $0.00001). xAI prices per item fetched, in addition to tokens.
|
|
34
|
+
|
|
35
|
+
| Billable unit | twitterapi.io | xAI `x_search` | Ratio |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| Posts / tweets | **$0.15 / 1K** (15 credits each) | **$5 / 1K** | **33× cheaper** |
|
|
38
|
+
| Profiles / users | **$0.18 / 1K** (18 credits each) | **$10 / 1K** | **56× cheaper** |
|
|
39
|
+
| Followers / followings | $0.01–0.03 / 1K, tiered by page size | not offered | — |
|
|
40
|
+
| Follower IDs (bulk) | from $0.0045 / 1K, tiered | not offered | — |
|
|
41
|
+
| Minimum per call | $0.00015 (15 credits); 60 credits for the follower endpoints | none listed — per item | — |
|
|
42
|
+
| List calls | $0.0015 (150 credits) per call | — | — |
|
|
43
|
+
| Images / video in posts | tokens on your model | tokens (`view_image` / `view_x_video`) | — |
|
|
44
|
+
|
|
45
|
+
## Counting rules that change the bill
|
|
46
|
+
|
|
47
|
+
**xAI `x_search`** is billed per item fetched, not per call:
|
|
48
|
+
|
|
49
|
+
- every post returned by a search **or a thread fetch** counts toward the post
|
|
50
|
+
rate, **including parent and quoted posts**;
|
|
51
|
+
- every profile returned by a user search counts toward the profile rate;
|
|
52
|
+
- counts accumulate across all X Search calls in one request and are **not
|
|
53
|
+
de-duplicated** — a post returned by two searches is billed twice;
|
|
54
|
+
- the docs say per-item pricing was "in effect as of September 21, 2026".
|
|
55
|
+
|
|
56
|
+
**twitterapi.io** bills per item returned, with floors:
|
|
57
|
+
|
|
58
|
+
- a call returning 0 or 1 tweet still costs the 15-credit minimum ($0.00015);
|
|
59
|
+
- follower/following calls have their own tier table and a 60-credit ($0.0006)
|
|
60
|
+
minimum, because the smallest page is 20 items at 3 credits each;
|
|
61
|
+
- follower and following pricing *falls* as the page grows: 3 credits per item at
|
|
62
|
+
20–99 returned, 2 at 100–199, and 1 at a full 200-item page. That means
|
|
63
|
+
`pageSize` is a price control, not just a pagination control;
|
|
64
|
+
- credits never expire, and recharges add bonus credits (valid 30 days) plus up
|
|
65
|
+
to 5% off at larger amounts.
|
|
66
|
+
|
|
67
|
+
## Cost per mode
|
|
68
|
+
|
|
69
|
+
The `twitter` tool has 17 modes. Each maps to one twitterapi.io endpoint and one
|
|
70
|
+
billable unit. "Quoted" means the rate appears in the linked official source;
|
|
71
|
+
"inferred" means the unit follows from the endpoint's documented return type, but
|
|
72
|
+
that endpoint's page does not restate a price.
|
|
73
|
+
|
|
74
|
+
| Mode | Endpoint | Billable unit | Rate | Basis |
|
|
75
|
+
|---|---|---|---|---|
|
|
76
|
+
| `posts` (default) | `/twitter/tweet/advanced_search` | tweets returned | $0.15 / 1K | inferred from the tweet unit rate |
|
|
77
|
+
| `users` | `/twitter/user/search` | profiles returned | $0.18 / 1K | inferred (endpoint returns user objects) |
|
|
78
|
+
| `thread` | `/twitter/tweet/thread_context` | tweets returned | $0.15 / 1K | inferred |
|
|
79
|
+
| `user` | `/twitter/user/last_tweets` | tweets returned | $0.15 / 1K | inferred |
|
|
80
|
+
| `replies` | `/twitter/tweet/replies/v2` | tweets returned | $0.15 / 1K | inferred |
|
|
81
|
+
| `quotes` | `/twitter/tweet/quotes` | tweets returned | $0.15 / 1K | inferred |
|
|
82
|
+
| `mentions` | `/twitter/user/mentions` | tweets returned | $0.15 / 1K | inferred |
|
|
83
|
+
| `tweets` | `/twitter/tweets` | tweets returned | $0.15 / 1K | inferred |
|
|
84
|
+
| `retweeters` | `/twitter/tweet/retweeters` | users returned | $0.18 / 1K | inferred |
|
|
85
|
+
| `community` | `/twitter/community/tweets` | tweets returned | $0.15 / 1K | inferred |
|
|
86
|
+
| `profile` | `/twitter/user/info` | profiles returned | $0.18 / 1K | inferred from the profile unit rate |
|
|
87
|
+
| `followers` | `/twitter/user/followers` | followers returned | $0.01–0.03 / 1K, tiered; 60-credit minimum | **quoted** (endpoint doc) |
|
|
88
|
+
| `followings` | `/twitter/user/followings` | followings returned | $0.01–0.03 / 1K, tiered; 60-credit minimum | **quoted** (endpoint doc) |
|
|
89
|
+
| `list` | `/twitter/list/tweets_timeline` | per call | $0.0015 (150 credits) | **quoted** for "list function calls"; whether this endpoint is included is not explicit |
|
|
90
|
+
| `trends` | `/twitter/trends` | not published | **unverified** | no price on the pricing page or endpoint doc |
|
|
91
|
+
| `about` | `/twitter/user_about` | not published | **unverified** | no price on the pricing page or endpoint doc |
|
|
92
|
+
| `space` | `/twitter/spaces/detail` | not published | **unverified** | no price on the pricing page or endpoint doc |
|
|
93
|
+
|
|
94
|
+
Every call is subject to the 15-credit ($0.00015) minimum unless the response
|
|
95
|
+
qualifies as bulk data.
|
|
96
|
+
|
|
97
|
+
## Worked examples (retrieval only)
|
|
98
|
+
|
|
99
|
+
Assumes pages full enough that no floor applies, and no media attached.
|
|
100
|
+
|
|
101
|
+
| Scenario | twitterapi.io | xAI `x_search` |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Keyword search, 200 posts returned | 200 × 15 = 3,000 credits = **$0.030** | 200/1K × $5 = **$1.00** |
|
|
104
|
+
| Read a 40-post thread | 40 × 15 = 600 credits = **$0.006** | 40/1K × $5 = **$0.20** (every thread post counts) |
|
|
105
|
+
| 1,000 followers at a full 200-item page | 1,000 × 1 credit = **$0.01** | not offered |
|
|
106
|
+
| One profile lookup | 18 credits = **$0.00018** | 1/1K × $10 = **$0.01** |
|
|
107
|
+
|
|
108
|
+
On this surface the retrieval side is 33× cheaper for posts and 56× cheaper for
|
|
109
|
+
profiles. The gap in the other direction is capability, not price: `x_search`
|
|
110
|
+
also offers semantic search, which twitterapi.io has no equivalent for, and it
|
|
111
|
+
answers in the same call instead of handing the posts to a model you pay for.
|
|
112
|
+
|
|
113
|
+
## Adding the answer
|
|
114
|
+
|
|
115
|
+
Retrieval is only part of the bill. For both routes, the answer costs the tokens
|
|
116
|
+
the model reads and writes:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
answer cost = (input tokens × input rate + output tokens × output rate) / 1,000,000
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **Here**, that is your pi model. A synthesized answer feeds retrieved posts
|
|
123
|
+
into one prompt, so a search returning 200 posts is a large input prompt; set
|
|
124
|
+
`twitter.synthesisModel` to whatever model you are willing to pay for.
|
|
125
|
+
- **With `x_search`**, the same arithmetic applies at the Grok model's rates, and
|
|
126
|
+
reasoning tokens are billed too.
|
|
127
|
+
|
|
128
|
+
Neither side's total can be stated as a single number, because token volume
|
|
129
|
+
depends on how much text was retrieved and how long the answer is. Worked
|
|
130
|
+
example with a hypothetical rate: at $1 per 1M input tokens, a 20,000-token
|
|
131
|
+
synthesis prompt costs $0.02 — which is comparable to, or larger than, the
|
|
132
|
+
retrieval cost of a 200-post search in the table above. That is why this document
|
|
133
|
+
leads with per-item rates and refuses to quote a single "total per search".
|
|
134
|
+
|
|
135
|
+
Media behaves the same way on both sides: attached images are token costs, not
|
|
136
|
+
per-item charges. This extension attaches post images, and video posts as their
|
|
137
|
+
poster frame, only when the configured model accepts image input.
|
|
138
|
+
|
|
139
|
+
## What this comparison excludes
|
|
140
|
+
|
|
141
|
+
- twitterapi.io's optional subscription and recharge bonus credits, which lower
|
|
142
|
+
the effective rate further, and its trial credit;
|
|
143
|
+
- the official X API's own read prices (reported at roughly $0.005 per post read),
|
|
144
|
+
which are a baseline rather than a route this extension can use;
|
|
145
|
+
- enterprise agreements, and write/post endpoints that this extension never calls;
|
|
146
|
+
- the token cost of the agent conversation itself, which is identical on both
|
|
147
|
+
sides of this comparison and belongs to pi, not to the retrieval route.
|
|
148
|
+
|
|
149
|
+
## Sources
|
|
150
|
+
|
|
151
|
+
- twitterapi.io pricing: <https://twitterapi.io/pricing>
|
|
152
|
+
- twitterapi.io followers endpoint (tier table and minimum):
|
|
153
|
+
<https://docs.twitterapi.io/api-reference/endpoint/get_user_followers>
|
|
154
|
+
- twitterapi.io followings endpoint:
|
|
155
|
+
<https://docs.twitterapi.io/api-reference/endpoint/get_user_followings>
|
|
156
|
+
- xAI pricing, tool invocation costs:
|
|
157
|
+
<https://docs.x.ai/developers/pricing#tool-invocation-costs>
|
|
158
|
+
- xAI `x_search` tool page (per-item rates, what counts as a fetched post, usage
|
|
159
|
+
counters): <https://docs.x.ai/developers/tools/x-search>
|
|
160
|
+
|
|
161
|
+
## Reconciling your own spend
|
|
162
|
+
|
|
163
|
+
twitterapi.io reports credit usage per key in its dashboard. For `x_search`, each
|
|
164
|
+
Responses API response reports `x_posts_fetched` and `x_users_fetched` under
|
|
165
|
+
`usage.server_side_tool_usage_details`, which is what the per-item bill is
|
|
166
|
+
computed from — and what to check if a request costs more than expected, since
|
|
167
|
+
the model decides how many searches to run.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-twitterapi.io",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "twitterapi.io-backed X/Twitter search extension for pi coding agent",
|
|
5
5
|
"author": "José Antonio Galiano Sandoval",
|
|
6
6
|
"keywords": [
|
|
@@ -20,12 +20,16 @@
|
|
|
20
20
|
"url": "https://github.com/jagaliano/pi-twitterapi.io/issues"
|
|
21
21
|
},
|
|
22
22
|
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
23
26
|
"files": [
|
|
24
27
|
"src",
|
|
25
28
|
"!src/**/*.test.ts",
|
|
26
29
|
"README.md",
|
|
27
30
|
"CHANGELOG.md",
|
|
28
|
-
"docs"
|
|
31
|
+
"docs",
|
|
32
|
+
"!docs/video-spike.md"
|
|
29
33
|
],
|
|
30
34
|
"pi": {
|
|
31
35
|
"extensions": [
|
package/src/backend/media.ts
CHANGED
|
@@ -3,12 +3,18 @@ import { toBase64, type ImageAttachment } from "../synthesize.js";
|
|
|
3
3
|
const MAX_MEDIA_BYTES = 8 * 1024 * 1024;
|
|
4
4
|
const MEDIA_TIMEOUT_MS = 20_000;
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/** Receives streamed body chunks; may return a promise to apply backpressure. */
|
|
7
|
+
export type ChunkSink = (chunk: Uint8Array) => void | Promise<void>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stream a response body into `onChunk`, enforcing a byte cap, so an oversized
|
|
11
|
+
* response is never fully buffered. Returns `false` (and cancels the body) when
|
|
12
|
+
* the cap is exceeded, `true` when the body was read to the end.
|
|
13
|
+
*/
|
|
14
|
+
export async function readCapped(response: Response, limit: number, onChunk: ChunkSink): Promise<boolean> {
|
|
8
15
|
const body = response.body;
|
|
9
|
-
if (!body) return
|
|
16
|
+
if (!body) return false;
|
|
10
17
|
const reader = body.getReader();
|
|
11
|
-
const chunks: Uint8Array[] = [];
|
|
12
18
|
let total = 0;
|
|
13
19
|
try {
|
|
14
20
|
for (;;) {
|
|
@@ -18,13 +24,25 @@ async function readCapped(response: Response, limit: number): Promise<Uint8Array
|
|
|
18
24
|
total += value.byteLength;
|
|
19
25
|
if (total > limit) {
|
|
20
26
|
await reader.cancel().catch(() => undefined);
|
|
21
|
-
return
|
|
27
|
+
return false;
|
|
22
28
|
}
|
|
23
|
-
|
|
29
|
+
await onChunk(value);
|
|
24
30
|
}
|
|
25
31
|
} finally {
|
|
26
32
|
reader.releaseLock();
|
|
27
33
|
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Buffer a capped body into bytes (images). Returns undefined when the cap is exceeded. */
|
|
38
|
+
async function readCappedBytes(response: Response, limit: number): Promise<Uint8Array | undefined> {
|
|
39
|
+
const chunks: Uint8Array[] = [];
|
|
40
|
+
const ok = await readCapped(response, limit, (chunk) => {
|
|
41
|
+
chunks.push(chunk);
|
|
42
|
+
});
|
|
43
|
+
if (!ok) return undefined;
|
|
44
|
+
let total = 0;
|
|
45
|
+
for (const chunk of chunks) total += chunk.byteLength;
|
|
28
46
|
const bytes = new Uint8Array(total);
|
|
29
47
|
let offset = 0;
|
|
30
48
|
for (const chunk of chunks) {
|
|
@@ -88,7 +106,7 @@ export function createFetchMedia(fetcher: typeof fetch, callerSignal?: AbortSign
|
|
|
88
106
|
if (!mimeType.startsWith("image/")) return undefined;
|
|
89
107
|
const declared = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
90
108
|
if (Number.isFinite(declared) && declared > maxBytes) return undefined;
|
|
91
|
-
const bytes = await
|
|
109
|
+
const bytes = await readCappedBytes(response, maxBytes);
|
|
92
110
|
if (!bytes) return undefined;
|
|
93
111
|
return { data: toBase64(bytes), mimeType };
|
|
94
112
|
} catch {
|
package/src/backend/model.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TwitterConfig } from "../config.js";
|
|
2
2
|
import type { SynthesisModel } from "../synthesize.js";
|
|
3
|
+
import type { ExecFn } from "./video.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Minimal structural view of pi's ModelRegistry, so this module stays testable
|
|
@@ -37,6 +38,8 @@ export interface BackendOptions {
|
|
|
37
38
|
fallbackModelIds?: string[];
|
|
38
39
|
/** Override the retry delay between last-model attempts (tests). */
|
|
39
40
|
synthesisSleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
41
|
+
/** Injected local-process runner for video processing (tests). */
|
|
42
|
+
videoExec?: ExecFn;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
/** Resolve a `provider/model` spec, or a bare model id, against the registry. */
|
package/src/backend/runs.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
synthesizeDocument,
|
|
7
7
|
synthesizeTrends,
|
|
8
8
|
synthesizeUserAnswer,
|
|
9
|
+
type SynthesisDeps,
|
|
9
10
|
} from "../synthesize.js";
|
|
10
11
|
import {
|
|
11
12
|
fetchCommunityTweets,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
} from "../twitterapi.js";
|
|
34
35
|
import { toSynthesisModel, type TwitterApiSynthesisOptions } from "./model.js";
|
|
35
36
|
import { createFetchMedia } from "./media.js";
|
|
37
|
+
import { createProcessVideo } from "./video.js";
|
|
36
38
|
import { applyFallbackNote, resolveSynthesisBackend, type SynthesisBackend } from "./synthesis.js";
|
|
37
39
|
|
|
38
40
|
export interface TwitterApiRunOptions extends TwitterApiSynthesisOptions {
|
|
@@ -81,6 +83,32 @@ function incompleteReason(stoppedBy: string | undefined, pages: number): string
|
|
|
81
83
|
return undefined;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
/** Build synthesis deps, wiring the optional bound video pre-processor (M5). */
|
|
87
|
+
function mediaDeps(
|
|
88
|
+
backend: Pick<SynthesisBackend, "complete" | "fetcher">,
|
|
89
|
+
options: TwitterApiSynthesisOptions,
|
|
90
|
+
): SynthesisDeps {
|
|
91
|
+
return {
|
|
92
|
+
complete: backend.complete,
|
|
93
|
+
fetchMedia: createFetchMedia(backend.fetcher, options.signal),
|
|
94
|
+
processVideo: options.config.enableVideoProcessing
|
|
95
|
+
? createProcessVideo({
|
|
96
|
+
fetcher: options.fetcher ?? fetch,
|
|
97
|
+
env: options.env ?? {},
|
|
98
|
+
signal: options.signal,
|
|
99
|
+
exec: options.videoExec,
|
|
100
|
+
})
|
|
101
|
+
: undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Append config-level disclosures (ignored project keys, switch warnings). */
|
|
106
|
+
function appendConfigNotes(options: TwitterApiSynthesisOptions, details: TwitterSearchDetails): void {
|
|
107
|
+
if (options.config.configNotes.length > 0) {
|
|
108
|
+
details.notes = [...(details.notes ?? []), ...options.config.configNotes];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
/**
|
|
85
113
|
* Retrieve posts from twitterapi.io and synthesize the answer, returning the
|
|
86
114
|
* shared `{ markdown, details }` shape.
|
|
@@ -112,10 +140,7 @@ export async function runTwitterApiSearch(
|
|
|
112
140
|
model: toSynthesisModel(model),
|
|
113
141
|
signal: options.signal,
|
|
114
142
|
incomplete,
|
|
115
|
-
deps:
|
|
116
|
-
complete,
|
|
117
|
-
fetchMedia: createFetchMedia(fetcher, options.signal),
|
|
118
|
-
},
|
|
143
|
+
deps: mediaDeps(backend, options),
|
|
119
144
|
});
|
|
120
145
|
|
|
121
146
|
if (search.window?.shortfallHours) {
|
|
@@ -150,6 +175,7 @@ export async function runTwitterApiSearch(
|
|
|
150
175
|
];
|
|
151
176
|
}
|
|
152
177
|
applyFallbackNote(backend, details);
|
|
178
|
+
appendConfigNotes(options, details);
|
|
153
179
|
|
|
154
180
|
return { markdown: formatTwitterResults(details), details };
|
|
155
181
|
}
|
|
@@ -202,6 +228,7 @@ export async function runTwitterApiUserSearch(
|
|
|
202
228
|
];
|
|
203
229
|
}
|
|
204
230
|
applyFallbackNote(backend, details);
|
|
231
|
+
appendConfigNotes(options, details);
|
|
205
232
|
return { markdown: formatTwitterResults(details), details };
|
|
206
233
|
}
|
|
207
234
|
|
|
@@ -234,7 +261,7 @@ export async function runTwitterApiThread(
|
|
|
234
261
|
model: toSynthesisModel(model),
|
|
235
262
|
signal: options.signal,
|
|
236
263
|
incomplete,
|
|
237
|
-
deps:
|
|
264
|
+
deps: mediaDeps(backend, options),
|
|
238
265
|
});
|
|
239
266
|
details.notes = [
|
|
240
267
|
...(details.notes ?? []),
|
|
@@ -247,6 +274,7 @@ export async function runTwitterApiThread(
|
|
|
247
274
|
];
|
|
248
275
|
}
|
|
249
276
|
applyFallbackNote(backend, details);
|
|
277
|
+
appendConfigNotes(options, details);
|
|
250
278
|
return { markdown: formatTwitterResults(details), details };
|
|
251
279
|
}
|
|
252
280
|
|
|
@@ -265,10 +293,11 @@ async function completeTweetAnswer(
|
|
|
265
293
|
model: toSynthesisModel(backend.model),
|
|
266
294
|
signal: options.signal,
|
|
267
295
|
incomplete: input.incomplete,
|
|
268
|
-
deps:
|
|
296
|
+
deps: mediaDeps(backend, options),
|
|
269
297
|
});
|
|
270
298
|
details.notes = [...(details.notes ?? []), ...input.notes];
|
|
271
299
|
applyFallbackNote(backend, details);
|
|
300
|
+
appendConfigNotes(options, details);
|
|
272
301
|
return { markdown: formatTwitterResults(details), details };
|
|
273
302
|
}
|
|
274
303
|
|
|
@@ -392,6 +421,7 @@ export async function runTwitterApiTrends(
|
|
|
392
421
|
deps: { complete: backend.complete },
|
|
393
422
|
});
|
|
394
423
|
applyFallbackNote(backend, details);
|
|
424
|
+
appendConfigNotes(options, details);
|
|
395
425
|
return { markdown: formatTwitterResults(details), details };
|
|
396
426
|
}
|
|
397
427
|
|
|
@@ -414,6 +444,7 @@ async function completeUserAnswer(
|
|
|
414
444
|
});
|
|
415
445
|
details.notes = [...(details.notes ?? []), ...input.notes];
|
|
416
446
|
applyFallbackNote(backend, details);
|
|
447
|
+
appendConfigNotes(options, details);
|
|
417
448
|
return { markdown: formatTwitterResults(details), details };
|
|
418
449
|
}
|
|
419
450
|
|
|
@@ -556,6 +587,7 @@ export async function runTwitterApiAbout(
|
|
|
556
587
|
notes,
|
|
557
588
|
});
|
|
558
589
|
applyFallbackNote(backend, details);
|
|
590
|
+
appendConfigNotes(options, details);
|
|
559
591
|
return { markdown: formatTwitterResults(details), details };
|
|
560
592
|
}
|
|
561
593
|
|
|
@@ -683,5 +715,6 @@ export async function runTwitterApiSpace(
|
|
|
683
715
|
notes,
|
|
684
716
|
});
|
|
685
717
|
applyFallbackNote(backend, details);
|
|
718
|
+
appendConfigNotes(options, details);
|
|
686
719
|
return { markdown: formatTwitterResults(details), details };
|
|
687
720
|
}
|
package/src/backend/synthesis.ts
CHANGED
|
@@ -36,6 +36,37 @@ export function completionText(message: unknown): string {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
/** The only efforts a provider can legitimately name for a reasoning model. */
|
|
40
|
+
const REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recover the cheapest accepted reasoning effort from a provider rejection.
|
|
44
|
+
*
|
|
45
|
+
* Some models cannot be called with reasoning off at all — pi sends
|
|
46
|
+
* `reasoning_effort: 'none'` by default, and an `openai-responses` model that
|
|
47
|
+
* requires reasoning answers 400 with the accepted list. Retrying once with the
|
|
48
|
+
* first supported value keeps such models usable instead of failing synthesis
|
|
49
|
+
* (measured live against `opencode-go/muse-spark-1.3-contributor`).
|
|
50
|
+
*
|
|
51
|
+
* The rejection and the list must belong to the *same* sentence mentioning the
|
|
52
|
+
* reasoning effort, and the recovered value must be a real effort name: an
|
|
53
|
+
* unrelated 400 that happens to list its own values (for example
|
|
54
|
+
* `Invalid response_format. Supported values: [json, text]`) must not trigger a
|
|
55
|
+
* repair that would mask the original cause.
|
|
56
|
+
*/
|
|
57
|
+
export function supportedReasoningEffort(error: unknown): string | undefined {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
const rejected = /reasoning[_ ]effort[^.;]{0,120}?(?:not supported|unsupported|invalid)/i.test(message)
|
|
60
|
+
|| /(?:not supported|unsupported|invalid)[^.;]{0,120}?reasoning[_ ]effort/i.test(message);
|
|
61
|
+
if (!rejected) return undefined;
|
|
62
|
+
const list = /supported values?:?\s*\[([^\]]+)\]/i.exec(message)?.[1];
|
|
63
|
+
if (!list) return undefined;
|
|
64
|
+
return list
|
|
65
|
+
.split(",")
|
|
66
|
+
.map((value) => value.trim().replace(/^["']|["']$/g, "").toLowerCase())
|
|
67
|
+
.find((value) => (REASONING_EFFORTS as readonly string[]).includes(value));
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
/**
|
|
40
71
|
* Build the synthesis completion for a resolved model. Shared by every
|
|
41
72
|
* twitterapi.io path so failure handling cannot differ between them.
|
|
@@ -50,32 +81,42 @@ function createCompletion(
|
|
|
50
81
|
request.mediaManifest && request.images.length > 0
|
|
51
82
|
? `${request.prompt}\n\nAttached images, in order:\n${request.mediaManifest}`
|
|
52
83
|
: request.prompt;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
const attempt = (reasoningEffort?: string): Promise<unknown> =>
|
|
85
|
+
run.call(
|
|
86
|
+
registry,
|
|
87
|
+
model as never,
|
|
88
|
+
{
|
|
89
|
+
systemPrompt: request.system,
|
|
90
|
+
messages: [
|
|
91
|
+
{
|
|
92
|
+
role: "user",
|
|
93
|
+
content:
|
|
94
|
+
request.images.length > 0
|
|
95
|
+
? [
|
|
96
|
+
{ type: "text", text: promptText },
|
|
97
|
+
...request.images.map((image) => ({
|
|
98
|
+
type: "image",
|
|
99
|
+
data: image.data,
|
|
100
|
+
mimeType: image.mimeType,
|
|
101
|
+
})),
|
|
102
|
+
]
|
|
103
|
+
: request.prompt,
|
|
104
|
+
timestamp: Date.now(),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
} as never,
|
|
108
|
+
(reasoningEffort ? { signal: request.signal, reasoningEffort } : { signal: request.signal }) as never,
|
|
109
|
+
);
|
|
110
|
+
try {
|
|
111
|
+
return completionText(await attempt());
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// Authoritative cancellation wins: never spend another registry call on a
|
|
114
|
+
// repair after the caller has gone away (P2-2).
|
|
115
|
+
if (classifySynthesisError(error, request.signal) === "cancelled") throw error;
|
|
116
|
+
const effort = supportedReasoningEffort(error);
|
|
117
|
+
if (!effort) throw error;
|
|
118
|
+
return completionText(await attempt(effort));
|
|
119
|
+
}
|
|
79
120
|
};
|
|
80
121
|
}
|
|
81
122
|
|