voice-mode 4.4.0__py3-none-any.whl → 4.5.0__py3-none-any.whl

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 (75) hide show
  1. voice_mode/__version__.py +1 -1
  2. voice_mode/cli.py +79 -3
  3. voice_mode/cli_commands/transcribe.py +7 -6
  4. voice_mode/config.py +1 -1
  5. voice_mode/conversation_logger.py +6 -0
  6. voice_mode/core.py +9 -2
  7. voice_mode/frontend/.next/BUILD_ID +1 -1
  8. voice_mode/frontend/.next/app-build-manifest.json +5 -5
  9. voice_mode/frontend/.next/build-manifest.json +3 -3
  10. voice_mode/frontend/.next/next-minimal-server.js.nft.json +1 -1
  11. voice_mode/frontend/.next/next-server.js.nft.json +1 -1
  12. voice_mode/frontend/.next/prerender-manifest.json +1 -1
  13. voice_mode/frontend/.next/required-server-files.json +1 -1
  14. voice_mode/frontend/.next/server/app/_not-found/page.js +1 -1
  15. voice_mode/frontend/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. voice_mode/frontend/.next/server/app/_not-found.html +1 -1
  17. voice_mode/frontend/.next/server/app/_not-found.rsc +1 -1
  18. voice_mode/frontend/.next/server/app/api/connection-details/route.js +2 -2
  19. voice_mode/frontend/.next/server/app/favicon.ico/route.js +2 -2
  20. voice_mode/frontend/.next/server/app/index.html +1 -1
  21. voice_mode/frontend/.next/server/app/index.rsc +2 -2
  22. voice_mode/frontend/.next/server/app/page.js +3 -3
  23. voice_mode/frontend/.next/server/app/page_client-reference-manifest.js +1 -1
  24. voice_mode/frontend/.next/server/chunks/994.js +1 -1
  25. voice_mode/frontend/.next/server/middleware-build-manifest.js +1 -1
  26. voice_mode/frontend/.next/server/next-font-manifest.js +1 -1
  27. voice_mode/frontend/.next/server/next-font-manifest.json +1 -1
  28. voice_mode/frontend/.next/server/pages/404.html +1 -1
  29. voice_mode/frontend/.next/server/pages/500.html +1 -1
  30. voice_mode/frontend/.next/server/server-reference-manifest.json +1 -1
  31. voice_mode/frontend/.next/standalone/.next/BUILD_ID +1 -1
  32. voice_mode/frontend/.next/standalone/.next/app-build-manifest.json +5 -5
  33. voice_mode/frontend/.next/standalone/.next/build-manifest.json +3 -3
  34. voice_mode/frontend/.next/standalone/.next/prerender-manifest.json +1 -1
  35. voice_mode/frontend/.next/standalone/.next/required-server-files.json +1 -1
  36. voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  37. voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  38. voice_mode/frontend/.next/standalone/.next/server/app/_not-found.html +1 -1
  39. voice_mode/frontend/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  40. voice_mode/frontend/.next/standalone/.next/server/app/api/connection-details/route.js +2 -2
  41. voice_mode/frontend/.next/standalone/.next/server/app/favicon.ico/route.js +2 -2
  42. voice_mode/frontend/.next/standalone/.next/server/app/index.html +1 -1
  43. voice_mode/frontend/.next/standalone/.next/server/app/index.rsc +2 -2
  44. voice_mode/frontend/.next/standalone/.next/server/app/page.js +3 -3
  45. voice_mode/frontend/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. voice_mode/frontend/.next/standalone/.next/server/chunks/994.js +1 -1
  47. voice_mode/frontend/.next/standalone/.next/server/middleware-build-manifest.js +1 -1
  48. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.js +1 -1
  49. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.json +1 -1
  50. voice_mode/frontend/.next/standalone/.next/server/pages/404.html +1 -1
  51. voice_mode/frontend/.next/standalone/.next/server/pages/500.html +1 -1
  52. voice_mode/frontend/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  53. voice_mode/frontend/.next/standalone/server.js +1 -1
  54. voice_mode/frontend/.next/static/chunks/app/layout-d3ec7f6f14ea7396.js +1 -0
  55. voice_mode/frontend/.next/static/chunks/app/{page-ae0d14863ed895ea.js → page-471796963fb1a4bd.js} +1 -1
  56. voice_mode/frontend/.next/static/chunks/{main-app-836e76fc70b52220.js → main-app-78da5e437b6a2a9f.js} +1 -1
  57. voice_mode/frontend/.next/trace +43 -43
  58. voice_mode/frontend/.next/types/app/api/connection-details/route.ts +1 -1
  59. voice_mode/frontend/.next/types/app/layout.ts +1 -1
  60. voice_mode/frontend/.next/types/app/page.ts +1 -1
  61. voice_mode/frontend/package-lock.json +26 -15
  62. voice_mode/provider_discovery.py +55 -79
  63. voice_mode/providers.py +61 -45
  64. voice_mode/simple_failover.py +41 -12
  65. voice_mode/tools/__init__.py +138 -30
  66. voice_mode/tools/converse.py +148 -337
  67. voice_mode/tools/diagnostics.py +2 -1
  68. voice_mode/tools/voice_registry.py +24 -28
  69. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/METADATA +5 -2
  70. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/RECORD +74 -74
  71. voice_mode/frontend/.next/static/chunks/app/layout-917e8410913fe899.js +0 -1
  72. /voice_mode/frontend/.next/static/{WhZriRkBKVNPSmCnOFRav → Ni4GIqyDdn0QehvmlLBZg}/_buildManifest.js +0 -0
  73. /voice_mode/frontend/.next/static/{WhZriRkBKVNPSmCnOFRav → Ni4GIqyDdn0QehvmlLBZg}/_ssgManifest.js +0 -0
  74. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/WHEEL +0 -0
  75. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/entry_points.txt +0 -0
@@ -1,4 +1,4 @@
1
- // File: /tmp/build-via-sdist-z_iazuah/voice_mode-4.4.0/voice_mode/frontend/app/api/connection-details/route.ts
1
+ // File: /tmp/build-via-sdist-yrh8rni2/voice_mode-4.5.0/voice_mode/frontend/app/api/connection-details/route.ts
2
2
  import * as entry from '../../../../../app/api/connection-details/route.js'
3
3
  import type { NextRequest } from 'next/server.js'
4
4
 
@@ -1,4 +1,4 @@
1
- // File: /tmp/build-via-sdist-z_iazuah/voice_mode-4.4.0/voice_mode/frontend/app/layout.tsx
1
+ // File: /tmp/build-via-sdist-yrh8rni2/voice_mode-4.5.0/voice_mode/frontend/app/layout.tsx
2
2
  import * as entry from '../../../app/layout.js'
3
3
  import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
4
4
 
@@ -1,4 +1,4 @@
1
- // File: /tmp/build-via-sdist-z_iazuah/voice_mode-4.4.0/voice_mode/frontend/app/page.tsx
1
+ // File: /tmp/build-via-sdist-yrh8rni2/voice_mode-4.5.0/voice_mode/frontend/app/page.tsx
2
2
  import * as entry from '../../../app/page.js'
3
3
  import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
4
4
 
@@ -1328,6 +1328,16 @@
1328
1328
  "dev": true,
1329
1329
  "license": "MIT"
1330
1330
  },
1331
+ "node_modules/baseline-browser-mapping": {
1332
+ "version": "2.8.5",
1333
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.5.tgz",
1334
+ "integrity": "sha512-TiU4qUT9jdCuh4aVOG7H1QozyeI2sZRqoRPdqBIaslfNt4WUSanRBueAwl2x5jt4rXBMim3lIN2x6yT8PDi24Q==",
1335
+ "dev": true,
1336
+ "license": "Apache-2.0",
1337
+ "bin": {
1338
+ "baseline-browser-mapping": "dist/cli.js"
1339
+ }
1340
+ },
1331
1341
  "node_modules/binary-extensions": {
1332
1342
  "version": "2.3.0",
1333
1343
  "dev": true,
@@ -1360,9 +1370,9 @@
1360
1370
  }
1361
1371
  },
1362
1372
  "node_modules/browserslist": {
1363
- "version": "4.25.4",
1364
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
1365
- "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==",
1373
+ "version": "4.26.2",
1374
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
1375
+ "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==",
1366
1376
  "dev": true,
1367
1377
  "funding": [
1368
1378
  {
@@ -1380,9 +1390,10 @@
1380
1390
  ],
1381
1391
  "license": "MIT",
1382
1392
  "dependencies": {
1383
- "caniuse-lite": "^1.0.30001737",
1384
- "electron-to-chromium": "^1.5.211",
1385
- "node-releases": "^2.0.19",
1393
+ "baseline-browser-mapping": "^2.8.3",
1394
+ "caniuse-lite": "^1.0.30001741",
1395
+ "electron-to-chromium": "^1.5.218",
1396
+ "node-releases": "^2.0.21",
1386
1397
  "update-browserslist-db": "^1.1.3"
1387
1398
  },
1388
1399
  "bin": {
@@ -1489,9 +1500,9 @@
1489
1500
  }
1490
1501
  },
1491
1502
  "node_modules/caniuse-lite": {
1492
- "version": "1.0.30001741",
1493
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
1494
- "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==",
1503
+ "version": "1.0.30001743",
1504
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
1505
+ "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==",
1495
1506
  "dev": true,
1496
1507
  "funding": [
1497
1508
  {
@@ -1774,9 +1785,9 @@
1774
1785
  "license": "MIT"
1775
1786
  },
1776
1787
  "node_modules/electron-to-chromium": {
1777
- "version": "1.5.215",
1778
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz",
1779
- "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==",
1788
+ "version": "1.5.220",
1789
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.220.tgz",
1790
+ "integrity": "sha512-TWXijEwR1ggr4BdAKrb1nMNqYLTx1/4aD1fkeZU+FVJGTKu53/T7UyHKXlqEX3Ub02csyHePbHmkvnrjcaYzMA==",
1780
1791
  "dev": true,
1781
1792
  "license": "ISC"
1782
1793
  },
@@ -3799,9 +3810,9 @@
3799
3810
  }
3800
3811
  },
3801
3812
  "node_modules/node-releases": {
3802
- "version": "2.0.20",
3803
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz",
3804
- "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==",
3813
+ "version": "2.0.21",
3814
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
3815
+ "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==",
3805
3816
  "dev": true,
3806
3817
  "license": "MIT"
3807
3818
  },
@@ -26,6 +26,8 @@ logger = logging.getLogger("voice-mode")
26
26
 
27
27
  def detect_provider_type(base_url: str) -> str:
28
28
  """Detect provider type from base URL."""
29
+ if not base_url:
30
+ return "unknown"
29
31
  if "openai.com" in base_url:
30
32
  return "openai"
31
33
  elif ":8880" in base_url:
@@ -47,6 +49,8 @@ def detect_provider_type(base_url: str) -> str:
47
49
 
48
50
  def is_local_provider(base_url: str) -> bool:
49
51
  """Check if a provider URL is for a local service."""
52
+ if not base_url:
53
+ return False
50
54
  provider_type = detect_provider_type(base_url)
51
55
  return provider_type in ["kokoro", "whisper", "local"] or \
52
56
  "127.0.0.1" in base_url or \
@@ -57,13 +61,11 @@ def is_local_provider(base_url: str) -> bool:
57
61
  class EndpointInfo:
58
62
  """Information about a discovered endpoint."""
59
63
  base_url: str
60
- healthy: bool
61
64
  models: List[str]
62
65
  voices: List[str] # Only for TTS
63
- last_health_check: str # ISO format timestamp
64
- response_time_ms: Optional[float] = None
65
- error: Optional[str] = None
66
66
  provider_type: Optional[str] = None # e.g., "openai", "kokoro", "whisper"
67
+ last_check: Optional[str] = None # ISO format timestamp of last attempt
68
+ last_error: Optional[str] = None # Last error if any
67
69
 
68
70
 
69
71
  class ProviderRegistry:
@@ -78,44 +80,38 @@ class ProviderRegistry:
78
80
  self._initialized = False
79
81
 
80
82
  async def initialize(self):
81
- """Initialize the registry by assuming all configured endpoints are healthy."""
83
+ """Initialize the registry with configured endpoints."""
82
84
  if self._initialized:
83
85
  return
84
-
86
+
85
87
  async with self._discovery_lock:
86
88
  if self._initialized: # Double-check after acquiring lock
87
89
  return
88
-
89
- logger.info("Initializing provider registry (optimistic mode)...")
90
-
91
- # Initialize TTS endpoints as healthy
90
+
91
+ logger.info("Initializing provider registry...")
92
+
93
+ # Initialize TTS endpoints
92
94
  for url in TTS_BASE_URLS:
93
95
  provider_type = detect_provider_type(url)
94
96
  self.registry["tts"][url] = EndpointInfo(
95
97
  base_url=url,
96
- healthy=True,
97
98
  models=["gpt4o-mini-tts", "tts-1", "tts-1-hd"] if provider_type == "openai" else ["tts-1"],
98
99
  voices=["alloy", "echo", "fable", "nova", "onyx", "shimmer"] if provider_type == "openai" else ["af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky", "af_v0", "af_v0bella", "af_v0irulan", "af_v0nicole", "af_v0sarah", "af_v0sky", "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_michael", "am_onyx", "am_puck", "am_santa", "am_v0adam", "am_v0gurney", "am_v0michael", "bf_alice", "bf_emma", "bf_lily", "bf_v0emma", "bf_v0isabella", "bm_daniel", "bm_fable", "bm_george", "bm_lewis", "bm_v0george", "bm_v0lewis", "ef_dora", "em_alex", "em_santa", "ff_siwis", "hf_alpha", "hf_beta", "hm_omega", "hm_psi", "if_sara", "im_nicola", "jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo", "pf_dora", "pm_alex", "pm_santa", "zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi", "zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang"],
99
- last_health_check=datetime.now(timezone.utc).isoformat(),
100
- response_time_ms=None,
101
100
  provider_type=provider_type
102
101
  )
103
102
 
104
- # Initialize STT endpoints as healthy
103
+ # Initialize STT endpoints
105
104
  for url in STT_BASE_URLS:
106
105
  provider_type = detect_provider_type(url)
107
106
  self.registry["stt"][url] = EndpointInfo(
108
107
  base_url=url,
109
- healthy=True,
110
108
  models=["whisper-1"],
111
- voices=[],
112
- last_health_check=datetime.now(timezone.utc).isoformat(),
113
- response_time_ms=None,
109
+ voices=[], # STT doesn't have voices
114
110
  provider_type=provider_type
115
111
  )
116
-
112
+
117
113
  self._initialized = True
118
- logger.info(f"Provider registry initialized with {len(self.registry['tts'])} TTS and {len(self.registry['stt'])} STT endpoints (all assumed healthy)")
114
+ logger.info(f"Provider registry initialized with {len(self.registry['tts'])} TTS and {len(self.registry['stt'])} STT endpoints")
119
115
 
120
116
  async def _discover_endpoints(self, service_type: str, base_urls: List[str]):
121
117
  """Discover all endpoints for a service type."""
@@ -131,12 +127,11 @@ class ProviderRegistry:
131
127
  logger.error(f"Failed to discover {service_type} endpoint {url}: {result}")
132
128
  self.registry[service_type][url] = EndpointInfo(
133
129
  base_url=url,
134
- healthy=False,
135
130
  models=[],
136
131
  voices=[],
137
- last_health_check=datetime.now(timezone.utc).isoformat(),
138
- error=str(result),
139
- provider_type=detect_provider_type(url)
132
+ provider_type=detect_provider_type(url),
133
+ last_check=datetime.now(timezone.utc).isoformat(),
134
+ last_error=str(result)
140
135
  )
141
136
 
142
137
  async def _discover_endpoint(self, service_type: str, base_url: str) -> None:
@@ -201,12 +196,11 @@ class ProviderRegistry:
201
196
  # Store endpoint info
202
197
  self.registry[service_type][base_url] = EndpointInfo(
203
198
  base_url=base_url,
204
- healthy=True,
205
199
  models=models,
206
200
  voices=voices,
207
- last_health_check=datetime.now(timezone.utc).isoformat(),
208
- response_time_ms=response_time,
209
- provider_type=detect_provider_type(base_url)
201
+ provider_type=detect_provider_type(base_url),
202
+ last_check=datetime.now(timezone.utc).isoformat(),
203
+ last_error=None
210
204
  )
211
205
 
212
206
  logger.info(f"Successfully discovered {service_type} endpoint {base_url} with {len(models)} models and {len(voices)} voices")
@@ -215,12 +209,11 @@ class ProviderRegistry:
215
209
  logger.warning(f"Endpoint {base_url} discovery failed: {e}")
216
210
  self.registry[service_type][base_url] = EndpointInfo(
217
211
  base_url=base_url,
218
- healthy=False,
219
212
  models=[],
220
213
  voices=[],
221
- last_health_check=datetime.now(timezone.utc).isoformat(),
222
- error=str(e),
223
- provider_type=detect_provider_type(base_url)
214
+ provider_type=detect_provider_type(base_url),
215
+ last_check=datetime.now(timezone.utc).isoformat(),
216
+ last_error=str(e)
224
217
  )
225
218
 
226
219
  async def _discover_voices(self, base_url: str, client: AsyncOpenAI) -> List[str]:
@@ -247,41 +240,35 @@ class ProviderRegistry:
247
240
  # The system will use configured defaults instead
248
241
  return []
249
242
 
250
- async def check_health(self, service_type: str, base_url: str) -> bool:
251
- """Check the health of a specific endpoint and update registry."""
252
- logger.debug(f"Health check for {service_type} endpoint: {base_url}")
253
-
254
- # Re-discover the endpoint
255
- await self._discover_endpoint(service_type, base_url)
256
-
257
- # Return health status
258
- endpoint_info = self.registry[service_type].get(base_url)
259
- return endpoint_info.healthy if endpoint_info else False
260
243
 
261
- def get_healthy_endpoints(self, service_type: str) -> List[EndpointInfo]:
262
- """Get all healthy endpoints for a service type."""
244
+ def get_endpoints(self, service_type: str) -> List[EndpointInfo]:
245
+ """Get all endpoints for a service type in priority order."""
263
246
  endpoints = []
264
-
247
+
265
248
  # Return endpoints in the order they were configured
266
249
  base_urls = TTS_BASE_URLS if service_type == "tts" else STT_BASE_URLS
267
-
250
+
268
251
  for url in base_urls:
269
252
  info = self.registry[service_type].get(url)
270
- if info and info.healthy:
253
+ if info:
271
254
  endpoints.append(info)
272
-
255
+
273
256
  return endpoints
257
+
258
+ def get_healthy_endpoints(self, service_type: str) -> List[EndpointInfo]:
259
+ """Deprecated: Use get_endpoints instead. Returns all endpoints."""
260
+ return self.get_endpoints(service_type)
274
261
 
275
262
  def find_endpoint_with_voice(self, voice: str) -> Optional[EndpointInfo]:
276
- """Find the first healthy TTS endpoint that supports a specific voice."""
277
- for endpoint in self.get_healthy_endpoints("tts"):
263
+ """Find the first TTS endpoint that supports a specific voice."""
264
+ for endpoint in self.get_endpoints("tts"):
278
265
  if voice in endpoint.voices:
279
266
  return endpoint
280
267
  return None
281
-
268
+
282
269
  def find_endpoint_with_model(self, service_type: str, model: str) -> Optional[EndpointInfo]:
283
- """Find the first healthy endpoint that supports a specific model."""
284
- for endpoint in self.get_healthy_endpoints(service_type):
270
+ """Find the first endpoint that supports a specific model."""
271
+ for endpoint in self.get_endpoints(service_type):
285
272
  if model in endpoint.models:
286
273
  return endpoint
287
274
  return None
@@ -291,47 +278,36 @@ class ProviderRegistry:
291
278
  return {
292
279
  "tts": {
293
280
  url: {
294
- "healthy": info.healthy,
295
281
  "models": info.models,
296
282
  "voices": info.voices,
297
- "response_time_ms": info.response_time_ms,
298
- "last_check": info.last_health_check,
299
- "error": info.error
283
+ "provider_type": info.provider_type,
284
+ "last_check": info.last_check,
285
+ "last_error": info.last_error
300
286
  }
301
287
  for url, info in self.registry["tts"].items()
302
288
  },
303
289
  "stt": {
304
290
  url: {
305
- "healthy": info.healthy,
306
291
  "models": info.models,
307
- "response_time_ms": info.response_time_ms,
308
- "last_check": info.last_health_check,
309
- "error": info.error
292
+ "provider_type": info.provider_type,
293
+ "last_check": info.last_check,
294
+ "last_error": info.last_error
310
295
  }
311
296
  for url, info in self.registry["stt"].items()
312
297
  }
313
298
  }
314
299
 
315
- async def mark_unhealthy(self, service_type: str, base_url: str, error: str):
316
- """Mark an endpoint as unhealthy after a failure.
317
-
318
- If ALWAYS_TRY_LOCAL is enabled and the provider is local, it will not be
319
- permanently marked as unhealthy - it will be retried on next request.
300
+ async def mark_failed(self, service_type: str, base_url: str, error: str):
301
+ """Record that an endpoint failed.
302
+
303
+ This updates the last_error and last_check fields for diagnostics,
304
+ but doesn't prevent the endpoint from being tried again.
320
305
  """
321
306
  if base_url in self.registry[service_type]:
322
- # Check if we should skip marking local providers as unhealthy
323
- if config.ALWAYS_TRY_LOCAL and is_local_provider(base_url):
324
- # Log the error but don't mark as unhealthy
325
- logger.info(f"Local {service_type} endpoint {base_url} failed ({error}) but will be retried (ALWAYS_TRY_LOCAL enabled)")
326
- # Update error and last check time for diagnostics, but keep healthy=True
327
- self.registry[service_type][base_url].error = f"{error} (will retry)"
328
- self.registry[service_type][base_url].last_health_check = datetime.now(timezone.utc).isoformat()
329
- else:
330
- # Normal behavior - mark as unhealthy
331
- self.registry[service_type][base_url].healthy = False
332
- self.registry[service_type][base_url].error = error
333
- self.registry[service_type][base_url].last_health_check = datetime.now(timezone.utc).isoformat()
334
- logger.warning(f"Marked {service_type} endpoint {base_url} as unhealthy: {error}")
307
+ # Update error and last check time for diagnostics
308
+ self.registry[service_type][base_url].last_error = error
309
+ self.registry[service_type][base_url].last_check = datetime.now(timezone.utc).isoformat()
310
+ logger.info(f"{service_type} endpoint {base_url} failed: {error}")
335
311
 
336
312
 
337
313
  # Global registry instance
voice_mode/providers.py CHANGED
@@ -10,7 +10,7 @@ from typing import Dict, Optional, List, Any, Tuple
10
10
  from openai import AsyncOpenAI
11
11
 
12
12
  from .config import TTS_VOICES, TTS_MODELS, TTS_BASE_URLS, OPENAI_API_KEY, get_voice_preferences
13
- from .provider_discovery import provider_registry, EndpointInfo
13
+ from .provider_discovery import provider_registry, EndpointInfo, is_local_provider
14
14
 
15
15
  logger = logging.getLogger("voice-mode")
16
16
 
@@ -49,21 +49,24 @@ async def get_tts_client_and_voice(
49
49
  if base_url:
50
50
  logger.info(f"TTS Provider Selection: Using specific base URL: {base_url}")
51
51
  endpoint_info = provider_registry.registry["tts"].get(base_url)
52
- if not endpoint_info or not endpoint_info.healthy:
53
- raise ValueError(f"Requested base URL {base_url} is not available")
54
-
52
+ if not endpoint_info:
53
+ raise ValueError(f"Requested base URL {base_url} is not configured")
54
+
55
55
  selected_voice = voice or _select_voice_for_endpoint(endpoint_info)
56
56
  selected_model = model or _select_model_for_endpoint(endpoint_info)
57
-
57
+
58
+ # Disable retries for local endpoints - they either work or don't
59
+ max_retries = 0 if is_local_provider(base_url) else 2
58
60
  client = AsyncOpenAI(
59
61
  api_key=OPENAI_API_KEY or "dummy-key-for-local",
60
- base_url=base_url
62
+ base_url=base_url,
63
+ max_retries=max_retries
61
64
  )
62
-
65
+
63
66
  logger.info(f" • Selected endpoint: {base_url}")
64
67
  logger.info(f" • Selected voice: {selected_voice}")
65
68
  logger.info(f" • Selected model: {selected_model}")
66
-
69
+
67
70
  return client, selected_voice, selected_model, endpoint_info
68
71
 
69
72
  # Voice-first selection algorithm
@@ -83,69 +86,75 @@ async def get_tts_client_and_voice(
83
86
  logger.info(f" Specific voice requested: {voice}")
84
87
  for url in TTS_BASE_URLS:
85
88
  endpoint_info = provider_registry.registry["tts"].get(url)
86
- if not endpoint_info or not endpoint_info.healthy:
89
+ if not endpoint_info:
87
90
  continue
88
-
91
+
89
92
  if voice in endpoint_info.voices:
90
93
  selected_voice = voice
91
94
  selected_model = _select_model_for_endpoint(endpoint_info, model)
92
-
95
+
93
96
  api_key = OPENAI_API_KEY if endpoint_info.provider_type == "openai" else (OPENAI_API_KEY or "dummy-key-for-local")
94
- client = AsyncOpenAI(api_key=api_key, base_url=url)
95
-
97
+ # Disable retries for local endpoints - they either work or don't
98
+ max_retries = 0 if is_local_provider(url) else 2
99
+ client = AsyncOpenAI(api_key=api_key, base_url=url, max_retries=max_retries)
100
+
96
101
  logger.info(f" ✓ Selected endpoint: {url} ({endpoint_info.provider_type})")
97
102
  logger.info(f" ✓ Selected voice: {selected_voice}")
98
103
  logger.info(f" ✓ Selected model: {selected_model}")
99
-
104
+
100
105
  return client, selected_voice, selected_model, endpoint_info
101
106
 
102
107
  # No specific voice requested - iterate through voice preferences
103
108
  logger.info(" No specific voice requested, checking voice preferences...")
104
109
  for preferred_voice in combined_voice_list:
105
110
  logger.debug(f" Looking for voice: {preferred_voice}")
106
-
111
+
107
112
  # Check each endpoint for this voice
108
113
  for url in TTS_BASE_URLS:
109
114
  endpoint_info = provider_registry.registry["tts"].get(url)
110
- if not endpoint_info or not endpoint_info.healthy:
115
+ if not endpoint_info:
111
116
  continue
112
-
117
+
113
118
  if preferred_voice in endpoint_info.voices:
114
119
  logger.info(f" Found voice '{preferred_voice}' at {url} ({endpoint_info.provider_type})")
115
120
  selected_voice = preferred_voice
116
121
  selected_model = _select_model_for_endpoint(endpoint_info, model)
117
-
122
+
118
123
  api_key = OPENAI_API_KEY if endpoint_info.provider_type == "openai" else (OPENAI_API_KEY or "dummy-key-for-local")
119
- client = AsyncOpenAI(api_key=api_key, base_url=url)
120
-
124
+ # Disable retries for local endpoints - they either work or don't
125
+ max_retries = 0 if is_local_provider(url) else 2
126
+ client = AsyncOpenAI(api_key=api_key, base_url=url, max_retries=max_retries)
127
+
121
128
  logger.info(f" ✓ Selected endpoint: {url} ({endpoint_info.provider_type})")
122
129
  logger.info(f" ✓ Selected voice: {selected_voice}")
123
130
  logger.info(f" ✓ Selected model: {selected_model}")
124
-
131
+
125
132
  return client, selected_voice, selected_model, endpoint_info
126
133
 
127
134
  # No preferred voices found - fall back to any available endpoint
128
135
  logger.warning(" No preferred voices available, using any available endpoint...")
129
136
  for url in TTS_BASE_URLS:
130
137
  endpoint_info = provider_registry.registry["tts"].get(url)
131
- if not endpoint_info or not endpoint_info.healthy:
138
+ if not endpoint_info:
132
139
  continue
133
-
140
+
134
141
  if endpoint_info.voices:
135
142
  selected_voice = endpoint_info.voices[0]
136
143
  selected_model = _select_model_for_endpoint(endpoint_info, model)
137
-
144
+
138
145
  api_key = OPENAI_API_KEY if endpoint_info.provider_type == "openai" else (OPENAI_API_KEY or "dummy-key-for-local")
139
- client = AsyncOpenAI(api_key=api_key, base_url=url)
140
-
146
+ # Disable retries for local endpoints - they either work or don't
147
+ max_retries = 0 if is_local_provider(url) else 2
148
+ client = AsyncOpenAI(api_key=api_key, base_url=url, max_retries=max_retries)
149
+
141
150
  logger.info(f" ✓ Selected endpoint: {url} ({endpoint_info.provider_type})")
142
151
  logger.info(f" ✓ Selected voice: {selected_voice}")
143
152
  logger.info(f" ✓ Selected model: {selected_model}")
144
-
153
+
145
154
  return client, selected_voice, selected_model, endpoint_info
146
-
155
+
147
156
  # No suitable endpoint found
148
- raise ValueError("No healthy TTS endpoints found that support requested voice/model preferences")
157
+ raise ValueError("No TTS endpoints found that support requested voice/model preferences")
149
158
 
150
159
 
151
160
  async def get_stt_client(
@@ -171,30 +180,36 @@ async def get_stt_client(
171
180
  # If specific base_url is requested, use it directly
172
181
  if base_url:
173
182
  endpoint_info = provider_registry.registry["stt"].get(base_url)
174
- if not endpoint_info or not endpoint_info.healthy:
175
- raise ValueError(f"Requested base URL {base_url} is not available")
176
-
183
+ if not endpoint_info:
184
+ raise ValueError(f"Requested base URL {base_url} is not configured")
185
+
177
186
  selected_model = model or "whisper-1" # Default STT model
178
-
187
+
188
+ # Disable retries for local endpoints - they either work or don't
189
+ max_retries = 0 if is_local_provider(base_url) else 2
179
190
  client = AsyncOpenAI(
180
191
  api_key=OPENAI_API_KEY or "dummy-key-for-local",
181
- base_url=base_url
192
+ base_url=base_url,
193
+ max_retries=max_retries
182
194
  )
183
-
195
+
184
196
  return client, selected_model, endpoint_info
185
-
186
- # Get any healthy STT endpoint
187
- healthy_endpoints = provider_registry.get_healthy_endpoints("stt")
188
- if not healthy_endpoints:
189
- raise ValueError("No healthy STT endpoints available")
190
-
191
- endpoint_info = healthy_endpoints[0]
197
+
198
+ # Get STT endpoints in priority order
199
+ endpoints = provider_registry.get_endpoints("stt")
200
+ if not endpoints:
201
+ raise ValueError("No STT endpoints available")
202
+
203
+ endpoint_info = endpoints[0]
192
204
  selected_model = model or "whisper-1"
193
205
 
194
206
  api_key = OPENAI_API_KEY if endpoint_info.provider_type == "openai" else (OPENAI_API_KEY or "dummy-key-for-local")
207
+ # Disable retries for local endpoints - they either work or don't
208
+ max_retries = 0 if is_local_provider(endpoint_info.base_url) else 2
195
209
  client = AsyncOpenAI(
196
210
  api_key=api_key,
197
- base_url=endpoint_info.base_url
211
+ base_url=endpoint_info.base_url,
212
+ max_retries=max_retries
198
213
  )
199
214
 
200
215
  return client, selected_model, endpoint_info
@@ -256,8 +271,9 @@ async def is_provider_available(provider_id: str, timeout: float = 2.0) -> bool:
256
271
  # Check in appropriate registry
257
272
  service_type = "tts" if provider_id in ["kokoro", "openai"] else "stt"
258
273
  endpoint_info = provider_registry.registry[service_type].get(base_url)
259
-
260
- return endpoint_info.healthy if endpoint_info else False
274
+
275
+ # Without health checks, we just return if the endpoint is configured
276
+ return endpoint_info is not None
261
277
 
262
278
 
263
279
  def get_provider_by_voice(voice: str) -> Optional[Dict[str, Any]]: