pi-twitterapi.io 0.1.2 → 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 +51 -3
- 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,7 +252,7 @@ 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
258
|
| Cost | xAI tokens + per post/profile | twitterapi.io credits + your model's tokens — [cost comparison](docs/pricing-comparison.md) |
|
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
|
|