@juspay/neurolink 12.28.4 → 12.29.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 +9 -2
- package/dist/adapters/tts/googleTTSHandler.d.ts +71 -1
- package/dist/adapters/tts/googleTTSHandler.js +254 -0
- package/dist/browser/neurolink.min.js +399 -399
- package/dist/cli/commands/voices.d.ts +23 -0
- package/dist/cli/commands/voices.js +102 -0
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/cli/parser.js +3 -0
- package/dist/constants/enums.d.ts +13 -0
- package/dist/constants/enums.js +15 -0
- package/dist/core/baseProvider.d.ts +4 -0
- package/dist/core/baseProvider.js +245 -148
- package/dist/core/modules/structuredOutputPolicy.d.ts +27 -0
- package/dist/core/modules/structuredOutputPolicy.js +36 -0
- package/dist/core/nativeGenerateLoop.js +7 -1
- package/dist/neurolink.d.ts +42 -0
- package/dist/neurolink.js +102 -2
- package/dist/providers/catalog/index.generated.d.ts +1 -1
- package/dist/providers/catalog/index.generated.js +6 -0
- package/dist/providers/catalog/morph.json +97 -0
- package/dist/providers/catalog/novita.json +114 -0
- package/dist/providers/openaiChatCompletionsBase.d.ts +22 -0
- package/dist/providers/openaiChatCompletionsBase.js +291 -8
- package/dist/server/utils/validation.d.ts +34 -0
- package/dist/server/utils/validation.js +27 -0
- package/dist/server/websocket/WebSocketHandler.d.ts +4 -6
- package/dist/server/websocket/WebSocketHandler.js +185 -18
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/generate.d.ts +15 -0
- package/dist/types/isolatedAgent.d.ts +16 -7
- package/dist/types/openaiCompatible.d.ts +6 -0
- package/dist/types/providerCatalog.generated.d.ts +2 -2
- package/dist/types/providers.d.ts +50 -3
- package/dist/types/server.d.ts +8 -0
- package/dist/types/stream.d.ts +30 -0
- package/dist/types/tts.d.ts +10 -0
- package/dist/types/utilities.d.ts +16 -3
- package/dist/utils/logger.d.ts +58 -13
- package/dist/utils/logger.js +156 -11
- package/dist/utils/providerHealth.d.ts +65 -1
- package/dist/utils/providerHealth.js +216 -54
- package/dist/utils/ttsProcessor.d.ts +30 -1
- package/dist/utils/ttsProcessor.js +76 -0
- package/docs-site/static/search-index.json +4 -4
- package/package.json +6 -1
|
@@ -14,8 +14,53 @@ export class ProviderHealthChecker {
|
|
|
14
14
|
static healthCache = new Map();
|
|
15
15
|
static DEFAULT_TIMEOUT = 5000; // 5 seconds
|
|
16
16
|
static DEFAULT_CACHE_AGE = 300000; // 5 minutes
|
|
17
|
+
/**
|
|
18
|
+
* Fixed backoff window for the circuit breaker below, in milliseconds.
|
|
19
|
+
* Deliberately NOT the per-call `maxCacheAge` option: `consecutiveFailures`
|
|
20
|
+
* is a single process-wide map keyed only by provider name, shared by every
|
|
21
|
+
* caller (e.g. `checkFallbackProviderAvailability` hard-codes a 15s
|
|
22
|
+
* `maxCacheAge` for its own health-status cache). Evicting the breaker
|
|
23
|
+
* entry using whichever caller happens to read it next would let a
|
|
24
|
+
* short-TTL caller silently erase a backoff another caller is relying on
|
|
25
|
+
* — see `checkProviderHealth`. This constant governs breaker expiry alone
|
|
26
|
+
* and is intentionally independent of `maxCacheAge`/`DEFAULT_CACHE_AGE`.
|
|
27
|
+
*/
|
|
28
|
+
static CIRCUIT_BREAKER_RESET_MS = 300000; // 5 minutes
|
|
17
29
|
static CONSECUTIVE_FAILURE_THRESHOLD = ProviderHealthChecker.getValidatedFailureThreshold();
|
|
30
|
+
/**
|
|
31
|
+
* Circuit breaker state, keyed by provider.
|
|
32
|
+
*
|
|
33
|
+
* `count` is a count of consecutive failed CONNECTIVITY PROBES (plus the
|
|
34
|
+
* rare case of the check itself throwing) — never of "the provider is
|
|
35
|
+
* unconfigured". Those are different facts and only one of them is worth
|
|
36
|
+
* backing off from. A missing credential is a free, local, determinate
|
|
37
|
+
* answer that costs nothing to re-derive and flips the instant an env var
|
|
38
|
+
* is set; treating it as a failure is what let a sweep of ~38 providers
|
|
39
|
+
* (issue #1305 grew it from 8) blacklist every provider the machine
|
|
40
|
+
* happens not to have credentials for, after which the sweep reported
|
|
41
|
+
* `isConfigured: false` and no `responseTime` for a provider that was
|
|
42
|
+
* merely unconfigured — and kept reporting it after the key was supplied.
|
|
43
|
+
*
|
|
44
|
+
* `lastFailureAt` bounds how long a run of failures is held against a
|
|
45
|
+
* provider; see `checkProviderHealth`.
|
|
46
|
+
*/
|
|
18
47
|
static consecutiveFailures = new Map();
|
|
48
|
+
/**
|
|
49
|
+
* Hard cap on simultaneous per-provider checks inside
|
|
50
|
+
* `checkAllProvidersHealth`. Issue #1305: the sweep used to cover only 8
|
|
51
|
+
* of ~38 registered providers; now every non-opted-out descriptor is
|
|
52
|
+
* included (see `excludeFromHealthSweep`). Firing all ~38 at once via a
|
|
53
|
+
* single `Promise.allSettled` would be harmless by default
|
|
54
|
+
* (`includeConnectivityTest` defaults to false, so most calls never
|
|
55
|
+
* leave the process), but a caller who opts into
|
|
56
|
+
* `includeConnectivityTest: true` would otherwise open up to ~38
|
|
57
|
+
* concurrent outbound HTTP connections to that many different vendors in
|
|
58
|
+
* one burst. A worker pool keeps that bounded without reducing coverage
|
|
59
|
+
* — every provider is still checked, just not all in the same instant,
|
|
60
|
+
* and without the latency a fixed batch would add (see
|
|
61
|
+
* `checkAllProvidersHealth`).
|
|
62
|
+
*/
|
|
63
|
+
static MAX_CONCURRENT_HEALTH_CHECKS = 8;
|
|
19
64
|
/**
|
|
20
65
|
* Validate and return a safe failure threshold value
|
|
21
66
|
*/
|
|
@@ -44,24 +89,49 @@ export class ProviderHealthChecker {
|
|
|
44
89
|
return cached;
|
|
45
90
|
}
|
|
46
91
|
}
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
// Circuit breaker. It suppresses outbound PROBES, not the whole check:
|
|
93
|
+
// for most providers the step-1 configuration audit is local and free,
|
|
94
|
+
// so skipping it bought nothing and cost the caller a status object of
|
|
95
|
+
// a different shape — no `responseTime`, and a fabricated
|
|
96
|
+
// `isConfigured: false` for a provider nothing had looked at.
|
|
97
|
+
//
|
|
98
|
+
// LiteLLM and Ollama are the exception: their step-1 check
|
|
99
|
+
// (checkLiteLLMConfig/checkOllamaConfig) makes a real outbound request,
|
|
100
|
+
// because for a local runtime "configured" only means something if it
|
|
101
|
+
// means "reachable" — both providers have zero required env vars
|
|
102
|
+
// (`credentialsResolvedExternally` / no `apiKey`), so with no request at
|
|
103
|
+
// all every install would read as configured. Auto-select
|
|
104
|
+
// (`NeuroLink.hasProviderEnvVars`) depends on that: it always calls with
|
|
105
|
+
// `includeConnectivityTest: false`, so gating this probe on that flag
|
|
106
|
+
// instead of the breaker would make a dead local proxy look configured
|
|
107
|
+
// and win auto-select over a real, working cloud provider. So this
|
|
108
|
+
// probe still runs in shallow mode, but — like the step-3 connectivity
|
|
109
|
+
// probe — it is governed by the SAME breaker below (`allowRuntimeProbe`
|
|
110
|
+
// passed into checkEnvironmentConfiguration): skipped while blacklisted,
|
|
111
|
+
// and its outcome folded into `anyProbeFailed`/`anyProbeRan` further
|
|
112
|
+
// down, so a dead local proxy still gets backed off instead of eating a
|
|
113
|
+
// full timeout on every call forever.
|
|
114
|
+
//
|
|
115
|
+
// A run of failures older than CIRCUIT_BREAKER_RESET_MS is forgotten,
|
|
116
|
+
// which is what makes the "will be retried after cache TTL expires"
|
|
117
|
+
// warning below true. Nothing else could clear it, because the
|
|
118
|
+
// breaker's whole effect is to suppress the probe that would disprove
|
|
119
|
+
// it. This is deliberately the fixed CIRCUIT_BREAKER_RESET_MS window,
|
|
120
|
+
// not this call's own `maxCacheAge` — `consecutiveFailures` is a single
|
|
121
|
+
// shared map keyed only by provider name, so reusing whichever caller
|
|
122
|
+
// happens to invoke `checkProviderHealth` next (some call sites pass a
|
|
123
|
+
// much shorter `maxCacheAge` for their own unrelated caching needs)
|
|
124
|
+
// would let that caller silently evict a backoff a different caller is
|
|
125
|
+
// relying on.
|
|
126
|
+
const breaker = this.consecutiveFailures.get(providerName);
|
|
127
|
+
if (breaker !== undefined &&
|
|
128
|
+
Date.now() - breaker.lastFailureAt >= this.CIRCUIT_BREAKER_RESET_MS) {
|
|
129
|
+
this.consecutiveFailures.delete(providerName);
|
|
130
|
+
}
|
|
131
|
+
const failureCount = this.consecutiveFailures.get(providerName)?.count ?? 0;
|
|
132
|
+
const blacklisted = failureCount >= this.CONSECUTIVE_FAILURE_THRESHOLD;
|
|
133
|
+
if (blacklisted) {
|
|
63
134
|
logger.warn(`Provider ${providerName} blacklisted due to consecutive failures`, { failureCount });
|
|
64
|
-
return healthStatus;
|
|
65
135
|
}
|
|
66
136
|
const startTime = Date.now();
|
|
67
137
|
const healthStatus = {
|
|
@@ -74,18 +144,37 @@ export class ProviderHealthChecker {
|
|
|
74
144
|
recommendations: [],
|
|
75
145
|
};
|
|
76
146
|
try {
|
|
77
|
-
// 1. Check environment configuration
|
|
78
|
-
|
|
147
|
+
// 1. Check environment configuration. For LiteLLM/Ollama this
|
|
148
|
+
// includes a breaker-governed runtime probe — see the comment on
|
|
149
|
+
// `blacklisted` above.
|
|
150
|
+
const runtimeProbe = await this.checkEnvironmentConfiguration(providerName, healthStatus, timeout, !blacklisted);
|
|
79
151
|
// 2. Check API key validity (basic format validation)
|
|
80
152
|
await this.checkApiKeyValidity(providerName, healthStatus);
|
|
81
|
-
// 3. Optional: Connectivity test
|
|
82
|
-
|
|
153
|
+
// 3. Optional: Connectivity test. Also governed by the breaker.
|
|
154
|
+
const issuesBeforeProbe = healthStatus.configurationIssues.length;
|
|
155
|
+
const probed = includeConnectivityTest && !blacklisted;
|
|
156
|
+
if (probed) {
|
|
83
157
|
await this.checkConnectivity(providerName, healthStatus, timeout);
|
|
84
158
|
}
|
|
159
|
+
const probeFailed = probed && healthStatus.configurationIssues.length > issuesBeforeProbe;
|
|
160
|
+
// Fold the step-1 runtime probe (if this provider has one) together
|
|
161
|
+
// with the step-3 connectivity probe into one pair of breaker
|
|
162
|
+
// signals, so a call where BOTH run and fail still counts as exactly
|
|
163
|
+
// ONE failure (the breaker steps consecutive calls, not consecutive
|
|
164
|
+
// requests).
|
|
165
|
+
const anyProbeRan = probed || runtimeProbe.ran;
|
|
166
|
+
const anyProbeFailed = probeFailed || runtimeProbe.failed;
|
|
85
167
|
// 4. Optional: Model validation
|
|
86
168
|
if (includeModelValidation) {
|
|
87
169
|
await this.checkModelAvailability(providerName, healthStatus);
|
|
88
170
|
}
|
|
171
|
+
if (blacklisted) {
|
|
172
|
+
healthStatus.error = `Provider blacklisted after ${failureCount} consecutive failures`;
|
|
173
|
+
healthStatus.warning =
|
|
174
|
+
"Provider will be retried after cache TTL expires";
|
|
175
|
+
healthStatus.configurationIssues.push(`Blacklisted due to ${failureCount} consecutive failures`);
|
|
176
|
+
healthStatus.recommendations.push("Check provider status and configuration");
|
|
177
|
+
}
|
|
89
178
|
// 5. Determine overall health
|
|
90
179
|
healthStatus.isHealthy =
|
|
91
180
|
healthStatus.isConfigured &&
|
|
@@ -99,14 +188,17 @@ export class ProviderHealthChecker {
|
|
|
99
188
|
timestamp: Date.now(),
|
|
100
189
|
});
|
|
101
190
|
}
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
191
|
+
// Only a probe that actually ran moves the breaker. A check that
|
|
192
|
+
// issued no request is not evidence either way: it neither proves the
|
|
193
|
+
// provider is failing nor that it has recovered.
|
|
194
|
+
if (anyProbeFailed) {
|
|
195
|
+
this.consecutiveFailures.set(providerName, {
|
|
196
|
+
count: failureCount + 1,
|
|
197
|
+
lastFailureAt: Date.now(),
|
|
198
|
+
});
|
|
105
199
|
}
|
|
106
|
-
else {
|
|
107
|
-
|
|
108
|
-
const currentFailures = this.consecutiveFailures.get(providerName) || 0;
|
|
109
|
-
this.consecutiveFailures.set(providerName, currentFailures + 1);
|
|
200
|
+
else if (anyProbeRan) {
|
|
201
|
+
this.consecutiveFailures.delete(providerName);
|
|
110
202
|
}
|
|
111
203
|
logger.debug(`Health check completed for ${providerName}`, {
|
|
112
204
|
isHealthy: healthStatus.isHealthy,
|
|
@@ -119,12 +211,15 @@ export class ProviderHealthChecker {
|
|
|
119
211
|
healthStatus.error = errorMessage;
|
|
120
212
|
healthStatus.configurationIssues.push(`Health check failed: ${errorMessage}`);
|
|
121
213
|
healthStatus.responseTime = Date.now() - startTime;
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
this.consecutiveFailures.set(providerName,
|
|
214
|
+
// A check that threw failed to answer at all, which is a failure in
|
|
215
|
+
// the sense the breaker is for — unlike an answered "not configured".
|
|
216
|
+
this.consecutiveFailures.set(providerName, {
|
|
217
|
+
count: failureCount + 1,
|
|
218
|
+
lastFailureAt: Date.now(),
|
|
219
|
+
});
|
|
125
220
|
logger.warn(`Health check failed for ${providerName}`, {
|
|
126
221
|
error: errorMessage,
|
|
127
|
-
consecutiveFailures:
|
|
222
|
+
consecutiveFailures: failureCount + 1,
|
|
128
223
|
});
|
|
129
224
|
}
|
|
130
225
|
return healthStatus;
|
|
@@ -132,7 +227,7 @@ export class ProviderHealthChecker {
|
|
|
132
227
|
/**
|
|
133
228
|
* Check environment configuration for a provider
|
|
134
229
|
*/
|
|
135
|
-
static async checkEnvironmentConfiguration(providerName, healthStatus, timeout) {
|
|
230
|
+
static async checkEnvironmentConfiguration(providerName, healthStatus, timeout, allowRuntimeProbe) {
|
|
136
231
|
const requiredEnvVars = this.getRequiredEnvironmentVariables(providerName);
|
|
137
232
|
logger.debug(`[ProviderHealthChecker] Checking environment configuration for ${providerName}`, {
|
|
138
233
|
requiredEnvVars,
|
|
@@ -163,7 +258,7 @@ export class ProviderHealthChecker {
|
|
|
163
258
|
healthStatus.recommendations.push(`Set the following environment variables: ${missingVars.join(", ")}`);
|
|
164
259
|
}
|
|
165
260
|
// Provider-specific configuration checks
|
|
166
|
-
|
|
261
|
+
return this.checkProviderSpecificConfig(providerName, healthStatus, timeout, allowRuntimeProbe);
|
|
167
262
|
}
|
|
168
263
|
/**
|
|
169
264
|
* Check API key validity (format validation)
|
|
@@ -456,7 +551,7 @@ export class ProviderHealthChecker {
|
|
|
456
551
|
/**
|
|
457
552
|
* Provider-specific configuration checks
|
|
458
553
|
*/
|
|
459
|
-
static async checkProviderSpecificConfig(providerName, healthStatus, timeout) {
|
|
554
|
+
static async checkProviderSpecificConfig(providerName, healthStatus, timeout, allowRuntimeProbe) {
|
|
460
555
|
switch (providerName) {
|
|
461
556
|
case AIProviderName.VERTEX:
|
|
462
557
|
await this.checkVertexAIConfig(healthStatus);
|
|
@@ -468,12 +563,13 @@ export class ProviderHealthChecker {
|
|
|
468
563
|
await this.checkAzureConfig(healthStatus);
|
|
469
564
|
break;
|
|
470
565
|
case AIProviderName.LITELLM:
|
|
471
|
-
|
|
472
|
-
break;
|
|
566
|
+
return this.checkLiteLLMConfig(healthStatus, timeout, allowRuntimeProbe);
|
|
473
567
|
case AIProviderName.OLLAMA:
|
|
474
|
-
|
|
475
|
-
break;
|
|
568
|
+
return this.checkOllamaConfig(healthStatus, timeout, allowRuntimeProbe);
|
|
476
569
|
}
|
|
570
|
+
// Every other provider's config check above is local (env vars, file
|
|
571
|
+
// existence) — never a network request, so there is no probe to report.
|
|
572
|
+
return { ran: false, failed: false };
|
|
477
573
|
}
|
|
478
574
|
/**
|
|
479
575
|
* Check Vertex AI configuration
|
|
@@ -800,13 +896,22 @@ export class ProviderHealthChecker {
|
|
|
800
896
|
};
|
|
801
897
|
}
|
|
802
898
|
}
|
|
803
|
-
static async checkLiteLLMConfig(healthStatus, timeout = this.DEFAULT_TIMEOUT) {
|
|
899
|
+
static async checkLiteLLMConfig(healthStatus, timeout = this.DEFAULT_TIMEOUT, allowRuntimeProbe = true) {
|
|
804
900
|
const liteLLMBase = this.getLiteLLMBaseUrl();
|
|
805
901
|
if (!liteLLMBase.startsWith("http")) {
|
|
806
902
|
healthStatus.isConfigured = false;
|
|
807
903
|
healthStatus.configurationIssues.push("Invalid LITELLM_BASE_URL format");
|
|
808
904
|
healthStatus.recommendations.push("Set LITELLM_BASE_URL to a valid URL (e.g., http://localhost:4000)");
|
|
809
|
-
return;
|
|
905
|
+
return { ran: false, failed: false };
|
|
906
|
+
}
|
|
907
|
+
if (!allowRuntimeProbe) {
|
|
908
|
+
// Blacklisted: don't issue the request. A repeatedly unreachable
|
|
909
|
+
// local provider is not usable, which is what auto-select needs —
|
|
910
|
+
// but the caller's blacklist block (checkProviderHealth) already adds
|
|
911
|
+
// the error/warning/configurationIssue for this, so nothing is
|
|
912
|
+
// duplicated here.
|
|
913
|
+
healthStatus.isConfigured = false;
|
|
914
|
+
return { ran: false, failed: false };
|
|
810
915
|
}
|
|
811
916
|
// Only pin the availability check to a specific model when the user
|
|
812
917
|
// explicitly configured one. The fallback default ("openai/gpt-4o-mini")
|
|
@@ -840,20 +945,30 @@ export class ProviderHealthChecker {
|
|
|
840
945
|
healthStatus.isConfigured = false;
|
|
841
946
|
healthStatus.configurationIssues.push(`LiteLLM runtime check failed: ${failureReason}`);
|
|
842
947
|
healthStatus.recommendations.push("Start the LiteLLM proxy and ensure the configured model is available from /v1/models");
|
|
843
|
-
return;
|
|
948
|
+
return { ran: true, failed: true };
|
|
844
949
|
}
|
|
845
950
|
healthStatus.isConfigured = true;
|
|
951
|
+
return { ran: true, failed: false };
|
|
846
952
|
}
|
|
847
953
|
/**
|
|
848
954
|
* Check Ollama configuration
|
|
849
955
|
*/
|
|
850
|
-
static async checkOllamaConfig(healthStatus, timeout = this.DEFAULT_TIMEOUT) {
|
|
956
|
+
static async checkOllamaConfig(healthStatus, timeout = this.DEFAULT_TIMEOUT, allowRuntimeProbe = true) {
|
|
851
957
|
const ollamaBase = this.getOllamaBaseUrl();
|
|
852
958
|
if (!ollamaBase.startsWith("http")) {
|
|
853
959
|
healthStatus.isConfigured = false;
|
|
854
960
|
healthStatus.configurationIssues.push("Invalid OLLAMA_BASE_URL format (OLLAMA_API_BASE is still accepted as a legacy alias)");
|
|
855
961
|
healthStatus.recommendations.push("Set OLLAMA_BASE_URL to a valid URL (e.g., http://localhost:11434). OLLAMA_API_BASE remains supported as a legacy alias.");
|
|
856
|
-
return;
|
|
962
|
+
return { ran: false, failed: false };
|
|
963
|
+
}
|
|
964
|
+
if (!allowRuntimeProbe) {
|
|
965
|
+
// Blacklisted: don't issue the request. A repeatedly unreachable
|
|
966
|
+
// local provider is not usable, which is what auto-select needs —
|
|
967
|
+
// but the caller's blacklist block (checkProviderHealth) already adds
|
|
968
|
+
// the error/warning/configurationIssue for this, so nothing is
|
|
969
|
+
// duplicated here.
|
|
970
|
+
healthStatus.isConfigured = false;
|
|
971
|
+
return { ran: false, failed: false };
|
|
857
972
|
}
|
|
858
973
|
const availability = await this.checkOllamaAvailability({
|
|
859
974
|
model: this.getConfiguredOllamaModel(),
|
|
@@ -863,9 +978,10 @@ export class ProviderHealthChecker {
|
|
|
863
978
|
healthStatus.isConfigured = false;
|
|
864
979
|
healthStatus.configurationIssues.push(`Ollama runtime check failed: ${availability.reason ?? "unknown error"}`);
|
|
865
980
|
healthStatus.recommendations.push("Start Ollama and install the configured model before using Ollama as a fallback provider");
|
|
866
|
-
return;
|
|
981
|
+
return { ran: true, failed: true };
|
|
867
982
|
}
|
|
868
983
|
healthStatus.isConfigured = true;
|
|
984
|
+
return { ran: true, failed: false };
|
|
869
985
|
}
|
|
870
986
|
/**
|
|
871
987
|
* Get common models for a provider
|
|
@@ -1443,18 +1559,64 @@ export class ProviderHealthChecker {
|
|
|
1443
1559
|
return null;
|
|
1444
1560
|
}
|
|
1445
1561
|
/**
|
|
1446
|
-
* Get health status for all registered providers
|
|
1562
|
+
* Get health status for all registered providers.
|
|
1563
|
+
*
|
|
1564
|
+
* Membership: every descriptor in PROVIDER_DESCRIPTORS EXCEPT those that
|
|
1565
|
+
* explicitly opt out via `excludeFromHealthSweep` (none currently do).
|
|
1566
|
+
* Before issue #1305's fix, membership was implicit opt-IN via
|
|
1567
|
+
* `defaultHealthSweepPriority`, which only 8 of ~38 descriptors carried —
|
|
1568
|
+
* every other registered provider was silently invisible to this sweep,
|
|
1569
|
+
* including anything a caller had actually configured and could use.
|
|
1570
|
+
*
|
|
1571
|
+
* Order: `defaultHealthSweepPriority` still decides ORDER for the
|
|
1572
|
+
* providers that set it (lower = first — order is behaviour for
|
|
1573
|
+
* first-healthy-wins callers like `getBestHealthyProvider`); everything
|
|
1574
|
+
* else sorts after them, in PROVIDER_DESCRIPTORS's own declaration order
|
|
1575
|
+
* (Array.prototype.sort is stable, so ties never reshuffle).
|
|
1576
|
+
*
|
|
1577
|
+
* Checks run through a worker pool of at most
|
|
1578
|
+
* MAX_CONCURRENT_HEALTH_CHECKS, so a full sweep of every provider can't
|
|
1579
|
+
* fire dozens of concurrent requests at once when a caller opts into
|
|
1580
|
+
* `includeConnectivityTest`, and no check waits on a slower one beside
|
|
1581
|
+
* it.
|
|
1447
1582
|
*/
|
|
1448
1583
|
static async checkAllProvidersHealth(options = {}) {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
const providers = PROVIDER_DESCRIPTORS.filter((d) => d.defaultHealthSweepPriority !== undefined)
|
|
1453
|
-
.sort((a, b) => (a.defaultHealthSweepPriority ?? 0) -
|
|
1454
|
-
(b.defaultHealthSweepPriority ?? 0))
|
|
1584
|
+
const providers = PROVIDER_DESCRIPTORS.filter((d) => d.excludeFromHealthSweep !== true)
|
|
1585
|
+
.sort((a, b) => (a.defaultHealthSweepPriority ?? Number.MAX_SAFE_INTEGER) -
|
|
1586
|
+
(b.defaultHealthSweepPriority ?? Number.MAX_SAFE_INTEGER))
|
|
1455
1587
|
.map((d) => d.name);
|
|
1456
|
-
|
|
1457
|
-
|
|
1588
|
+
// A fixed pool of workers pulling from a shared cursor, NOT
|
|
1589
|
+
// chunk-and-await. Both bound concurrency to the same number, but
|
|
1590
|
+
// chunking also makes every batch wait for its slowest member before
|
|
1591
|
+
// the next one starts, so a sweep costs the SUM of the per-batch
|
|
1592
|
+
// maxima. With `includeConnectivityTest` each check can burn the whole
|
|
1593
|
+
// `timeout`, which turned a ~1x worst case into ~ceil(n/cap)x. Here a
|
|
1594
|
+
// slot freed by a fast check immediately takes the next provider, so
|
|
1595
|
+
// the bound costs nothing in latency.
|
|
1596
|
+
const results = new Array(providers.length);
|
|
1597
|
+
let cursor = 0;
|
|
1598
|
+
const runWorker = async () => {
|
|
1599
|
+
// `cursor++` is atomic here: there is no await between reading and
|
|
1600
|
+
// incrementing, so two workers can never claim the same provider.
|
|
1601
|
+
for (let index = cursor++; index < providers.length; index = cursor++) {
|
|
1602
|
+
try {
|
|
1603
|
+
results[index] = {
|
|
1604
|
+
status: "fulfilled",
|
|
1605
|
+
value: await this.checkProviderHealth(providers[index], options),
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
catch (reason) {
|
|
1609
|
+
results[index] = { status: "rejected", reason };
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
// Writing by index keeps results in provider order regardless of the
|
|
1614
|
+
// order they complete in, which the rejected-case fallback below
|
|
1615
|
+
// depends on. Zero providers means zero workers and an immediate
|
|
1616
|
+
// return.
|
|
1617
|
+
await Promise.all(Array.from({
|
|
1618
|
+
length: Math.min(this.MAX_CONCURRENT_HEALTH_CHECKS, providers.length),
|
|
1619
|
+
}, () => runWorker()));
|
|
1458
1620
|
return results.map((result, index) => {
|
|
1459
1621
|
if (result.status === "fulfilled") {
|
|
1460
1622
|
return result.value;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module utils/ttsProcessor
|
|
8
8
|
*/
|
|
9
|
-
import type { TTSChunk, TTSOptions, TTSResult, TTSHandler } from "../types/index.js";
|
|
9
|
+
import type { TTSChunk, TTSOptions, TTSResult, TTSHandler, TTSVoice } from "../types/index.js";
|
|
10
10
|
import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
|
|
11
11
|
import { NeuroLinkError } from "./errorHandling.js";
|
|
12
12
|
/**
|
|
@@ -128,6 +128,35 @@ export declare class TTSProcessor {
|
|
|
128
128
|
* ```
|
|
129
129
|
*/
|
|
130
130
|
static supports(providerName: string): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* List the voices a registered provider offers.
|
|
133
|
+
*
|
|
134
|
+
* The counterpart to `synthesize()` for discovery: a caller cannot pass
|
|
135
|
+
* `TTSOptions.voice` without first knowing what the provider will accept,
|
|
136
|
+
* and `getVoices` is optional on `TTSHandler`, so asking the handler
|
|
137
|
+
* directly means every caller re-implements the same two guards. Both
|
|
138
|
+
* failures are reported as typed `TTSError`s rather than a `TypeError` on
|
|
139
|
+
* an absent member.
|
|
140
|
+
*
|
|
141
|
+
* `languageCode` is passed through verbatim; each handler decides what
|
|
142
|
+
* filtering it means. Google and Azure query their APIs with it, OpenAI's
|
|
143
|
+
* voice list is fixed and ignores it.
|
|
144
|
+
*
|
|
145
|
+
* @param providerName - Provider identifier, resolved case-insensitively
|
|
146
|
+
* @param options - Optional language filter
|
|
147
|
+
* @returns The provider's voices
|
|
148
|
+
* @throws TTSError if the provider is not registered or cannot list voices
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```typescript
|
|
152
|
+
* const voices = await TTSProcessor.getVoices("google-ai", {
|
|
153
|
+
* languageCode: "en-US",
|
|
154
|
+
* });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
static getVoices(providerName: string, options?: {
|
|
158
|
+
languageCode?: string;
|
|
159
|
+
}): Promise<TTSVoice[]>;
|
|
131
160
|
/**
|
|
132
161
|
* Synthesize speech from text using a registered TTS provider
|
|
133
162
|
*
|
|
@@ -323,6 +323,82 @@ export class TTSProcessor {
|
|
|
323
323
|
}
|
|
324
324
|
return isSupported;
|
|
325
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* List the voices a registered provider offers.
|
|
328
|
+
*
|
|
329
|
+
* The counterpart to `synthesize()` for discovery: a caller cannot pass
|
|
330
|
+
* `TTSOptions.voice` without first knowing what the provider will accept,
|
|
331
|
+
* and `getVoices` is optional on `TTSHandler`, so asking the handler
|
|
332
|
+
* directly means every caller re-implements the same two guards. Both
|
|
333
|
+
* failures are reported as typed `TTSError`s rather than a `TypeError` on
|
|
334
|
+
* an absent member.
|
|
335
|
+
*
|
|
336
|
+
* `languageCode` is passed through verbatim; each handler decides what
|
|
337
|
+
* filtering it means. Google and Azure query their APIs with it, OpenAI's
|
|
338
|
+
* voice list is fixed and ignores it.
|
|
339
|
+
*
|
|
340
|
+
* @param providerName - Provider identifier, resolved case-insensitively
|
|
341
|
+
* @param options - Optional language filter
|
|
342
|
+
* @returns The provider's voices
|
|
343
|
+
* @throws TTSError if the provider is not registered or cannot list voices
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```typescript
|
|
347
|
+
* const voices = await TTSProcessor.getVoices("google-ai", {
|
|
348
|
+
* languageCode: "en-US",
|
|
349
|
+
* });
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
static async getVoices(providerName, options = {}) {
|
|
353
|
+
const handler = this.getHandler(providerName);
|
|
354
|
+
if (!handler) {
|
|
355
|
+
const registered = this.listProviders();
|
|
356
|
+
throw new TTSError({
|
|
357
|
+
code: TTS_ERROR_CODES.PROVIDER_NOT_SUPPORTED,
|
|
358
|
+
message: `TTS provider "${providerName}" is not registered. Registered providers: ${registered.length > 0 ? registered.join(", ") : "none"}`,
|
|
359
|
+
category: ErrorCategory.VALIDATION,
|
|
360
|
+
severity: ErrorSeverity.MEDIUM,
|
|
361
|
+
retriable: false,
|
|
362
|
+
context: { provider: providerName },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const listVoices = handler.getVoices;
|
|
366
|
+
if (typeof listVoices !== "function") {
|
|
367
|
+
throw new TTSError({
|
|
368
|
+
code: TTS_ERROR_CODES.PROVIDER_NOT_SUPPORTED,
|
|
369
|
+
message: `TTS provider "${providerName}" does not support voice listing`,
|
|
370
|
+
category: ErrorCategory.VALIDATION,
|
|
371
|
+
severity: ErrorSeverity.MEDIUM,
|
|
372
|
+
retriable: false,
|
|
373
|
+
context: { provider: providerName },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// Same two guards synthesize() enforces, so getVoices() keeps the
|
|
377
|
+
// `@throws TTSError` contract above regardless of what a given handler's
|
|
378
|
+
// own implementation does: block on configuration before the handler
|
|
379
|
+
// runs at all, and shape whatever it throws through the same
|
|
380
|
+
// toSynthesisError() synthesize() uses, so `code`/`retriable`/category
|
|
381
|
+
// survive for a caller keying retry logic off them.
|
|
382
|
+
if (!handler.isConfigured()) {
|
|
383
|
+
throw new TTSError({
|
|
384
|
+
code: TTS_ERROR_CODES.PROVIDER_NOT_CONFIGURED,
|
|
385
|
+
message: `TTS provider "${providerName}" is not configured. Please set the required API keys.`,
|
|
386
|
+
category: ErrorCategory.CONFIGURATION,
|
|
387
|
+
severity: ErrorSeverity.HIGH,
|
|
388
|
+
retriable: false,
|
|
389
|
+
context: { provider: providerName },
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
return await listVoices.call(handler, options.languageCode);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
// `getVoices`'s own options ({ languageCode }) share no fields with
|
|
397
|
+
// `TTSOptions` — synthesize()'s options shape — so there is nothing
|
|
398
|
+
// meaningful to forward into toSynthesisError()'s context here.
|
|
399
|
+
throw this.toSynthesisError(error, providerName, "", {});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
326
402
|
/**
|
|
327
403
|
* Synthesize speech from text using a registered TTS provider
|
|
328
404
|
*
|