@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 +135 -0
- package/package.json +20 -0
- package/src/adapters/registry.ts +296 -0
- package/src/adapters/retry.ts +193 -0
- package/src/adapters/types.ts +866 -0
- package/src/audio-tags.test.ts +221 -0
- package/src/audio-tags.ts +191 -0
- package/src/catalog.test.ts +144 -0
- package/src/catalog.ts +237 -0
- package/src/catalogs/anthropic.ts +135 -0
- package/src/catalogs/assemblyai.ts +54 -0
- package/src/catalogs/copilot.ts +63 -0
- package/src/catalogs/deepgram.ts +61 -0
- package/src/catalogs/deepseek.ts +85 -0
- package/src/catalogs/elevenlabs.ts +244 -0
- package/src/catalogs/google.ts +332 -0
- package/src/catalogs/huggingface.ts +180 -0
- package/src/catalogs/openai-image.ts +62 -0
- package/src/catalogs/openai-vision.ts +53 -0
- package/src/catalogs/openrouter.ts +221 -0
- package/src/catalogs/xai.ts +330 -0
- package/src/index.ts +48 -0
- package/src/providers.test.ts +172 -0
- package/src/providers.ts +262 -0
- package/src/types.ts +137 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
+
}
|