@crossworks/voice-client 0.230.43

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/LICENSE.md ADDED
@@ -0,0 +1,135 @@
1
+ # License
2
+
3
+ Mantle is **dual-licensed** by Cross Works Engineering (Pty) Ltd:
4
+
5
+ 1. **Public license — Functional Source License 1.1 (MIT Future), reproduced in
6
+ full below.** Free to use, self-host, modify, and run for any purpose that is
7
+ not a Competing Use (see the _Permitted Purpose_ clause). Two years after each
8
+ version is first published, that version automatically converts to the MIT
9
+ license.
10
+ 2. **Commercial license — see [`LICENSE-COMMERCIAL.md`](./LICENSE-COMMERCIAL.md).**
11
+ A paid agreement that lifts the FSL's _Competing Use_ restriction for partners
12
+ who want to embed Mantle in a commercial product or offer it as a service
13
+ during the two-year window. Contact **licensing@crossworks.engineering**.
14
+
15
+ For a plain-language explanation of how the two licenses fit together and what the
16
+ key terms mean, see [`LICENSING.md`](./LICENSING.md). Third-party open-source
17
+ components bundled with Mantle are attributed in
18
+ [`THIRD-PARTY-NOTICES.md`](./THIRD-PARTY-NOTICES.md).
19
+
20
+ The "Change Date" for each release is **two years after that release is first made
21
+ available** under this license; on the Change Date that release becomes available
22
+ under the MIT license (the _Grant of Future License_ below).
23
+
24
+ ---
25
+
26
+ # Functional Source License, Version 1.1, MIT Future License
27
+
28
+ ## Abbreviation
29
+
30
+ FSL-1.1-MIT
31
+
32
+ ## Notice
33
+
34
+ Copyright 2026 Cross Works Engineering (Pty) Ltd
35
+
36
+ ## Terms and Conditions
37
+
38
+ ### Licensor ("We")
39
+
40
+ The party offering the Software under these Terms and Conditions.
41
+
42
+ ### The Software
43
+
44
+ The "Software" is each version of the software that we make available under
45
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
46
+ Conditions with the Software.
47
+
48
+ ### License Grant
49
+
50
+ Subject to your compliance with this License Grant and the Patents,
51
+ Redistribution and Trademark clauses below, we hereby grant you the right to
52
+ use, copy, modify, create derivative works, publicly perform, publicly display
53
+ and redistribute the Software for any Permitted Purpose identified below.
54
+
55
+ ### Permitted Purpose
56
+
57
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
58
+ means making the Software available to others in a commercial product or
59
+ service that:
60
+
61
+ 1. substitutes for the Software;
62
+
63
+ 2. substitutes for any other product or service we offer using the Software
64
+ that exists as of the date we make the Software available; or
65
+
66
+ 3. offers the same or substantially similar functionality as the Software.
67
+
68
+ Permitted Purposes specifically include using the Software:
69
+
70
+ 1. for your internal use and access;
71
+
72
+ 2. for non-commercial education;
73
+
74
+ 3. for non-commercial research; and
75
+
76
+ 4. in connection with professional services that you provide to a licensee
77
+ using the Software in accordance with these Terms and Conditions.
78
+
79
+ ### Patents
80
+
81
+ To the extent your use for a Permitted Purpose would necessarily infringe our
82
+ patents, the license grant above includes a license under our patents. If you
83
+ make a claim against any party that the Software infringes or contributes to
84
+ the infringement of any patent, then your patent license to the Software ends
85
+ immediately.
86
+
87
+ ### Redistribution
88
+
89
+ The Terms and Conditions apply to all copies, modifications and derivatives of
90
+ the Software.
91
+
92
+ If you redistribute any copies, modifications or derivatives of the Software,
93
+ you must include a copy of or a link to these Terms and Conditions and not
94
+ remove any copyright notices provided in or with the Software.
95
+
96
+ ### Disclaimer
97
+
98
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
99
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
100
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
101
+
102
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
103
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
104
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
105
+
106
+ ### Trademarks
107
+
108
+ Except for displaying the License Details and identifying us as the origin of
109
+ the Software, you have no right under these Terms and Conditions to use our
110
+ trademarks, trade names, service marks or product names.
111
+
112
+ ## Grant of Future License
113
+
114
+ We hereby irrevocably grant you an additional license to use the Software under
115
+ the MIT license that is effective on the second anniversary of the date we make
116
+ the Software available. On or after that date, you may use the Software under
117
+ the MIT license, in which case the following will apply:
118
+
119
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
120
+ this software and associated documentation files (the "Software"), to deal in
121
+ the Software without restriction, including without limitation the rights to
122
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
123
+ of the Software, and to permit persons to whom the Software is furnished to do
124
+ so, subject to the following conditions:
125
+
126
+ The above copyright notice and this permission notice shall be included in all
127
+ copies or substantial portions of the Software.
128
+
129
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
130
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
131
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
132
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
133
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
134
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
135
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@crossworks/voice-client",
3
+ "version": "0.230.43",
4
+ "description": "Browser-safe surface of the voice/model layer — provider catalogue, model catalogs, audio tags, and the adapter type/metadata contract. Zero deps by design: nothing here may reach the network adapters or node builtins (the jackdaw-repo-split P0 boundary).",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./*": "./src/*.ts"
8
+ },
9
+ "devDependencies": {
10
+ "@types/node": "^22.20.1"
11
+ },
12
+ "license": "SEE LICENSE IN LICENSE.md",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/crossworks-engineering/mantle"
16
+ },
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit"
19
+ }
20
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Adapter registry — provider id → dispatcher lookup, per capability.
3
+ *
4
+ * Built-in adapters self-register at module load via the import chain
5
+ * in `./index.ts`. Apps that want to add custom adapters at runtime
6
+ * call `registerTtsAdapter(...)` etc. before the first use.
7
+ *
8
+ * Resolution is intentionally strict: if no adapter is registered for
9
+ * a given `providerId`, the lookup returns null and the runtime
10
+ * surfaces a clear "not yet wired" error rather than guessing. The
11
+ * catalog's `wired` flag is derived from these registries so the UI
12
+ * stays honest about which providers can actually be called.
13
+ */
14
+
15
+ import type { Provider, ProviderCapability, ProviderId } from '../providers';
16
+ import type {
17
+ ChatDispatcher,
18
+ EmbeddingDispatcher,
19
+ ImageGenDispatcher,
20
+ SttDispatcher,
21
+ TtsDispatcher,
22
+ VisionDispatcher,
23
+ } from './types';
24
+ import { withChatRetry } from './retry';
25
+
26
+ const CHAT = new Map<ProviderId, ChatDispatcher>();
27
+ const TTS = new Map<ProviderId, TtsDispatcher>();
28
+ const STT = new Map<ProviderId, SttDispatcher>();
29
+ const VISION = new Map<ProviderId, VisionDispatcher>();
30
+ const IMAGE_GEN = new Map<ProviderId, ImageGenDispatcher>();
31
+ const EMBEDDING = new Map<ProviderId, EmbeddingDispatcher>();
32
+
33
+ export type WiredCapability = 'chat' | 'tts' | 'stt' | 'vision' | 'image_gen' | 'embedding';
34
+
35
+ /**
36
+ * STATIC mirror of which providers have a registered adapter, per capability —
37
+ * the source of truth for `isProviderWired` and the settings UI.
38
+ *
39
+ * Why static and not "read the live Maps above": the built-in adapters only land
40
+ * in those Maps when `./index.ts` runs its `register*Adapter(...)` chain, which
41
+ * pulls in node-only deps (undici / node:crypto). The browser bundle imports the
42
+ * adapter-free `@mantle/voice/client` leaf, so client-side the Maps are EMPTY —
43
+ * reading them there reported EVERY provider as "not wired". This pure-data table
44
+ * gives the correct answer in both bundles. It's kept in lockstep with the live
45
+ * registrations by `registry.test.ts` (register an adapter without adding it here
46
+ * → the drift test fails). Mirror `adapters/index.ts` exactly when editing.
47
+ */
48
+ export const WIRED_PROVIDERS: Record<WiredCapability, ReadonlySet<ProviderId>> = {
49
+ chat: new Set<ProviderId>([
50
+ 'openrouter',
51
+ 'anthropic',
52
+ 'google',
53
+ 'xai',
54
+ 'huggingface',
55
+ 'deepseek',
56
+ 'copilot',
57
+ 'custom',
58
+ 'local',
59
+ ]),
60
+ tts: new Set<ProviderId>(['openrouter', 'openai', 'elevenlabs', 'xai', 'google']),
61
+ stt: new Set<ProviderId>([
62
+ 'openrouter',
63
+ 'openai',
64
+ 'xai',
65
+ 'elevenlabs',
66
+ 'deepgram',
67
+ 'assemblyai',
68
+ 'google',
69
+ ]),
70
+ vision: new Set<ProviderId>(['openai', 'anthropic', 'google', 'xai', 'openrouter']),
71
+ image_gen: new Set<ProviderId>(['openrouter', 'openai', 'xai', 'google', 'huggingface']),
72
+ embedding: new Set<ProviderId>(['openrouter', 'openai', 'google', 'mistral', 'cohere', 'local']),
73
+ };
74
+
75
+ function mapFor(capability: WiredCapability): ReadonlyMap<ProviderId, unknown> {
76
+ return {
77
+ chat: CHAT,
78
+ tts: TTS,
79
+ stt: STT,
80
+ vision: VISION,
81
+ image_gen: IMAGE_GEN,
82
+ embedding: EMBEDDING,
83
+ }[capability];
84
+ }
85
+
86
+ /** Live-registry provider ids for a capability — used by the drift test to prove
87
+ * WIRED_PROVIDERS matches what `adapters/index.ts` actually registered. */
88
+ export function registeredProviderIds(capability: WiredCapability): ProviderId[] {
89
+ return [...mapFor(capability).keys()];
90
+ }
91
+
92
+ // ─── Chat ────────────────────────────────────────────────────────────
93
+
94
+ export function registerChatAdapter(adapter: ChatDispatcher): void {
95
+ CHAT.set(adapter.providerId, adapter);
96
+ }
97
+
98
+ export function getChatAdapter(providerId: string): ChatDispatcher | null {
99
+ const adapter = CHAT.get(providerId as ProviderId) ?? null;
100
+ if (!adapter) return null;
101
+ // OpenRouter's SDK already retries transient errors internally; wrapping it
102
+ // would compound attempt counts. The native-fetch adapters (anthropic /
103
+ // google / xai / huggingface / deepseek) have no retry of their own, so wrap
104
+ // those once here for uniform 429/5xx/network/timeout backoff.
105
+ if (adapter.providerId === 'openrouter') return adapter;
106
+ return withChatRetry(adapter);
107
+ }
108
+
109
+ export function listChatAdapters(): ChatDispatcher[] {
110
+ return Array.from(CHAT.values());
111
+ }
112
+
113
+ // ─── TTS ─────────────────────────────────────────────────────────────
114
+
115
+ export function registerTtsAdapter(adapter: TtsDispatcher): void {
116
+ TTS.set(adapter.providerId, adapter);
117
+ }
118
+
119
+ export function getTtsAdapter(providerId: string): TtsDispatcher | null {
120
+ return TTS.get(providerId as ProviderId) ?? null;
121
+ }
122
+
123
+ export function listTtsAdapters(): TtsDispatcher[] {
124
+ return Array.from(TTS.values());
125
+ }
126
+
127
+ // ─── STT ─────────────────────────────────────────────────────────────
128
+
129
+ export function registerSttAdapter(adapter: SttDispatcher): void {
130
+ STT.set(adapter.providerId, adapter);
131
+ }
132
+
133
+ export function getSttAdapter(providerId: string): SttDispatcher | null {
134
+ return STT.get(providerId as ProviderId) ?? null;
135
+ }
136
+
137
+ export function listSttAdapters(): SttDispatcher[] {
138
+ return Array.from(STT.values());
139
+ }
140
+
141
+ // ─── Vision (interface ready, no adapters yet) ───────────────────────
142
+
143
+ export function registerVisionAdapter(adapter: VisionDispatcher): void {
144
+ VISION.set(adapter.providerId, adapter);
145
+ }
146
+
147
+ export function getVisionAdapter(providerId: string): VisionDispatcher | null {
148
+ return VISION.get(providerId as ProviderId) ?? null;
149
+ }
150
+
151
+ /**
152
+ * Provider ids whose vision adapter can read a PDF NATIVELY — i.e. implements
153
+ * `extractDocument`. This is the SELF-MAINTAINING source of truth for "which
154
+ * providers a Document worker can use natively": it reads the adapter registry,
155
+ * so the moment a new adapter (e.g. Google) gains `extractDocument`, it appears
156
+ * here with no second list to update. Native-PDF capability is a fact about OUR
157
+ * adapter code, not something the provider's API advertises — so the registry
158
+ * is the only honest place to derive it.
159
+ */
160
+ export function nativeDocumentProviders(): ProviderId[] {
161
+ const out: ProviderId[] = [];
162
+ for (const [id, adapter] of VISION) {
163
+ if (typeof adapter.extractDocument === 'function') out.push(id);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ // ─── Image generation (interface ready, no adapters yet) ─────────────
169
+
170
+ export function registerImageGenAdapter(adapter: ImageGenDispatcher): void {
171
+ IMAGE_GEN.set(adapter.providerId, adapter);
172
+ }
173
+
174
+ export function getImageGenAdapter(providerId: string): ImageGenDispatcher | null {
175
+ return IMAGE_GEN.get(providerId as ProviderId) ?? null;
176
+ }
177
+
178
+ // ─── Embedding ───────────────────────────────────────────────────────
179
+
180
+ export function registerEmbeddingAdapter(adapter: EmbeddingDispatcher): void {
181
+ EMBEDDING.set(adapter.providerId, adapter);
182
+ }
183
+
184
+ export function getEmbeddingAdapter(providerId: string): EmbeddingDispatcher | null {
185
+ return EMBEDDING.get(providerId as ProviderId) ?? null;
186
+ }
187
+
188
+ export function listEmbeddingAdapters(): EmbeddingDispatcher[] {
189
+ return Array.from(EMBEDDING.values());
190
+ }
191
+
192
+ // ─── Capability check (used by UI to derive `wired` flag) ────────────
193
+
194
+ /**
195
+ * Verify the providers catalog and the adapter registry agree on
196
+ * "what each provider supports". Returns an array of drift problems
197
+ * (empty when consistent). Each problem is the human-readable
198
+ * sentence we surface to the dev log or to the failing test.
199
+ *
200
+ * The drift we care about: a registered adapter exists for a
201
+ * (provider, capability) pair that the providers catalog does NOT
202
+ * declare. Symptom in production: provider dropdown filters in the
203
+ * worker form (which read the catalog's `capabilities`) hide the
204
+ * provider for that kind, even though the runtime would happily
205
+ * accept it. We hit this exact bug when xai-tts + google-tts
206
+ * shipped — adapters registered, catalog still said chat-only —
207
+ * and the TTS dropdown wouldn't list either.
208
+ *
209
+ * Catalog without adapter is the OTHER direction and is FINE — it
210
+ * means "we plan to wire this; not yet." That's the documented
211
+ * "not yet wired" state and isn't a bug.
212
+ *
213
+ * Called from `./index.ts` once on module load (dev-log warning) and
214
+ * exercised explicitly in catalog-consistency.test.ts so a missed
215
+ * catalog edit fails CI.
216
+ */
217
+ export function findAdapterCatalogDrift(
218
+ providers: ReadonlyArray<{ id: string; capabilities: readonly string[] }>,
219
+ ): string[] {
220
+ const problems: string[] = [];
221
+ const catalogById = new Map(providers.map((p) => [p.id as string, p.capabilities]));
222
+
223
+ function check(
224
+ label: 'chat' | 'tts' | 'stt' | 'vision' | 'image_gen' | 'embedding',
225
+ registry: Map<ProviderId, { adapterName: string }>,
226
+ ): void {
227
+ for (const [providerId, adapter] of registry) {
228
+ const caps = catalogById.get(providerId);
229
+ if (!caps) {
230
+ problems.push(
231
+ `${adapter.adapterName} is registered, but provider id '${providerId}' is not in SUPPORTED_PROVIDERS.`,
232
+ );
233
+ continue;
234
+ }
235
+ if (!caps.includes(label)) {
236
+ problems.push(
237
+ `${adapter.adapterName} is registered, but the providers catalog for '${providerId}' does not list '${label}' in capabilities. ` +
238
+ `Add '${label}' to the entry in packages/voice/src/providers.ts so the worker-form dropdown surfaces this provider for ${label} workers.`,
239
+ );
240
+ }
241
+ }
242
+ }
243
+
244
+ check('chat', CHAT);
245
+ check('tts', TTS);
246
+ check('stt', STT);
247
+ check('vision', VISION);
248
+ check('image_gen', IMAGE_GEN);
249
+ check('embedding', EMBEDDING);
250
+
251
+ return problems;
252
+ }
253
+
254
+ /**
255
+ * Is the given provider wired (i.e. has a registered adapter) for the
256
+ * given capability? Drives the "wired" / "not yet wired" hint in the
257
+ * settings UI so the catalog stays honest.
258
+ */
259
+ export function isProviderWired(providerId: string, capability: WiredCapability): boolean {
260
+ const id = providerId as ProviderId;
261
+ // Union of the STATIC table (covers the built-in adapters — and is the ONLY
262
+ // thing visible in the adapter-free browser bundle, where the live Maps are
263
+ // empty) and the LIVE registry (honours adapters registered at runtime, e.g.
264
+ // custom/hot-swapped). No 'openai' chat carve-out: OpenAI has no direct chat
265
+ // adapter (it's reached via the `openrouter` provider with an `openai/*`
266
+ // model), so for chat it is honestly "not wired" — surfacing it as wired only
267
+ // produced an empty model dropdown.
268
+ return (WIRED_PROVIDERS[capability]?.has(id) ?? false) || mapFor(capability).has(id);
269
+ }
270
+
271
+ /**
272
+ * For each capability the provider's catalog DECLARES, return whether
273
+ * an adapter is registered for it. Drives the api-keys form's
274
+ * per-provider wired-status summary so operators see exactly what a
275
+ * key for this provider will be usable for (vs. the binary
276
+ * "any-capability-wired" check which misclassifies partially-wired
277
+ * providers like Mistral/Cohere — both declare chat but only wire
278
+ * embedding).
279
+ *
280
+ * Returns wired + unwired arrays preserving the catalog's declared
281
+ * order. UI typically renders wired ones first (the usable
282
+ * capabilities) and unwired ones separately as "supported but not
283
+ * dispatched by Mantle yet."
284
+ */
285
+ export function wiredCapabilitiesFor(provider: Provider): {
286
+ wired: ProviderCapability[];
287
+ unwired: ProviderCapability[];
288
+ } {
289
+ const wired: ProviderCapability[] = [];
290
+ const unwired: ProviderCapability[] = [];
291
+ for (const cap of provider.capabilities) {
292
+ if (isProviderWired(provider.id, cap)) wired.push(cap);
293
+ else unwired.push(cap);
294
+ }
295
+ return { wired, unwired };
296
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Retry/backoff for the chat dispatch path.
3
+ *
4
+ * The native-fetch chat adapters (anthropic / google / xai / huggingface /
5
+ * deepseek) were each a single `fetch` that threw on the first non-OK
6
+ * response — so a momentary 429 or 503 on ANY tool-loop iteration aborted the
7
+ * whole turn (the responder dropped the inbound message; a multi-iteration
8
+ * tool run lost all prior work). Only OpenRouter had resilience, via its SDK's
9
+ * built-in retries.
10
+ *
11
+ * `withChatRetry` wraps a ChatDispatcher so the direct-provider adapters get
12
+ * uniform, configurable retry on transient errors (429, 408/409/425, 5xx,
13
+ * network blips, and the adapters' own 60s fetch timeout) with exponential
14
+ * backoff + jitter, honoring `Retry-After` when the provider sends it. It is
15
+ * applied once at the `getChatAdapter` registry boundary, so every chat call
16
+ * site (responder, web assistant, extractor, summarizer, reflector,
17
+ * heartbeats, invoke_agent) is covered without opting in.
18
+ *
19
+ * OpenRouter is intentionally NOT wrapped — its SDK already retries, and
20
+ * double-wrapping would compound attempt counts. See registry.getChatAdapter.
21
+ */
22
+ import type { ChatDispatcher, ChatOptions, ChatResult } from './types';
23
+
24
+ /** Default attempts AFTER the first try (so 2 ⇒ up to 3 total calls). */
25
+ export const DEFAULT_MAX_RETRIES = 2;
26
+ const DEFAULT_BASE_DELAY_MS = 500;
27
+ const DEFAULT_MAX_DELAY_MS = 8_000;
28
+ /** Cap an honored Retry-After so a pathological header can't stall a turn. */
29
+ const RETRY_AFTER_CAP_MS = 30_000;
30
+
31
+ /** Transient HTTP statuses worth retrying. */
32
+ const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
33
+
34
+ /**
35
+ * Structured error the native-fetch chat adapters throw on a non-OK response.
36
+ * Carries the status + parsed Retry-After so the retry wrapper can decide
37
+ * cleanly (no message parsing). The `message` is byte-identical to the plain
38
+ * `Error` the adapters threw before (`<provider> chat <status>: <body…>`), so
39
+ * existing wire-shape tests and log scrapers are unaffected.
40
+ */
41
+ export class ChatHttpError extends Error {
42
+ readonly provider: string;
43
+ readonly status: number;
44
+ readonly retryAfterMs?: number;
45
+ readonly body?: string;
46
+ constructor(opts: { provider: string; status: number; body?: string; retryAfterMs?: number }) {
47
+ super(`${opts.provider} chat ${opts.status}: ${(opts.body ?? '').slice(0, 400)}`);
48
+ this.name = 'ChatHttpError';
49
+ this.provider = opts.provider;
50
+ this.status = opts.status;
51
+ this.retryAfterMs = opts.retryAfterMs;
52
+ this.body = opts.body;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Parse a `Retry-After` header (RFC 7231: delta-seconds or HTTP-date) into ms.
58
+ * Returns undefined when absent/unparseable.
59
+ */
60
+ export function parseRetryAfterMs(headers?: Headers | null): number | undefined {
61
+ // Defensive `?.get?.` — real `fetch` always gives a Headers, but tests (and
62
+ // some mocked transports) hand back a plain object with no headers.
63
+ const raw = headers?.get?.('retry-after');
64
+ if (!raw) return undefined;
65
+ const secs = Number(raw);
66
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
67
+ const when = Date.parse(raw);
68
+ if (Number.isFinite(when)) return Math.max(0, when - Date.now());
69
+ return undefined;
70
+ }
71
+
72
+ /**
73
+ * An empty or truncated 2xx body that `JSON.parse` choked on — the signature of
74
+ * an upstream timeout / dropped connection that still returned 200 (or a cut-off
75
+ * stream). Transient: the same request usually succeeds on retry. (Caught in
76
+ * prod: a 16-step assistant turn died here after a 34s upstream stall returned
77
+ * an empty body — see openrouter-chat.ts.)
78
+ *
79
+ * We match only the families that CANNOT arise from a complete payload, because
80
+ * a complete-but-invalid body is a genuine bug we must not mask behind retries:
81
+ *
82
+ * - "Unexpected end of JSON input" — `''`, whitespace, or input ending where
83
+ * a value was expected. Always truncation.
84
+ * - "Unterminated string in JSON" — a string opened and never closed. Only
85
+ * reachable by running out of input.
86
+ *
87
+ * Deliberately NOT matched: `Expected ',' or '}' after property value…`,
88
+ * `Expected ':' after property name…`, `Expected property name or '}'…`. Those
89
+ * fire for BOTH a mid-object truncation and a malformed-but-complete body, and
90
+ * the message alone cannot tell them apart — only the byte offset versus the
91
+ * body length could, which this signature doesn't receive. Retrying a real
92
+ * malformed payload would turn one loud bug into three silent ones.
93
+ *
94
+ * ⚠️ The message strings are V8's, and V8 has changed them before: this used to
95
+ * claim it covered truncation generally, which was true on older Node where
96
+ * every cut-off body said "Unexpected end of JSON input". Modern V8 emits
97
+ * specific messages per failure shape, so that claim had quietly become false
98
+ * for most truncations. The tests below parse REAL malformed JSON rather than
99
+ * asserting hand-written message strings, so the next V8 wording change fails
100
+ * them instead of silently narrowing this again.
101
+ */
102
+ export function isEmptyJsonBodyError(err: unknown): boolean {
103
+ if (!(err instanceof SyntaxError)) return false;
104
+ return (
105
+ /unexpected end of (json )?input/i.test(err.message) ||
106
+ /unterminated string in json/i.test(err.message)
107
+ );
108
+ }
109
+
110
+ /** Decide whether an adapter error is worth retrying, and after how long. */
111
+ export function classifyChatError(err: unknown): {
112
+ retry: boolean;
113
+ retryAfterMs?: number;
114
+ } {
115
+ if (err instanceof ChatHttpError) {
116
+ return { retry: RETRYABLE_STATUS.has(err.status), retryAfterMs: err.retryAfterMs };
117
+ }
118
+ // Defensive: any error exposing a numeric HTTP status (e.g. an SDK error).
119
+ const status = (err as { status?: unknown } | null)?.status;
120
+ if (typeof status === 'number') return { retry: RETRYABLE_STATUS.has(status) };
121
+ // The adapters' own `AbortSignal.timeout(60_000)` fires a TimeoutError; a
122
+ // bare AbortError is USUALLY that timeout too — but since the Stop wiring,
123
+ // ChatOptions CAN carry a caller-supplied signal (a user Stop), and
124
+ // retrying against an aborted signal just burns backoff sleeps.
125
+ // `withChatRetry` short-circuits that case (it can see the signal; this
126
+ // classifier only sees the error). What reaches here is transient — retry.
127
+ const name = (err as { name?: string } | null)?.name;
128
+ if (name === 'TimeoutError' || name === 'AbortError') return { retry: true };
129
+ // Raw fetch network failures surface as TypeError ("fetch failed", ECONNRESET,
130
+ // DNS, …). The wrapper only guards a network call, so this is a blip — retry.
131
+ if (err instanceof TypeError) return { retry: true };
132
+ // Empty/truncated JSON body — an upstream stall that returned no parseable
133
+ // payload. Transient, and not covered by the status/network checks above.
134
+ if (isEmptyJsonBodyError(err)) return { retry: true };
135
+ const msg = String((err as { message?: unknown } | null)?.message ?? '');
136
+ if (/fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up/i.test(msg)) {
137
+ return { retry: true };
138
+ }
139
+ return { retry: false };
140
+ }
141
+
142
+ function describeError(err: unknown): string {
143
+ if (err instanceof ChatHttpError) return `HTTP ${err.status}`;
144
+ if (isEmptyJsonBodyError(err)) return 'empty/truncated response';
145
+ const name = (err as { name?: string } | null)?.name;
146
+ return name && name !== 'Error' ? name : 'network error';
147
+ }
148
+
149
+ export interface ChatRetryConfig {
150
+ maxRetries?: number;
151
+ baseDelayMs?: number;
152
+ maxDelayMs?: number;
153
+ }
154
+
155
+ /**
156
+ * Wrap a ChatDispatcher with retry/backoff on its `chat` call. Per-call
157
+ * `opts.maxRetries` overrides the config default; 0 disables. All other
158
+ * dispatcher members (providerId, adapterName, discoverModels, staticCatalog)
159
+ * are preserved unchanged.
160
+ */
161
+ export function withChatRetry(
162
+ adapter: ChatDispatcher,
163
+ config: ChatRetryConfig = {},
164
+ ): ChatDispatcher {
165
+ const base = config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
166
+ const max = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
167
+ const chat = async (opts: ChatOptions): Promise<ChatResult> => {
168
+ const maxRetries = opts.maxRetries ?? config.maxRetries ?? DEFAULT_MAX_RETRIES;
169
+ let attempt = 0;
170
+ for (;;) {
171
+ try {
172
+ return await adapter.chat(opts);
173
+ } catch (err) {
174
+ // A caller-aborted signal (user Stop) is not transient: every retry
175
+ // would abort identically after a pointless backoff sleep. Surface it
176
+ // immediately — the tool loop / run-turn recognise the stop.
177
+ if (opts.signal?.aborted) throw err;
178
+ const { retry, retryAfterMs } = classifyChatError(err);
179
+ if (!retry || attempt >= maxRetries) throw err;
180
+ attempt += 1;
181
+ const delay =
182
+ retryAfterMs != null
183
+ ? Math.min(retryAfterMs, RETRY_AFTER_CAP_MS)
184
+ : Math.round(Math.random() * Math.min(max, base * 2 ** (attempt - 1)));
185
+ console.warn(
186
+ `[chat-retry] ${adapter.adapterName} ${opts.model}: ${describeError(err)} — retry ${attempt}/${maxRetries} in ${delay}ms`,
187
+ );
188
+ await new Promise((resolve) => setTimeout(resolve, delay));
189
+ }
190
+ }
191
+ };
192
+ return { ...adapter, chat };
193
+ }