pi-braid-telegram 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,574 @@
1
+ /**
2
+ * transcribe.ts — the "recognize" half of voice inbound,
3
+ * extended with the key-pool failover + quiet-audio retry orchestrator.
4
+ *
5
+ * Two provider paths, selected by `mode`:
6
+ *
7
+ * - `"chat"` (DEFAULT for this deployment): Venice `chat/input_audio`. POSTs
8
+ * the saved voice bytes as a base64 `input_audio` content part to
9
+ * `{baseUrl}/chat/completions` (Venice does NOT expose
10
+ * OpenAI's `/v1/audio/transcriptions` route). Reads the transcript from
11
+ * `choices[0].message.content`. Default baseUrl https://api.venice.ai/api/v1,
12
+ * default model gemini-3-8-flash; the configured `language`
13
+ * (BRAID_TG_STT_LANGUAGE) is threaded into the prompt (default "Russian").
14
+ *
15
+ * - `"transcriptions"`: OpenAI / groq / any OpenAI-compatible
16
+ * `/v1/audio/transcriptions` endpoint with the bytes as a multipart form
17
+ * (`file` Blob named voice.ogg, `model`, optional `language`).
18
+ *
19
+ * Both are provider-configurable via env (baseUrl / apiKey / model /
20
+ * language / mode); only the env config changes.
21
+ *
22
+ * Key failover (): `keys` carries the ordered candidate pool from
23
+ * stt-keys.ts; the request advances past 401/403/429 and quota-class error
24
+ * bodies to the next candidate, stops at the first success, and the winner
25
+ * is remembered process-lifetime (tried first next time). Observability
26
+ * names candidate index + source kind only — never key material.
27
+ *
28
+ * Retry orchestrator: `transcribeVoiceReliable` bounds inbound
29
+ * transcription to two rounds — the first round on the raw bytes; when it fails or
30
+ * returns an empty transcript and normalization is enabled, the second round on
31
+ * ffmpeg-amplified bytes. The final failure line carries endpoint host,
32
+ * round count, the last candidate index/source, the last error class, and
33
+ * the remediation hint.
34
+ *
35
+ * Transport retry: a request that THROWS instead of answering (no HTTP
36
+ * status — DNS/TCP/TLS) is retried once, on the same candidate, after
37
+ * `transportRetryDelayMs`. Nothing was learned about the key, so the pool is
38
+ * not advanced; one retry per round, not a loop.
39
+ *
40
+ * Pure + injectable-fetch driven: `fetchFn` defaults to globalThis.fetch, so
41
+ * tests substitute a recorded fixture fake with NO network. Never throws —
42
+ * any non-ok response or network failure returns `{ ok: false, error }` so
43
+ * the caller degrades instead of dropping the voice message.
44
+ */
45
+
46
+ import type { TelegramFetch } from "./telegram-client.js";
47
+ import {
48
+ type SttKeyCandidate,
49
+ getPreferredSttKey,
50
+ countSttAttempt,
51
+ countSttSuccess,
52
+ countSttFailure,
53
+ countSttNormalizationApplied,
54
+ countSttTransportRetry,
55
+ countSttTransportRetrySuccess,
56
+ } from "./stt-keys.js";
57
+ import { normalizeAudioBytes } from "./audio-normalize.js";
58
+
59
+ /** Default OpenAI-compatible STT base URL (the /audio/transcriptions route). */
60
+ export const DEFAULT_STT_BASE_URL = "https://api.openai.com/v1";
61
+ /** Default transcription model. */
62
+ export const DEFAULT_STT_MODEL = "whisper-1";
63
+ /** Venice input_audio chat-completions base URL (the deployment default). */
64
+ export const DEFAULT_STT_CHAT_BASE_URL = "https://api.venice.ai/api/v1";
65
+ // model default: gemini-3-8-flash — it held up stable twice on the quiet
66
+ // initial clip while gemini-3-5-flash-lite wavered once even after gain.
67
+ export const DEFAULT_STT_CHAT_MODEL = "gemini-3-8-flash";
68
+
69
+ /**
70
+ * Wait before the single transport-class retry (ms). A thrown request carries
71
+ * no HTTP status, so it says nothing about the key: the observed failure mode
72
+ * is a seconds-scale transport blip (the same bytes transcribed cleanly on a
73
+ * later call from the same host). 2 s is long enough to clear a DNS/TCP/TLS
74
+ * hiccup or a momentarily unavailable upstream, and short enough that a voice
75
+ * note still lands while the owner waits; the bound keeps the worst case at
76
+ * one extra pause per round.
77
+ */
78
+ export const DEFAULT_STT_TRANSPORT_RETRY_DELAY_MS = 2000;
79
+
80
+ /** Injectable normalization step (bytes in → amplified bytes out, or null). */
81
+ export type NormalizeBytesFn = (
82
+ bytes: Uint8Array,
83
+ ) => Promise<Uint8Array | null>;
84
+
85
+ /** STT (speech-to-text) config threaded to both inbound consumers. */
86
+ export interface SttConfig {
87
+ /** Base URL of the API (default by mode: Venice for chat, OpenAI for transcriptions). */
88
+ baseUrl?: string;
89
+ /** API key. Absent → voice is still delivered (path), with an "unavailable" note. */
90
+ apiKey?: string;
91
+ /** Model name (default by mode: gemini-3-8-flash for chat, whisper-1 for transcriptions). */
92
+ model?: string;
93
+ /** Optional source-language hint (both paths): appended as `language` on
94
+ * the transcriptions form AND threaded into the chat-mode prompt — default
95
+ * "Russian" (the deployment's spoken language). */
96
+ language?: string;
97
+ /**
98
+ * Provider path. "chat" = Venice input_audio /chat/completions (the
99
+ * deployment default); "transcriptions" = OpenAI /audio/transcriptions
100
+ * multipart (kept intact). transcribeVoice defaults to "transcriptions"
101
+ * when omitted (backward compatible); index.ts always resolves an explicit
102
+ * mode, defaulting to "chat".
103
+ */
104
+ mode?: "transcriptions" | "chat";
105
+ /** Ordered key pool (stt-keys.ts) for failover; apiKey stays the first hit. */
106
+ keys?: SttKeyCandidate[];
107
+ /** Quiet-audio retry gate: enabled + the amplification step. */
108
+ normalize?: { enabled: boolean; normalizeBytes?: NormalizeBytesFn };
109
+ }
110
+
111
+ /** True when an STT api key is present (transcription is possible). */
112
+ export function isSttConfigured(
113
+ config: { apiKey?: string; keys?: SttKeyCandidate[] } | undefined,
114
+ ): boolean {
115
+ return Boolean(config?.apiKey || (config?.keys && config.keys.length > 0));
116
+ }
117
+
118
+ /** Winning-candidate provenance on a successful transcription. */
119
+ export interface SttKeyProvenance {
120
+ index: number;
121
+ source: string;
122
+ }
123
+
124
+ /** Outcome of transcribeVoice: recognized text, or a degraded error note.
125
+ * Enriched with observability metadata (candidate provenance, attempt
126
+ * count, last status/error class) — all key-material-free. */
127
+ export type TranscribeResult =
128
+ | { ok: true; text: string; key?: SttKeyProvenance }
129
+ | {
130
+ ok: false;
131
+ error: string;
132
+ attempts?: number;
133
+ /** Transport-class retries issued in the last round (0 = none).
134
+ * Kept apart from `attempts`, which counts key-pool hits only. */
135
+ retries?: number;
136
+ candidateIndex?: number;
137
+ candidateSource?: string;
138
+ lastStatus?: number;
139
+ lastClass?: string;
140
+ };
141
+
142
+ /** Options for transcribeVoice. */
143
+ export interface TranscribeVoiceOptions {
144
+ /** The saved voice bytes (from materializeVoice). */
145
+ bytes: Uint8Array;
146
+ /** MIME type of the audio (default audio/ogg). */
147
+ mimeType?: string;
148
+ /** API base URL (default by mode: Venice for chat, OpenAI for transcriptions). */
149
+ baseUrl?: string;
150
+ /** API key (single-candidate form; keys takes precedence when set). */
151
+ apiKey?: string;
152
+ /** Ordered key pool — failover advances past auth/quota/rate failures. */
153
+ keys?: SttKeyCandidate[];
154
+ /** Model name (default by mode: gemini-3-8-flash for chat, whisper-1 for transcriptions). */
155
+ model?: string;
156
+ /** Optional source-language hint (both paths): appended as `language` on
157
+ * the transcriptions form AND threaded into the chat-mode prompt (default
158
+ * "Russian" for this deployment). */
159
+ language?: string;
160
+ /** Provider path: "chat" (Venice input_audio, default for this deployment)
161
+ * or "transcriptions" (OpenAI multipart). Defaults to "transcriptions"
162
+ * here for backward compatibility; index.ts resolves it explicitly. */
163
+ mode?: "transcriptions" | "chat";
164
+ /** Injectable fetch (tests use a recorded-fixture fake). */
165
+ fetchFn?: TelegramFetch;
166
+ /** Wait before the single transport-class retry (ms; default
167
+ * DEFAULT_STT_TRANSPORT_RETRY_DELAY_MS). Tests pass 0 to stay off the real
168
+ * delay. */
169
+ transportRetryDelayMs?: number;
170
+ }
171
+
172
+ /** Error class for the failure line + counters (never key material). */
173
+ function classifySttFailure(status: number, body: string): string {
174
+ const lower = body.toLowerCase();
175
+ if (lower.includes("quota")) return "quota";
176
+ if (lower.includes("rate limit") || lower.includes("too many requests")) {
177
+ return "rate";
178
+ }
179
+ if (status === 401) return "auth";
180
+ if (status === 403) return "auth";
181
+ if (status === 429) return "rate";
182
+ if (status >= 400 && status < 500) return "client";
183
+ return "other";
184
+ }
185
+
186
+ /** Advance to the next candidate on 401/403/429 and on quota-class bodies. */
187
+ function shouldAdvance(status: number, errorClass: string): boolean {
188
+ return (
189
+ status === 401 || status === 403 || status === 429 || errorClass === "quota"
190
+ );
191
+ }
192
+
193
+ /** Await `ms` (only ever the transport-retry pause). */
194
+ function sleep(ms: number): Promise<void> {
195
+ return new Promise((resolve) => setTimeout(resolve, ms));
196
+ }
197
+
198
+ /**
199
+ * Internal outcome of one request: the public result plus the markers the
200
+ * failover/retry policy needs (never key material).
201
+ */
202
+ type AttemptOutcome = TranscribeResult & {
203
+ lastStatus?: number;
204
+ lastClass?: string;
205
+ /** The request threw before any response arrived, so there is no HTTP
206
+ * status and nothing was learned about the key — the transport-class
207
+ * failure (DNS/TCP/TLS) the inbound path retries once. A non-ok RESPONSE
208
+ * never sets this, whatever its class. */
209
+ transportFailure?: boolean;
210
+ };
211
+
212
+ /** Options for one single-candidate attempt (internal). */
213
+ type AttemptOptions = TranscribeVoiceOptions & { key: string };
214
+
215
+ /** One request against one key. Returns ok/failure plus classification. */
216
+ async function attemptTranscribe(
217
+ opts: AttemptOptions,
218
+ ): Promise<AttemptOutcome> {
219
+ const {
220
+ bytes,
221
+ key,
222
+ mode = "transcriptions",
223
+ fetchFn = globalThis.fetch,
224
+ } = opts;
225
+ try {
226
+ if (mode === "chat") {
227
+ const baseUrl = opts.baseUrl ?? DEFAULT_STT_CHAT_BASE_URL;
228
+ const model = opts.model ?? DEFAULT_STT_CHAT_MODEL;
229
+ // Venice chat/input_audio: base64 of the ogg bytes as an input_audio
230
+ // content part; the transcript comes back as a plain chat text content.
231
+ // Thread the configured source-language into the prompt so
232
+ // BRAID_TG_STT_LANGUAGE is honored in chat mode too (default "Russian"
233
+ // — the deployment's spoken language).
234
+ const language = opts.language?.trim() || "Russian";
235
+ const body = {
236
+ model,
237
+ messages: [
238
+ {
239
+ role: "user",
240
+ content: [
241
+ {
242
+ type: "input_audio",
243
+ input_audio: {
244
+ data: Buffer.from(bytes).toString("base64"),
245
+ format: "ogg",
246
+ },
247
+ },
248
+ {
249
+ type: "text",
250
+ text: `Transcribe this voice message verbatim (${language}). Return only the transcript.`,
251
+ },
252
+ ],
253
+ },
254
+ ],
255
+ };
256
+ const res = await fetchFn(
257
+ `${baseUrl.replace(/\/$/, "")}/chat/completions`,
258
+ {
259
+ method: "POST",
260
+ headers: {
261
+ Authorization: `Bearer ${key}`,
262
+ "Content-Type": "application/json",
263
+ },
264
+ body: JSON.stringify(body),
265
+ },
266
+ );
267
+ if (!res.ok) {
268
+ const resBody = await res.text().catch(() => "");
269
+ const detail = resBody ? `: ${resBody.slice(0, 200)}` : "";
270
+ return {
271
+ ok: false,
272
+ error: `HTTP ${res.status}${detail}`,
273
+ lastStatus: res.status,
274
+ lastClass: classifySttFailure(res.status, resBody),
275
+ };
276
+ }
277
+ const json = (await res.json().catch(() => null)) as {
278
+ choices?: Array<{ message?: { content?: unknown } }>;
279
+ } | null;
280
+ const text = json?.choices?.[0]?.message?.content;
281
+ if (typeof text !== "string") {
282
+ return {
283
+ ok: false,
284
+ error: "STT response missing text",
285
+ lastClass: "other",
286
+ };
287
+ }
288
+ return { ok: true, text: text.trim() };
289
+ }
290
+
291
+ // transcriptions path (kept intact)
292
+ const baseUrl = opts.baseUrl ?? DEFAULT_STT_BASE_URL;
293
+ const model = opts.model ?? DEFAULT_STT_MODEL;
294
+ const language = opts.language;
295
+ const mimeType = opts.mimeType ?? "audio/ogg";
296
+
297
+ const form = new FormData();
298
+ // TS lib requires a Uint8Array<ArrayBuffer> for BlobPart; copy so the
299
+ // backing store is a plain ArrayBuffer (mirrors oggToBlob in the client).
300
+ const copy = new Uint8Array(bytes.byteLength);
301
+ copy.set(bytes);
302
+ form.append("file", new Blob([copy], { type: mimeType }), "voice.ogg");
303
+ form.append("model", model);
304
+ if (language && language.trim()) form.append("language", language.trim());
305
+
306
+ const res = await fetchFn(
307
+ `${baseUrl.replace(/\/$/, "")}/audio/transcriptions`,
308
+ {
309
+ method: "POST",
310
+ headers: { Authorization: `Bearer ${key}` },
311
+ body: form,
312
+ },
313
+ );
314
+ if (!res.ok) {
315
+ const resBody = await res.text().catch(() => "");
316
+ const detail = resBody ? `: ${resBody.slice(0, 200)}` : "";
317
+ return {
318
+ ok: false,
319
+ error: `HTTP ${res.status}${detail}`,
320
+ lastStatus: res.status,
321
+ lastClass: classifySttFailure(res.status, resBody),
322
+ };
323
+ }
324
+ const json = (await res.json().catch(() => null)) as {
325
+ text?: unknown;
326
+ } | null;
327
+ const text = json?.text;
328
+ if (typeof text !== "string") {
329
+ return {
330
+ ok: false,
331
+ error: "STT response missing text",
332
+ lastClass: "other",
333
+ };
334
+ }
335
+ return { ok: true, text: text.trim() };
336
+ } catch (error) {
337
+ // The request never produced a response (network/DNS/TLS/connection).
338
+ // Flagged so the failover layer retries it once; the reported class stays
339
+ // "other" (what this case has always been labelled).
340
+ return {
341
+ ok: false,
342
+ error: error instanceof Error ? error.message : String(error),
343
+ lastClass: "other",
344
+ transportFailure: true,
345
+ };
346
+ }
347
+ }
348
+
349
+ /** Build the ordered candidate list from keys/apiKey (keys wins when set). */
350
+ function orderedCandidates(opts: {
351
+ keys?: SttKeyCandidate[];
352
+ apiKey?: string;
353
+ }): SttKeyCandidate[] {
354
+ const pool = opts.keys?.length
355
+ ? opts.keys
356
+ : opts.apiKey
357
+ ? [{ key: opts.apiKey, source: "explicit" as const, index: 0 }]
358
+ : [];
359
+ const preferred = getPreferredSttKey();
360
+ if (preferred && pool.some((c) => c.key === preferred.key)) {
361
+ return [preferred, ...pool.filter((c) => c.key !== preferred.key)];
362
+ }
363
+ return [...pool];
364
+ }
365
+
366
+ /**
367
+ * Transcribe the voice bytes. With `mode: "chat"` POSTs `{baseUrl}/chat/completions`
368
+ * with the bytes as a base64 `input_audio` content part and reads
369
+ * `choices[0].message.content` (Venice). With `mode: "transcriptions"` POSTs
370
+ * `{baseUrl}/audio/transcriptions` as multipart (file = Blob of the bytes with
371
+ * filename voice.ogg, model, optional language). On the chat path the optional
372
+ * language is threaded into the prompt (default "Russian"). When `keys` carries
373
+ * a pool, auth/quota/rate failures advance to the next candidate (). A request
374
+ * that throws (no HTTP status) is retried once on the same candidate, since a
375
+ * transport failure is not key-specific.
376
+ * Returns `{ ok: true, text }` on success, `{ ok: false, error }` on any
377
+ * non-ok response or network failure. Never throws.
378
+ */
379
+ export async function transcribeVoice(
380
+ opts: TranscribeVoiceOptions,
381
+ ): Promise<TranscribeResult> {
382
+ const candidates = orderedCandidates(opts);
383
+ if (candidates.length === 0) {
384
+ countSttFailure("no-key");
385
+ return { ok: false, error: "no STT api key configured" };
386
+ }
387
+ let attempts = 0;
388
+ let retries = 0;
389
+ let lastFailure: {
390
+ error: string;
391
+ lastStatus?: number;
392
+ lastClass?: string;
393
+ } = { error: "no attempts made" };
394
+ for (const candidate of candidates) {
395
+ attempts += 1;
396
+ countSttAttempt();
397
+ let r = await attemptTranscribe({ ...opts, key: candidate.key });
398
+ // One transport retry per call (= per round), never a loop: the budget is
399
+ // scoped here, so the added requests and latency of a round are bounded.
400
+ // A throw says nothing about the key, so the SAME candidate is retried
401
+ // rather than the pool advanced.
402
+ if (!r.ok && r.transportFailure && retries === 0) {
403
+ retries += 1;
404
+ countSttTransportRetry();
405
+ await sleep(
406
+ opts.transportRetryDelayMs ?? DEFAULT_STT_TRANSPORT_RETRY_DELAY_MS,
407
+ );
408
+ r = await attemptTranscribe({ ...opts, key: candidate.key });
409
+ if (r.ok) countSttTransportRetrySuccess();
410
+ }
411
+ if (r.ok) {
412
+ return {
413
+ ok: true,
414
+ text: r.text,
415
+ key: { index: candidate.index, source: candidate.source },
416
+ };
417
+ }
418
+ lastFailure = {
419
+ error: r.error,
420
+ lastStatus: r.lastStatus,
421
+ lastClass: r.lastClass,
422
+ };
423
+ const advance = shouldAdvance(r.lastStatus ?? 0, r.lastClass ?? "other");
424
+ if (!advance) break;
425
+ }
426
+ return {
427
+ ok: false,
428
+ error: lastFailure.error,
429
+ attempts,
430
+ retries,
431
+ candidateIndex:
432
+ candidates[Math.min(attempts, candidates.length) - 1]?.index,
433
+ candidateSource:
434
+ candidates[Math.min(attempts, candidates.length) - 1]?.source,
435
+ lastStatus: lastFailure.lastStatus,
436
+ lastClass: lastFailure.lastClass,
437
+ };
438
+ }
439
+
440
+ /** Options for transcribeVoiceReliable (the inbound orchestrator). */
441
+ export interface TranscribeVoiceReliableOptions extends Omit<
442
+ TranscribeVoiceOptions,
443
+ "apiKey"
444
+ > {
445
+ /** Single-candidate fallback (keys takes precedence). */
446
+ apiKey?: string;
447
+ /** Quiet-audio retry gate: enabled + optional injectable amplification
448
+ * (defaults to the real ffmpeg normalizeAudioBytes). */
449
+ normalize?: { enabled: boolean; normalizeBytes?: NormalizeBytesFn };
450
+ }
451
+
452
+ /** Endpoint host for the failure line (never the full URL, never keys). */
453
+ function endpointHost(baseUrl: string | undefined, mode: string): string {
454
+ try {
455
+ const url = new URL(
456
+ baseUrl ??
457
+ (mode === "chat" ? DEFAULT_STT_CHAT_BASE_URL : DEFAULT_STT_BASE_URL),
458
+ );
459
+ return url.host;
460
+ } catch {
461
+ return "unknown-endpoint";
462
+ }
463
+ }
464
+
465
+ /**
466
+ * The inbound orchestrator: bounded to TWO transcription rounds.
467
+ * The first round runs the key-pool failover on the raw bytes; when it fails or
468
+ * returns an EMPTY transcript and normalization is enabled, the bytes are
469
+ * amplified (ffmpeg; absent ffmpeg → the second round is skipped silently)
470
+ * and the second round reruns the pool on the normalized bytes. Counters (attempts, successes by
471
+ * candidate index, normalizations applied, failures by class) live in
472
+ * stt-keys.ts. The final failure error carries endpoint host, round count,
473
+ * the requests issued in that round (the transport retry named on its own),
474
+ * the last candidate index/source, the last error class, and the remediation
475
+ * hint — never key material. Never throws.
476
+ */
477
+ export async function transcribeVoiceReliable(
478
+ opts: TranscribeVoiceReliableOptions,
479
+ ): Promise<TranscribeResult> {
480
+ const host = endpointHost(opts.baseUrl, opts.mode ?? "chat");
481
+ const round = async (
482
+ bytes: Uint8Array,
483
+ ): Promise<{ result: TranscribeResult; okNonEmpty: boolean }> => {
484
+ const result = await transcribeVoice({ ...opts, bytes });
485
+ const okNonEmpty = result.ok && result.text.length > 0;
486
+ return { result, okNonEmpty };
487
+ };
488
+
489
+ const first = await round(opts.bytes);
490
+ if (first.okNonEmpty) {
491
+ if (first.result.ok) countSttSuccessWithProvenance(first.result);
492
+ return first.result;
493
+ }
494
+
495
+ // Empty-but-ok first round is retried like a failure.
496
+ const normalize = opts.normalize;
497
+ if (!normalize?.enabled) {
498
+ // No normalization → report the first round honestly (empty ok → failure
499
+ // with class "empty", real failures keep their enriched line).
500
+ return finalizeFailure(first.result, host, 1, normalize?.enabled === true);
501
+ }
502
+
503
+ const normalizeBytes =
504
+ normalize.normalizeBytes ??
505
+ ((bytes: Uint8Array) => normalizeAudioBytes(bytes));
506
+ const amplified = await normalizeBytes(opts.bytes);
507
+ if (!amplified) {
508
+ return finalizeFailure(first.result, host, 1, true);
509
+ }
510
+
511
+ const second = await round(amplified);
512
+ if (second.result.ok) {
513
+ countSttNormalizationApplied();
514
+ if (second.okNonEmpty) countSttSuccessWithProvenance(second.result);
515
+ else return finalizeFailure(second.result, host, 2, true);
516
+ return second.result;
517
+ }
518
+ return finalizeFailure(second.result, host, 2, true);
519
+ }
520
+
521
+ /** Count a final success by provenance (counters keyed by candidate index). */
522
+ function countSttSuccessWithProvenance(
523
+ result: TranscribeResult & { ok: true },
524
+ ): void {
525
+ if (result.key) {
526
+ countSttSuccess({
527
+ key: "",
528
+ index: result.key.index,
529
+ source: result.key.source as SttKeyCandidate["source"],
530
+ });
531
+ }
532
+ }
533
+
534
+ /** Compose the honest failure line + record the failure class. */
535
+ function finalizeFailure(
536
+ result: TranscribeResult,
537
+ host: string,
538
+ rounds: number,
539
+ normalizationAvailable: boolean,
540
+ ): TranscribeResult {
541
+ if (result.ok) {
542
+ // Empty transcript after the bounded rounds → report as failure.
543
+ const error = `empty transcript after ${rounds} round(s) (endpoint ${host} — hint: enable BRAID_TG_STT_NORMALIZE)`;
544
+ countSttFailure("empty");
545
+ return {
546
+ ok: false,
547
+ error,
548
+ attempts: result.key ? 1 : 1,
549
+ lastClass: "empty",
550
+ };
551
+ }
552
+ const attempts = result.attempts ?? 1;
553
+ const retries = result.retries ?? 0;
554
+ const parts = [
555
+ `endpoint ${host}`,
556
+ // Requests actually issued in this round: candidate hits plus the
557
+ // transport retry, which is named so the total is not read as key work.
558
+ `${rounds} round(s), ${attempts + retries} request(s)${
559
+ retries > 0 ? ` (${retries} transport retry)` : ""
560
+ }`,
561
+ result.candidateIndex !== undefined
562
+ ? `key ${result.candidateIndex}/${result.candidateSource ?? "unknown"}`
563
+ : undefined,
564
+ result.lastClass ? `class ${result.lastClass}` : undefined,
565
+ ].filter(Boolean);
566
+ const hint = normalizationAvailable
567
+ ? "hint: add keys (BRAID_TG_STT_KEYS / keyring) or enable BRAID_TG_STT_NORMALIZE"
568
+ : "hint: add keys (BRAID_TG_STT_KEYS / keyring)";
569
+ countSttFailure(result.lastClass ?? "other");
570
+ return {
571
+ ...result,
572
+ error: `${result.error} (${parts.join(", ")} — ${hint})`,
573
+ };
574
+ }