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 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
- ## [Unreleased]
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.** Video cannot be sent to a chat model, so video posts
130
- are represented by their poster frame and this limitation is disclosed.
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 only (chat models cannot ingest video) |
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.1.1",
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": [
@@ -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
- /** Read a body while enforcing a byte cap, so an oversized response is never fully buffered. */
7
- async function readCapped(response: Response, limit: number): Promise<Uint8Array | undefined> {
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 undefined;
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 undefined;
27
+ return false;
22
28
  }
23
- chunks.push(value);
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 readCapped(response, maxBytes);
109
+ const bytes = await readCappedBytes(response, maxBytes);
92
110
  if (!bytes) return undefined;
93
111
  return { data: toBase64(bytes), mimeType };
94
112
  } catch {
@@ -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. */
@@ -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: { complete, fetchMedia: createFetchMedia(fetcher, options.signal) },
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: { complete: backend.complete, fetchMedia: createFetchMedia(backend.fetcher, options.signal) },
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
  }
@@ -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 message = await run.call(
54
- registry,
55
- model as never,
56
- {
57
- systemPrompt: request.system,
58
- messages: [
59
- {
60
- role: "user",
61
- content:
62
- request.images.length > 0
63
- ? [
64
- { type: "text", text: promptText },
65
- ...request.images.map((image) => ({
66
- type: "image",
67
- data: image.data,
68
- mimeType: image.mimeType,
69
- })),
70
- ]
71
- : request.prompt,
72
- timestamp: Date.now(),
73
- },
74
- ],
75
- } as never,
76
- { signal: request.signal } as never,
77
- );
78
- return completionText(message);
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