converse-framework 0.2.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.
- converse_framework/__init__.py +108 -0
- converse_framework/audio_utils.py +412 -0
- converse_framework/cuda_utils.py +176 -0
- converse_framework/events.py +94 -0
- converse_framework/examples/__init__.py +20 -0
- converse_framework/examples/subprocess_provider.py +439 -0
- converse_framework/examples/text_chat.py +308 -0
- converse_framework/examples/voice_chat.py +223 -0
- converse_framework/examples/websocket_voice_chat.py +174 -0
- converse_framework/js/browser-voice-client.js +248 -0
- converse_framework/js/mic-frame-sender.js +445 -0
- converse_framework/js/speaker-echo-guard.js +308 -0
- converse_framework/js/tts-audio-player.js +237 -0
- converse_framework/pipeline.py +620 -0
- converse_framework/protocols.py +382 -0
- converse_framework/provider_events.py +159 -0
- converse_framework/providers/__init__.py +28 -0
- converse_framework/providers/faster_whisper.py +290 -0
- converse_framework/providers/kokoro_onnx.py +391 -0
- converse_framework/providers/llamacpp.py +264 -0
- converse_framework/providers/mock.py +171 -0
- converse_framework/providers/pocket_tts.py +409 -0
- converse_framework/providers/silero.py +161 -0
- converse_framework/providers/unavailable.py +137 -0
- converse_framework/providers/whisper_cpp.py +322 -0
- converse_framework/registry.py +397 -0
- converse_framework/session.py +315 -0
- converse_framework/transport.py +54 -0
- converse_framework/utterance_collector.py +336 -0
- converse_framework-0.2.0.dist-info/METADATA +992 -0
- converse_framework-0.2.0.dist-info/RECORD +33 -0
- converse_framework-0.2.0.dist-info/WHEEL +4 -0
- converse_framework-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Unavailable provider sentinel returned when a real provider cannot load."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from converse_framework.protocols import (
|
|
8
|
+
ASRProvider,
|
|
9
|
+
AudioChunk,
|
|
10
|
+
LLMProvider,
|
|
11
|
+
ProviderCapabilities,
|
|
12
|
+
ProviderStatus,
|
|
13
|
+
TTSProvider,
|
|
14
|
+
TranscriptEvent,
|
|
15
|
+
VADProvider,
|
|
16
|
+
) # fmt: skip
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Map of provider name -> optional-dependency extra. Used to construct a
|
|
20
|
+
# helpful install hint when a provider's underlying dependency is missing.
|
|
21
|
+
EXTRAS: dict[tuple[str, str], str] = {
|
|
22
|
+
("vad", "silero"): "silero",
|
|
23
|
+
("asr", "faster-whisper"): "faster-whisper",
|
|
24
|
+
("asr", "whisper-cpp"): "whisper-cpp",
|
|
25
|
+
("llm", "llamacpp"): "llamacpp",
|
|
26
|
+
("tts", "kokoro"): "kokoro",
|
|
27
|
+
("tts", "kokoro-onnx"): "kokoro",
|
|
28
|
+
("tts", "pocket-tts"): "pocket-tts",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Backward-compatible table exported for callers that already use it.
|
|
32
|
+
EXTRA_HINTS: dict[tuple[str, str], str] = {
|
|
33
|
+
key: f"converse-framework[{extra}]" for key, extra in EXTRAS.items()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extra_hint_for(kind: str, name: str) -> str | None:
|
|
38
|
+
"""Return the ``pip install`` extra hint for a missing provider, if known.
|
|
39
|
+
|
|
40
|
+
Apps and the :class:`UnavailableProvider` sentinel use this to
|
|
41
|
+
build a friendly installation message when a registered
|
|
42
|
+
provider's heavy backend is not installed in the current
|
|
43
|
+
environment.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
kind: Provider category (``"vad"``, ``"asr"``, ``"llm"``,
|
|
47
|
+
``"tts"``).
|
|
48
|
+
name: Registered provider name (e.g. ``"silero"``,
|
|
49
|
+
``"faster-whisper"``).
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The matching ``pip install`` extra string (e.g.
|
|
53
|
+
``"converse-framework[silero]"``) when one is registered,
|
|
54
|
+
otherwise ``None``.
|
|
55
|
+
"""
|
|
56
|
+
return EXTRA_HINTS.get((kind, name))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def missing_extra_for(kind: str, name: str) -> str | None:
|
|
60
|
+
"""Return the optional-dependency extra for a missing provider, if known."""
|
|
61
|
+
return EXTRAS.get((kind, name))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UnavailableProvider(VADProvider, ASRProvider, LLMProvider, TTSProvider):
|
|
65
|
+
"""Sentinel provider that reports not-ready and raises on use."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
kind: str,
|
|
70
|
+
name: str,
|
|
71
|
+
message: str | None = None,
|
|
72
|
+
requires_gpu: bool = False,
|
|
73
|
+
):
|
|
74
|
+
if message is None:
|
|
75
|
+
extra = extra_hint_for(kind, name)
|
|
76
|
+
message = (
|
|
77
|
+
f"Provider '{name}' ({kind}) is not available. "
|
|
78
|
+
f"Install the required extra with "
|
|
79
|
+
f"`pip install {extra}`."
|
|
80
|
+
if extra
|
|
81
|
+
else f"Provider '{name}' ({kind}) is not available."
|
|
82
|
+
)
|
|
83
|
+
install_hint = extra_hint_for(kind, name)
|
|
84
|
+
self._status = ProviderStatus(
|
|
85
|
+
name=name,
|
|
86
|
+
kind=kind,
|
|
87
|
+
ready=False,
|
|
88
|
+
message=message,
|
|
89
|
+
capabilities=ProviderCapabilities(requires_gpu=requires_gpu),
|
|
90
|
+
install_hint=install_hint,
|
|
91
|
+
missing_extra=missing_extra_for(kind, name),
|
|
92
|
+
provider_id=name,
|
|
93
|
+
loaded=False,
|
|
94
|
+
status_level="unavailable",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def status(self) -> ProviderStatus:
|
|
99
|
+
return self._status
|
|
100
|
+
|
|
101
|
+
async def check_status(self) -> ProviderStatus:
|
|
102
|
+
return self.status
|
|
103
|
+
|
|
104
|
+
async def process_frame(self, frame) -> list:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
async def transcribe_text_input(self, text: str) -> AsyncIterator[TranscriptEvent]:
|
|
108
|
+
raise RuntimeError(self._status.message)
|
|
109
|
+
yield # pragma: no cover
|
|
110
|
+
|
|
111
|
+
async def transcribe_audio(
|
|
112
|
+
self, pcm_s16le: bytes, sample_rate: int, progress=None
|
|
113
|
+
) -> AsyncIterator[TranscriptEvent]:
|
|
114
|
+
raise RuntimeError(self._status.message)
|
|
115
|
+
yield # pragma: no cover
|
|
116
|
+
|
|
117
|
+
async def stream_response(
|
|
118
|
+
self, messages: list[dict[str, str]]
|
|
119
|
+
) -> AsyncIterator[str]:
|
|
120
|
+
raise RuntimeError(self._status.message)
|
|
121
|
+
yield # pragma: no cover
|
|
122
|
+
|
|
123
|
+
async def stream_audio(self, text: str) -> AsyncIterator[AudioChunk]:
|
|
124
|
+
raise RuntimeError(self._status.message)
|
|
125
|
+
yield # pragma: no cover
|
|
126
|
+
|
|
127
|
+
async def stream_audio_with_progress(
|
|
128
|
+
self, text: str, progress=None
|
|
129
|
+
) -> AsyncIterator[AudioChunk]:
|
|
130
|
+
raise RuntimeError(self._status.message)
|
|
131
|
+
yield # pragma: no cover
|
|
132
|
+
|
|
133
|
+
async def load(self) -> ProviderStatus:
|
|
134
|
+
return self.status
|
|
135
|
+
|
|
136
|
+
async def unload(self) -> ProviderStatus:
|
|
137
|
+
return self.status
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""whisper.cpp HTTP ASR provider.
|
|
2
|
+
|
|
3
|
+
Talks to a running ``whisper-server`` (whisper.cpp's official HTTP server
|
|
4
|
+
binary) over HTTP. The provider does NOT manage the server subprocess --
|
|
5
|
+
the user is expected to start ``whisper-server`` themselves and configure
|
|
6
|
+
it with the desired model. The framework only needs ``httpx`` to talk
|
|
7
|
+
to it; install with::
|
|
8
|
+
|
|
9
|
+
pip install 'converse-framework[whisper-cpp]'
|
|
10
|
+
|
|
11
|
+
The ``httpx`` package is imported lazily inside async methods so the
|
|
12
|
+
base :mod:`converse_framework` package stays light. The provider probes
|
|
13
|
+
``{base_url}/health`` on :meth:`check_status` to decide whether to use
|
|
14
|
+
the legacy ``/inference`` endpoint (older whisper-server releases) or
|
|
15
|
+
the OpenAI-compatible ``/v1/audio/transcriptions`` endpoint (newer
|
|
16
|
+
releases). Both endpoints return a JSON object with a ``text`` field.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
from collections.abc import AsyncIterator
|
|
24
|
+
|
|
25
|
+
from converse_framework.audio_utils import (
|
|
26
|
+
float_audio_to_wav_bytes,
|
|
27
|
+
pcm_s16le_to_float32,
|
|
28
|
+
)
|
|
29
|
+
from converse_framework.protocols import (
|
|
30
|
+
ASRProvider,
|
|
31
|
+
ProgressCallback,
|
|
32
|
+
ProviderCapabilities,
|
|
33
|
+
ProviderStatus,
|
|
34
|
+
TranscriptEvent,
|
|
35
|
+
)
|
|
36
|
+
from converse_framework.providers.unavailable import extra_hint_for
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
_DEFAULT_EXTRA_HINT = "converse-framework[whisper-cpp]"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WhisperCppASRProvider(ASRProvider):
|
|
44
|
+
"""ASR provider that proxies audio to a ``whisper-server`` HTTP endpoint.
|
|
45
|
+
|
|
46
|
+
Config keys (all optional):
|
|
47
|
+
|
|
48
|
+
* ``base_url`` -- whisper-server address. Default
|
|
49
|
+
``"http://127.0.0.1:8082"``.
|
|
50
|
+
* ``model`` -- model filename hint stored alongside the server
|
|
51
|
+
config (e.g. ``"ggml-small.en.bin"``). Default
|
|
52
|
+
``"ggml-small.en.bin"``. whisper-server itself picks the
|
|
53
|
+
model on its own command line, so this key is for diagnostics
|
|
54
|
+
and status messages only.
|
|
55
|
+
* ``language`` -- ISO language code sent with each request.
|
|
56
|
+
Default ``"en"``.
|
|
57
|
+
* ``temperature`` -- sampling temperature forwarded to
|
|
58
|
+
whisper-server. Default ``0`` (greedy).
|
|
59
|
+
* ``timeout_s`` -- request timeout in seconds. Default ``120``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: dict):
|
|
63
|
+
self.base_url = str(config.get("base_url", "http://127.0.0.1:8082")).rstrip("/")
|
|
64
|
+
self.model = str(config.get("model", "ggml-small.en.bin"))
|
|
65
|
+
self.language = config.get("language", "en")
|
|
66
|
+
self.temperature = config.get("temperature", 0)
|
|
67
|
+
self.timeout_s = float(config.get("timeout_s", 120))
|
|
68
|
+
# The provider never starts or stops the server, so the bundle
|
|
69
|
+
# has no subprocess lifecycle to track.
|
|
70
|
+
self._endpoint: str | None = None
|
|
71
|
+
self._last_error: str | None = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def status(self) -> ProviderStatus:
|
|
75
|
+
if self._last_error:
|
|
76
|
+
message = self._last_error
|
|
77
|
+
status_level = "error"
|
|
78
|
+
else:
|
|
79
|
+
message = (
|
|
80
|
+
f"Configured for whisper-server at {self.base_url} "
|
|
81
|
+
f"(model hint: {self.model}, language: {self.language}). "
|
|
82
|
+
"The server is managed externally; the framework will not "
|
|
83
|
+
"start it for you."
|
|
84
|
+
)
|
|
85
|
+
status_level = "configured"
|
|
86
|
+
return ProviderStatus(
|
|
87
|
+
name="whisper-cpp",
|
|
88
|
+
kind="asr",
|
|
89
|
+
ready=self._last_error is None,
|
|
90
|
+
message=message,
|
|
91
|
+
capabilities=ProviderCapabilities(
|
|
92
|
+
languages=(str(self.language),) if self.language else ("en",)
|
|
93
|
+
),
|
|
94
|
+
provider_id="whisper-cpp",
|
|
95
|
+
# The provider does not own the heavy backend; the user does.
|
|
96
|
+
loaded=False,
|
|
97
|
+
managed_externally=True,
|
|
98
|
+
supports_model_management=False,
|
|
99
|
+
supports_voice_selection=False,
|
|
100
|
+
active_model=self.model,
|
|
101
|
+
models=({"id": self.model, "label": self.model},),
|
|
102
|
+
status_level=status_level,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def check_status(self) -> ProviderStatus:
|
|
106
|
+
return await self._http_check_status()
|
|
107
|
+
|
|
108
|
+
async def probe_status(self) -> ProviderStatus:
|
|
109
|
+
"""Cheap probe: check httpx import; no HTTP call."""
|
|
110
|
+
try:
|
|
111
|
+
pass # type: ignore[import-not-found]
|
|
112
|
+
except Exception as exc: # pragma: no cover - import path
|
|
113
|
+
self._last_error = self._missing_dep_message(exc)
|
|
114
|
+
return self.status
|
|
115
|
+
# httpx available; return cached status.
|
|
116
|
+
return self.status
|
|
117
|
+
|
|
118
|
+
async def load_status(self) -> ProviderStatus:
|
|
119
|
+
"""Alias for probe_status - server lifecycle is user-owned."""
|
|
120
|
+
return await self.probe_status()
|
|
121
|
+
|
|
122
|
+
async def _http_check_status(self) -> ProviderStatus:
|
|
123
|
+
"""Probe the server by GETting ``{base_url}/health``.
|
|
124
|
+
|
|
125
|
+
Returns a :class:`ProviderStatus` whose ``ready`` reflects
|
|
126
|
+
whether the server is reachable. As a side effect it caches
|
|
127
|
+
the preferred transcription endpoint so the next
|
|
128
|
+
:meth:`transcribe_audio` call skips the probe.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
import httpx # type: ignore[import-not-found]
|
|
132
|
+
except Exception as exc: # pragma: no cover - import path
|
|
133
|
+
self._last_error = self._missing_dep_message(exc)
|
|
134
|
+
return self.status
|
|
135
|
+
|
|
136
|
+
timeout = httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=2.0)
|
|
137
|
+
try:
|
|
138
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
139
|
+
response = await client.get(f"{self.base_url}/health")
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
self._last_error = f"Cannot reach whisper-server at {self.base_url}: {exc}"
|
|
143
|
+
self._endpoint = None
|
|
144
|
+
return self.status
|
|
145
|
+
|
|
146
|
+
# Server is up -- pick the endpoint once. Older whisper-server
|
|
147
|
+
# builds only expose /inference; newer builds also expose the
|
|
148
|
+
# OpenAI-compatible /v1/audio/transcriptions. We always try
|
|
149
|
+
# /inference first when /health answers, mirroring the
|
|
150
|
+
# upstream docs.
|
|
151
|
+
self._endpoint = "/inference"
|
|
152
|
+
self._last_error = None
|
|
153
|
+
return ProviderStatus(
|
|
154
|
+
name="whisper-cpp",
|
|
155
|
+
kind="asr",
|
|
156
|
+
ready=True,
|
|
157
|
+
message=(
|
|
158
|
+
f"whisper-server reachable at {self.base_url} "
|
|
159
|
+
f"(/health 200, will use {self._endpoint}). "
|
|
160
|
+
f"Model hint: {self.model}."
|
|
161
|
+
),
|
|
162
|
+
capabilities=ProviderCapabilities(
|
|
163
|
+
languages=(str(self.language),) if self.language else ("en",)
|
|
164
|
+
),
|
|
165
|
+
provider_id="whisper-cpp",
|
|
166
|
+
loaded=False,
|
|
167
|
+
managed_externally=True,
|
|
168
|
+
supports_model_management=False,
|
|
169
|
+
supports_voice_selection=False,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def load(self) -> ProviderStatus:
|
|
173
|
+
"""No-op: the server lifecycle is owned by the user.
|
|
174
|
+
|
|
175
|
+
We still call :meth:`check_status` so callers see the
|
|
176
|
+
current reachability state in the returned snapshot.
|
|
177
|
+
"""
|
|
178
|
+
return await self.check_status()
|
|
179
|
+
|
|
180
|
+
async def transcribe_text_input(self, text: str) -> AsyncIterator[TranscriptEvent]:
|
|
181
|
+
stripped = text.strip()
|
|
182
|
+
if stripped:
|
|
183
|
+
yield TranscriptEvent(text=stripped, final=True)
|
|
184
|
+
|
|
185
|
+
async def transcribe_audio(
|
|
186
|
+
self,
|
|
187
|
+
pcm_s16le: bytes,
|
|
188
|
+
sample_rate: int,
|
|
189
|
+
progress: ProgressCallback | None = None,
|
|
190
|
+
) -> AsyncIterator[TranscriptEvent]:
|
|
191
|
+
try:
|
|
192
|
+
import httpx # type: ignore[import-not-found]
|
|
193
|
+
except Exception as exc: # pragma: no cover - import path
|
|
194
|
+
raise RuntimeError(self._missing_dep_message(exc)) from exc
|
|
195
|
+
|
|
196
|
+
if sample_rate <= 0:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"whisper-cpp needs a positive sample_rate, got {sample_rate}"
|
|
199
|
+
)
|
|
200
|
+
if not pcm_s16le:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
audio = pcm_s16le_to_float32(pcm_s16le)
|
|
204
|
+
wav_bytes = float_audio_to_wav_bytes(audio, sample_rate)
|
|
205
|
+
if not wav_bytes:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
endpoint = await self._resolve_endpoint()
|
|
209
|
+
url = f"{self.base_url}{endpoint}"
|
|
210
|
+
|
|
211
|
+
if progress:
|
|
212
|
+
await progress(
|
|
213
|
+
"asr.progress",
|
|
214
|
+
{
|
|
215
|
+
"stage": "queued",
|
|
216
|
+
"message": (
|
|
217
|
+
f"Queued {round(audio.size / sample_rate, 2)}s utterance "
|
|
218
|
+
"for whisper.cpp."
|
|
219
|
+
),
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
params: dict[str, str] = {}
|
|
224
|
+
if self.language:
|
|
225
|
+
params["language"] = str(self.language)
|
|
226
|
+
if self.temperature is not None:
|
|
227
|
+
params["temperature"] = str(self.temperature)
|
|
228
|
+
# whisper-server's /inference endpoint reads the language and
|
|
229
|
+
# temperature from query string; /v1/audio/transcriptions reads
|
|
230
|
+
# them from the multipart form. Use the right shape for each.
|
|
231
|
+
if endpoint == "/inference":
|
|
232
|
+
request_kwargs: dict = {"params": params} if params else {}
|
|
233
|
+
else:
|
|
234
|
+
form: dict[str, str] = {}
|
|
235
|
+
if self.language:
|
|
236
|
+
form["language"] = str(self.language)
|
|
237
|
+
if self.temperature is not None:
|
|
238
|
+
form["temperature"] = str(self.temperature)
|
|
239
|
+
form["response_format"] = "json"
|
|
240
|
+
request_kwargs = {"data": form, "files": {"file": wav_bytes}}
|
|
241
|
+
|
|
242
|
+
timeout = httpx.Timeout(connect=5.0, read=self.timeout_s, write=10.0, pool=5.0)
|
|
243
|
+
try:
|
|
244
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
245
|
+
response = await asyncio.wait_for(
|
|
246
|
+
client.post(url, content=wav_bytes, **request_kwargs),
|
|
247
|
+
timeout=self.timeout_s,
|
|
248
|
+
)
|
|
249
|
+
response.raise_for_status()
|
|
250
|
+
payload = response.json()
|
|
251
|
+
except asyncio.TimeoutError as exc:
|
|
252
|
+
self._last_error = (
|
|
253
|
+
f"whisper-server request timed out after {self.timeout_s}s"
|
|
254
|
+
)
|
|
255
|
+
raise RuntimeError(self._last_error) from exc
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
self._last_error = f"whisper-server request to {url} failed: {exc}"
|
|
258
|
+
raise RuntimeError(self._last_error) from exc
|
|
259
|
+
|
|
260
|
+
text = self._extract_text(payload)
|
|
261
|
+
if progress:
|
|
262
|
+
await progress(
|
|
263
|
+
"asr.progress",
|
|
264
|
+
{"stage": "complete", "message": "ASR transcription complete."},
|
|
265
|
+
)
|
|
266
|
+
if text:
|
|
267
|
+
yield TranscriptEvent(text=text, final=True)
|
|
268
|
+
|
|
269
|
+
async def unload(self) -> ProviderStatus:
|
|
270
|
+
# No state to release -- the server is owned by the user.
|
|
271
|
+
self._endpoint = None
|
|
272
|
+
return await self.check_status()
|
|
273
|
+
|
|
274
|
+
async def _resolve_endpoint(self) -> str:
|
|
275
|
+
"""Pick the transcription endpoint the running server actually exposes.
|
|
276
|
+
|
|
277
|
+
First call probes the legacy ``/inference`` endpoint with a tiny
|
|
278
|
+
HEAD-ish request; if it 404s or returns an error we fall back to
|
|
279
|
+
the OpenAI-compatible ``/v1/audio/transcriptions``. The result
|
|
280
|
+
is cached on the instance so subsequent calls are cheap.
|
|
281
|
+
"""
|
|
282
|
+
if self._endpoint is not None:
|
|
283
|
+
return self._endpoint
|
|
284
|
+
|
|
285
|
+
import httpx # type: ignore[import-not-found]
|
|
286
|
+
|
|
287
|
+
timeout = httpx.Timeout(connect=2.0, read=3.0, write=3.0, pool=2.0)
|
|
288
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
289
|
+
for candidate in ("/inference", "/v1/audio/transcriptions"):
|
|
290
|
+
try:
|
|
291
|
+
response = await client.get(f"{self.base_url}{candidate}")
|
|
292
|
+
except Exception:
|
|
293
|
+
continue
|
|
294
|
+
# 405 (method not allowed) means the endpoint exists
|
|
295
|
+
# and only accepts POST; that is the exact response we
|
|
296
|
+
# expect from /inference. 404 means it is not there.
|
|
297
|
+
if response.status_code in (200, 405):
|
|
298
|
+
self._endpoint = candidate
|
|
299
|
+
return candidate
|
|
300
|
+
|
|
301
|
+
# Nothing matched -- default to the legacy endpoint and let the
|
|
302
|
+
# POST surface a clear error.
|
|
303
|
+
self._endpoint = "/inference"
|
|
304
|
+
return self._endpoint
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _extract_text(payload) -> str:
|
|
308
|
+
"""Pull the transcript out of either endpoint's JSON shape."""
|
|
309
|
+
if not isinstance(payload, dict):
|
|
310
|
+
return ""
|
|
311
|
+
text = payload.get("text")
|
|
312
|
+
if isinstance(text, str):
|
|
313
|
+
return text.strip()
|
|
314
|
+
return ""
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _missing_dep_message(exc: Exception) -> str:
|
|
318
|
+
hint = extra_hint_for("asr", "whisper-cpp") or _DEFAULT_EXTRA_HINT
|
|
319
|
+
return (
|
|
320
|
+
f"Provider 'whisper-cpp' (asr) is not available. "
|
|
321
|
+
f"Install the required extra with `pip install {hint}`. ({exc})"
|
|
322
|
+
)
|