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 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,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 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
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.1.2",
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