@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +9 -2
  2. package/dist/adapters/tts/googleTTSHandler.d.ts +71 -1
  3. package/dist/adapters/tts/googleTTSHandler.js +254 -0
  4. package/dist/browser/neurolink.min.js +399 -399
  5. package/dist/cli/commands/voices.d.ts +23 -0
  6. package/dist/cli/commands/voices.js +102 -0
  7. package/dist/cli/loop/optionsSchema.d.ts +1 -1
  8. package/dist/cli/parser.js +3 -0
  9. package/dist/constants/enums.d.ts +13 -0
  10. package/dist/constants/enums.js +15 -0
  11. package/dist/core/baseProvider.d.ts +4 -0
  12. package/dist/core/baseProvider.js +245 -148
  13. package/dist/core/modules/structuredOutputPolicy.d.ts +27 -0
  14. package/dist/core/modules/structuredOutputPolicy.js +36 -0
  15. package/dist/core/nativeGenerateLoop.js +7 -1
  16. package/dist/neurolink.d.ts +42 -0
  17. package/dist/neurolink.js +102 -2
  18. package/dist/providers/catalog/index.generated.d.ts +1 -1
  19. package/dist/providers/catalog/index.generated.js +6 -0
  20. package/dist/providers/catalog/morph.json +97 -0
  21. package/dist/providers/catalog/novita.json +114 -0
  22. package/dist/providers/openaiChatCompletionsBase.d.ts +22 -0
  23. package/dist/providers/openaiChatCompletionsBase.js +291 -8
  24. package/dist/server/utils/validation.d.ts +34 -0
  25. package/dist/server/utils/validation.js +27 -0
  26. package/dist/server/websocket/WebSocketHandler.d.ts +4 -6
  27. package/dist/server/websocket/WebSocketHandler.js +185 -18
  28. package/dist/types/cli.d.ts +11 -0
  29. package/dist/types/generate.d.ts +15 -0
  30. package/dist/types/isolatedAgent.d.ts +16 -7
  31. package/dist/types/openaiCompatible.d.ts +6 -0
  32. package/dist/types/providerCatalog.generated.d.ts +2 -2
  33. package/dist/types/providers.d.ts +50 -3
  34. package/dist/types/server.d.ts +8 -0
  35. package/dist/types/stream.d.ts +30 -0
  36. package/dist/types/tts.d.ts +10 -0
  37. package/dist/types/utilities.d.ts +16 -3
  38. package/dist/utils/logger.d.ts +58 -13
  39. package/dist/utils/logger.js +156 -11
  40. package/dist/utils/providerHealth.d.ts +65 -1
  41. package/dist/utils/providerHealth.js +216 -54
  42. package/dist/utils/ttsProcessor.d.ts +30 -1
  43. package/dist/utils/ttsProcessor.js +76 -0
  44. package/docs-site/static/search-index.json +4 -4
  45. 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
- // Check if provider has consecutive failures (blacklisting)
48
- const failureCount = this.consecutiveFailures.get(providerName) || 0;
49
- if (failureCount >= this.CONSECUTIVE_FAILURE_THRESHOLD) {
50
- const healthStatus = {
51
- provider: providerName,
52
- isHealthy: false,
53
- isConfigured: false,
54
- hasApiKey: false,
55
- lastChecked: new Date(),
56
- error: `Provider blacklisted after ${failureCount} consecutive failures`,
57
- warning: "Provider will be retried after cache TTL expires",
58
- configurationIssues: [
59
- `Blacklisted due to ${failureCount} consecutive failures`,
60
- ],
61
- recommendations: ["Check provider status and configuration"],
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
- await this.checkEnvironmentConfiguration(providerName, healthStatus, timeout);
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
- if (includeConnectivityTest) {
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
- // Reset failure count on success
103
- if (healthStatus.isHealthy) {
104
- this.consecutiveFailures.delete(providerName);
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
- // Track consecutive failures
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
- // Track consecutive failures
123
- const currentFailures = this.consecutiveFailures.get(providerName) || 0;
124
- this.consecutiveFailures.set(providerName, currentFailures + 1);
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: currentFailures + 1,
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
- await this.checkProviderSpecificConfig(providerName, healthStatus, timeout);
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
- await this.checkLiteLLMConfig(healthStatus, timeout);
472
- break;
566
+ return this.checkLiteLLMConfig(healthStatus, timeout, allowRuntimeProbe);
473
567
  case AIProviderName.OLLAMA:
474
- await this.checkOllamaConfig(healthStatus, timeout);
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
- // Sweep membership and ORDER come from the descriptors. Order is
1450
- // behaviour: auto-select takes the first healthy provider, so the
1451
- // priority field, not the descriptor array's layout, decides preference.
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
- const healthChecks = providers.map((provider) => this.checkProviderHealth(provider, options));
1457
- const results = await Promise.allSettled(healthChecks);
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
  *