natlas 0.1.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.
- natlas/__init__.py +111 -0
- natlas/async_client.py +413 -0
- natlas/backends/__init__.py +20 -0
- natlas/backends/hf_endpoint.py +14 -0
- natlas/backends/local.py +13 -0
- natlas/backends/official.py +13 -0
- natlas/backends/openai_compatible.py +26 -0
- natlas/client.py +423 -0
- natlas/constants.py +68 -0
- natlas/errors.py +85 -0
- natlas/languages.py +84 -0
- natlas/models.py +125 -0
- natlas/py.typed +0 -0
- natlas/transport.py +378 -0
- natlas/version.py +9 -0
- natlas/wire.py +730 -0
- natlas-0.1.0.dist-info/METADATA +438 -0
- natlas-0.1.0.dist-info/RECORD +21 -0
- natlas-0.1.0.dist-info/WHEEL +4 -0
- natlas-0.1.0.dist-info/licenses/LICENSE +201 -0
- natlas-0.1.0.dist-info/licenses/NOTICE +62 -0
natlas/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""``natlas`` — the Python SDK for N-ATLaS, Nigeria's sovereign multilingual LLM.
|
|
2
|
+
|
|
3
|
+
Every inference path talks to ``NCAIR1/N-ATLaS`` or an ``NCAIR1`` ASR model.
|
|
4
|
+
Point :class:`NAtlas` at a ``/serve`` gateway (``NATLAS_BASE_URL`` +
|
|
5
|
+
``NATLAS_API_KEY``). There is no public hosted API, and this package will
|
|
6
|
+
not call any other vendor's model.
|
|
7
|
+
|
|
8
|
+
Sync and async clients share one API surface, both built on ``httpx``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from natlas.async_client import AsyncNAtlas as AsyncNAtlas
|
|
14
|
+
from natlas.client import NAtlas as NAtlas
|
|
15
|
+
from natlas.constants import (
|
|
16
|
+
ASR_MAX_SEGMENT_SECONDS as ASR_MAX_SEGMENT_SECONDS,
|
|
17
|
+
)
|
|
18
|
+
from natlas.constants import (
|
|
19
|
+
ASR_MODEL_IDS as ASR_MODEL_IDS,
|
|
20
|
+
)
|
|
21
|
+
from natlas.constants import (
|
|
22
|
+
ASR_SAMPLE_RATE as ASR_SAMPLE_RATE,
|
|
23
|
+
)
|
|
24
|
+
from natlas.constants import (
|
|
25
|
+
ATTRIBUTION as ATTRIBUTION,
|
|
26
|
+
)
|
|
27
|
+
from natlas.constants import (
|
|
28
|
+
BACKENDS as BACKENDS,
|
|
29
|
+
)
|
|
30
|
+
from natlas.constants import (
|
|
31
|
+
LANGUAGE_NAMES as LANGUAGE_NAMES,
|
|
32
|
+
)
|
|
33
|
+
from natlas.constants import (
|
|
34
|
+
LANGUAGES as LANGUAGES,
|
|
35
|
+
)
|
|
36
|
+
from natlas.constants import (
|
|
37
|
+
LLM_CONTEXT_TOKENS as LLM_CONTEXT_TOKENS,
|
|
38
|
+
)
|
|
39
|
+
from natlas.constants import (
|
|
40
|
+
LLM_MODEL_ID as LLM_MODEL_ID,
|
|
41
|
+
)
|
|
42
|
+
from natlas.constants import (
|
|
43
|
+
Backend as Backend,
|
|
44
|
+
)
|
|
45
|
+
from natlas.constants import (
|
|
46
|
+
Language as Language,
|
|
47
|
+
)
|
|
48
|
+
from natlas.constants import (
|
|
49
|
+
is_language as is_language,
|
|
50
|
+
)
|
|
51
|
+
from natlas.errors import AbortError as AbortError
|
|
52
|
+
from natlas.errors import AuthError as AuthError
|
|
53
|
+
from natlas.errors import BadRequestError as BadRequestError
|
|
54
|
+
from natlas.errors import NAtlasError as NAtlasError
|
|
55
|
+
from natlas.errors import NetworkError as NetworkError
|
|
56
|
+
from natlas.errors import RateLimitError as RateLimitError
|
|
57
|
+
from natlas.errors import ServerError as ServerError
|
|
58
|
+
from natlas.errors import TimeoutError as TimeoutError
|
|
59
|
+
from natlas.languages import language_from_model_text as language_from_model_text
|
|
60
|
+
from natlas.languages import normalise_language as normalise_language
|
|
61
|
+
from natlas.models import ChatResult as ChatResult
|
|
62
|
+
from natlas.models import ChatStreamChunk as ChatStreamChunk
|
|
63
|
+
from natlas.models import HealthStatus as HealthStatus
|
|
64
|
+
from natlas.models import LanguageDetection as LanguageDetection
|
|
65
|
+
from natlas.models import ModelInfo as ModelInfo
|
|
66
|
+
from natlas.models import ModelList as ModelList
|
|
67
|
+
from natlas.models import Summary as Summary
|
|
68
|
+
from natlas.models import Transcription as Transcription
|
|
69
|
+
from natlas.models import Translation as Translation
|
|
70
|
+
from natlas.models import Usage as Usage
|
|
71
|
+
from natlas.models import VoiceChatResult as VoiceChatResult
|
|
72
|
+
from natlas.version import __version__ as __version__
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"ASR_MAX_SEGMENT_SECONDS",
|
|
76
|
+
"ASR_MODEL_IDS",
|
|
77
|
+
"ASR_SAMPLE_RATE",
|
|
78
|
+
"ATTRIBUTION",
|
|
79
|
+
"BACKENDS",
|
|
80
|
+
"LANGUAGES",
|
|
81
|
+
"LANGUAGE_NAMES",
|
|
82
|
+
"LLM_CONTEXT_TOKENS",
|
|
83
|
+
"LLM_MODEL_ID",
|
|
84
|
+
"AbortError",
|
|
85
|
+
"AsyncNAtlas",
|
|
86
|
+
"AuthError",
|
|
87
|
+
"Backend",
|
|
88
|
+
"BadRequestError",
|
|
89
|
+
"ChatResult",
|
|
90
|
+
"ChatStreamChunk",
|
|
91
|
+
"HealthStatus",
|
|
92
|
+
"Language",
|
|
93
|
+
"LanguageDetection",
|
|
94
|
+
"ModelInfo",
|
|
95
|
+
"ModelList",
|
|
96
|
+
"NAtlas",
|
|
97
|
+
"NAtlasError",
|
|
98
|
+
"NetworkError",
|
|
99
|
+
"RateLimitError",
|
|
100
|
+
"ServerError",
|
|
101
|
+
"Summary",
|
|
102
|
+
"TimeoutError",
|
|
103
|
+
"Transcription",
|
|
104
|
+
"Translation",
|
|
105
|
+
"Usage",
|
|
106
|
+
"VoiceChatResult",
|
|
107
|
+
"__version__",
|
|
108
|
+
"is_language",
|
|
109
|
+
"language_from_model_text",
|
|
110
|
+
"normalise_language",
|
|
111
|
+
]
|
natlas/async_client.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Asynchronous client for an N-ATLaS gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
7
|
+
from typing import Literal, overload
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from natlas.errors import BadRequestError, NAtlasError
|
|
12
|
+
from natlas.languages import normalise_language
|
|
13
|
+
from natlas.models import (
|
|
14
|
+
ChatResult,
|
|
15
|
+
ChatStreamChunk,
|
|
16
|
+
HealthStatus,
|
|
17
|
+
LanguageDetection,
|
|
18
|
+
ModelList,
|
|
19
|
+
Summary,
|
|
20
|
+
Transcription,
|
|
21
|
+
Translation,
|
|
22
|
+
VoiceChatResult,
|
|
23
|
+
)
|
|
24
|
+
from natlas.transport import AsyncTransport
|
|
25
|
+
from natlas.wire import (
|
|
26
|
+
SSE_DONE,
|
|
27
|
+
AudioInput,
|
|
28
|
+
Settings,
|
|
29
|
+
assert_response_format,
|
|
30
|
+
build_chat_body,
|
|
31
|
+
default_rng,
|
|
32
|
+
detect_messages,
|
|
33
|
+
detection_from,
|
|
34
|
+
load_audio,
|
|
35
|
+
parse_chat_chunk,
|
|
36
|
+
parse_chat_result,
|
|
37
|
+
parse_health,
|
|
38
|
+
parse_model_list,
|
|
39
|
+
parse_sse_line,
|
|
40
|
+
parse_transcription,
|
|
41
|
+
require_retries,
|
|
42
|
+
require_timeout,
|
|
43
|
+
resolve_settings,
|
|
44
|
+
summarize_messages,
|
|
45
|
+
summary_from,
|
|
46
|
+
translate_messages,
|
|
47
|
+
translation_from,
|
|
48
|
+
voice_messages,
|
|
49
|
+
voice_result,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _default_sleep(seconds: float) -> None:
|
|
54
|
+
await asyncio.sleep(seconds)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _aiter(response: httpx.Response) -> AsyncIterator[ChatStreamChunk]:
|
|
58
|
+
try:
|
|
59
|
+
async for line in response.aiter_lines():
|
|
60
|
+
parsed = parse_sse_line(line)
|
|
61
|
+
if parsed is SSE_DONE:
|
|
62
|
+
return
|
|
63
|
+
if parsed is None:
|
|
64
|
+
continue
|
|
65
|
+
yield parse_chat_chunk(parsed)
|
|
66
|
+
finally:
|
|
67
|
+
await response.aclose()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AsyncNAtlas:
|
|
71
|
+
"""Asynchronous client. Use it as an async context manager.
|
|
72
|
+
|
|
73
|
+
.. code-block:: python
|
|
74
|
+
|
|
75
|
+
async with AsyncNAtlas() as natlas:
|
|
76
|
+
async for chunk in await natlas.chat(messages=[...], stream=True):
|
|
77
|
+
print(chunk.delta, end="")
|
|
78
|
+
|
|
79
|
+
``timeout`` is seconds. See :class:`natlas.client.NAtlas` for configuration.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
base_url: str | None = None,
|
|
86
|
+
api_key: str | None = None,
|
|
87
|
+
backend: str | None = None,
|
|
88
|
+
model: str | None = None,
|
|
89
|
+
timeout: float | None = None,
|
|
90
|
+
max_retries: int | None = None,
|
|
91
|
+
retry_non_idempotent: bool = False,
|
|
92
|
+
http_client: httpx.AsyncClient | None = None,
|
|
93
|
+
sleep: Callable[[float], Awaitable[None]] | None = None,
|
|
94
|
+
rng: Callable[[], float] | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._settings: Settings = resolve_settings(
|
|
97
|
+
base_url=base_url,
|
|
98
|
+
api_key=api_key,
|
|
99
|
+
backend=backend,
|
|
100
|
+
model=model,
|
|
101
|
+
timeout=timeout,
|
|
102
|
+
max_retries=max_retries,
|
|
103
|
+
retry_non_idempotent=retry_non_idempotent,
|
|
104
|
+
)
|
|
105
|
+
self._owns_client = http_client is None
|
|
106
|
+
self._client = http_client if http_client is not None else httpx.AsyncClient()
|
|
107
|
+
self._closed = False
|
|
108
|
+
self._http = AsyncTransport(
|
|
109
|
+
self._client,
|
|
110
|
+
self._settings,
|
|
111
|
+
sleep or _default_sleep,
|
|
112
|
+
rng or default_rng,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def __aenter__(self) -> AsyncNAtlas:
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
119
|
+
await self.aclose()
|
|
120
|
+
|
|
121
|
+
async def aclose(self) -> None:
|
|
122
|
+
if self._closed:
|
|
123
|
+
return
|
|
124
|
+
self._closed = True
|
|
125
|
+
if self._owns_client:
|
|
126
|
+
await self._client.aclose()
|
|
127
|
+
|
|
128
|
+
@overload
|
|
129
|
+
async def chat(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
messages: Sequence[Mapping[str, object]],
|
|
133
|
+
stream: Literal[True],
|
|
134
|
+
language: str | None = None,
|
|
135
|
+
temperature: float | None = None,
|
|
136
|
+
max_tokens: int | None = None,
|
|
137
|
+
top_p: float | None = None,
|
|
138
|
+
model: str | None = None,
|
|
139
|
+
stop: str | Sequence[str] | None = None,
|
|
140
|
+
timeout: float | None = None,
|
|
141
|
+
max_retries: int | None = None,
|
|
142
|
+
) -> AsyncIterator[ChatStreamChunk]: ...
|
|
143
|
+
|
|
144
|
+
@overload
|
|
145
|
+
async def chat(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
messages: Sequence[Mapping[str, object]],
|
|
149
|
+
stream: Literal[False] = False,
|
|
150
|
+
language: str | None = None,
|
|
151
|
+
temperature: float | None = None,
|
|
152
|
+
max_tokens: int | None = None,
|
|
153
|
+
top_p: float | None = None,
|
|
154
|
+
model: str | None = None,
|
|
155
|
+
stop: str | Sequence[str] | None = None,
|
|
156
|
+
timeout: float | None = None,
|
|
157
|
+
max_retries: int | None = None,
|
|
158
|
+
) -> ChatResult: ...
|
|
159
|
+
|
|
160
|
+
async def chat(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
messages: Sequence[Mapping[str, object]],
|
|
164
|
+
stream: bool = False,
|
|
165
|
+
language: str | None = None,
|
|
166
|
+
temperature: float | None = None,
|
|
167
|
+
max_tokens: int | None = None,
|
|
168
|
+
top_p: float | None = None,
|
|
169
|
+
model: str | None = None,
|
|
170
|
+
stop: str | Sequence[str] | None = None,
|
|
171
|
+
timeout: float | None = None,
|
|
172
|
+
max_retries: int | None = None,
|
|
173
|
+
) -> ChatResult | AsyncIterator[ChatStreamChunk]:
|
|
174
|
+
"""Chat with N-ATLaS. ``stream=True`` resolves to an async iterator of deltas."""
|
|
175
|
+
self._ensure_open()
|
|
176
|
+
body = build_chat_body(
|
|
177
|
+
messages=messages,
|
|
178
|
+
stream=stream,
|
|
179
|
+
language=language,
|
|
180
|
+
temperature=temperature,
|
|
181
|
+
max_tokens=max_tokens,
|
|
182
|
+
top_p=top_p,
|
|
183
|
+
model=model or self._settings.model,
|
|
184
|
+
stop=stop,
|
|
185
|
+
default_model=self._settings.model,
|
|
186
|
+
)
|
|
187
|
+
url = f"{self._settings.api_base}/chat/completions"
|
|
188
|
+
timeout_s = _optional_timeout(timeout)
|
|
189
|
+
retries = _optional_retries(max_retries)
|
|
190
|
+
if stream:
|
|
191
|
+
response = await self._http.open_stream(
|
|
192
|
+
url, json_body=body, timeout=timeout_s, max_retries=retries
|
|
193
|
+
)
|
|
194
|
+
return _aiter(response)
|
|
195
|
+
payload, _status = await self._http.request_json(
|
|
196
|
+
"POST",
|
|
197
|
+
url,
|
|
198
|
+
idempotent=False,
|
|
199
|
+
json_body=body,
|
|
200
|
+
timeout=timeout_s,
|
|
201
|
+
max_retries=retries,
|
|
202
|
+
)
|
|
203
|
+
return parse_chat_result(payload)
|
|
204
|
+
|
|
205
|
+
async def list_models(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
timeout: float | None = None,
|
|
209
|
+
max_retries: int | None = None,
|
|
210
|
+
) -> ModelList:
|
|
211
|
+
"""``GET /v1/models``."""
|
|
212
|
+
self._ensure_open()
|
|
213
|
+
payload, _status = await self._http.request_json(
|
|
214
|
+
"GET",
|
|
215
|
+
f"{self._settings.api_base}/models",
|
|
216
|
+
idempotent=True,
|
|
217
|
+
timeout=_optional_timeout(timeout),
|
|
218
|
+
max_retries=_optional_retries(max_retries),
|
|
219
|
+
)
|
|
220
|
+
return parse_model_list(payload)
|
|
221
|
+
|
|
222
|
+
async def health(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
timeout: float | None = None,
|
|
226
|
+
max_retries: int | None = None,
|
|
227
|
+
) -> HealthStatus:
|
|
228
|
+
"""``GET /health``. A 503 degraded body is returned with ``ok=False``."""
|
|
229
|
+
self._ensure_open()
|
|
230
|
+
payload, status = await self._http.request_json(
|
|
231
|
+
"GET",
|
|
232
|
+
self._settings.health_url,
|
|
233
|
+
idempotent=True,
|
|
234
|
+
timeout=_optional_timeout(timeout),
|
|
235
|
+
max_retries=_optional_retries(max_retries),
|
|
236
|
+
ok_statuses=frozenset({503}),
|
|
237
|
+
)
|
|
238
|
+
return parse_health(payload, status)
|
|
239
|
+
|
|
240
|
+
async def transcribe(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
audio: AudioInput,
|
|
244
|
+
language: str,
|
|
245
|
+
filename: str | None = None,
|
|
246
|
+
response_format: str = "json",
|
|
247
|
+
timeout: float | None = None,
|
|
248
|
+
max_retries: int | None = None,
|
|
249
|
+
) -> Transcription:
|
|
250
|
+
"""Transcribe audio. ``language`` selects the NCAIR1 ASR model."""
|
|
251
|
+
self._ensure_open()
|
|
252
|
+
lang = normalise_language(language)
|
|
253
|
+
fmt = assert_response_format(response_format)
|
|
254
|
+
name, data, content_type = load_audio(audio, filename)
|
|
255
|
+
url = f"{self._settings.api_base}/audio/transcriptions"
|
|
256
|
+
files = {"file": (name, data, content_type)}
|
|
257
|
+
form = {"language": lang, "response_format": fmt}
|
|
258
|
+
timeout_s = _optional_timeout(timeout)
|
|
259
|
+
retries = _optional_retries(max_retries)
|
|
260
|
+
if fmt == "text":
|
|
261
|
+
text = await self._http.request_text(
|
|
262
|
+
"POST",
|
|
263
|
+
url,
|
|
264
|
+
idempotent=False,
|
|
265
|
+
data=form,
|
|
266
|
+
files=files,
|
|
267
|
+
timeout=timeout_s,
|
|
268
|
+
max_retries=retries,
|
|
269
|
+
)
|
|
270
|
+
return parse_transcription(text, lang)
|
|
271
|
+
payload, _status = await self._http.request_json(
|
|
272
|
+
"POST",
|
|
273
|
+
url,
|
|
274
|
+
idempotent=False,
|
|
275
|
+
data=form,
|
|
276
|
+
files=files,
|
|
277
|
+
timeout=timeout_s,
|
|
278
|
+
max_retries=retries,
|
|
279
|
+
)
|
|
280
|
+
return parse_transcription(payload, lang)
|
|
281
|
+
|
|
282
|
+
async def translate(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
text: str,
|
|
286
|
+
from_: str,
|
|
287
|
+
to: str,
|
|
288
|
+
temperature: float | None = None,
|
|
289
|
+
max_tokens: int | None = None,
|
|
290
|
+
timeout: float | None = None,
|
|
291
|
+
max_retries: int | None = None,
|
|
292
|
+
) -> Translation:
|
|
293
|
+
"""Translate between Hausa, Igbo, Yoruba, and Nigerian English via N-ATLaS."""
|
|
294
|
+
messages, source, target = translate_messages(text, from_, to)
|
|
295
|
+
result = await self._complete(
|
|
296
|
+
messages,
|
|
297
|
+
language=target,
|
|
298
|
+
temperature=0.2 if temperature is None else temperature,
|
|
299
|
+
max_tokens=1024 if max_tokens is None else max_tokens,
|
|
300
|
+
timeout=timeout,
|
|
301
|
+
max_retries=max_retries,
|
|
302
|
+
)
|
|
303
|
+
return translation_from(result, source, target)
|
|
304
|
+
|
|
305
|
+
async def summarize(
|
|
306
|
+
self,
|
|
307
|
+
*,
|
|
308
|
+
text: str,
|
|
309
|
+
language: str,
|
|
310
|
+
temperature: float | None = None,
|
|
311
|
+
max_tokens: int | None = None,
|
|
312
|
+
timeout: float | None = None,
|
|
313
|
+
max_retries: int | None = None,
|
|
314
|
+
) -> Summary:
|
|
315
|
+
"""Summarise text in one of the four languages. The summary is the model's."""
|
|
316
|
+
messages, target = summarize_messages(text, language)
|
|
317
|
+
result = await self._complete(
|
|
318
|
+
messages,
|
|
319
|
+
language=target,
|
|
320
|
+
temperature=0.3 if temperature is None else temperature,
|
|
321
|
+
max_tokens=512 if max_tokens is None else max_tokens,
|
|
322
|
+
timeout=timeout,
|
|
323
|
+
max_retries=max_retries,
|
|
324
|
+
)
|
|
325
|
+
return summary_from(result, target)
|
|
326
|
+
|
|
327
|
+
async def detect_language(
|
|
328
|
+
self,
|
|
329
|
+
text: str,
|
|
330
|
+
*,
|
|
331
|
+
timeout: float | None = None,
|
|
332
|
+
max_retries: int | None = None,
|
|
333
|
+
) -> LanguageDetection:
|
|
334
|
+
"""Ask N-ATLaS which of the four languages ``text`` is in."""
|
|
335
|
+
result = await self._complete(
|
|
336
|
+
detect_messages(text),
|
|
337
|
+
language=None,
|
|
338
|
+
temperature=0,
|
|
339
|
+
max_tokens=16,
|
|
340
|
+
timeout=timeout,
|
|
341
|
+
max_retries=max_retries,
|
|
342
|
+
)
|
|
343
|
+
return detection_from(result)
|
|
344
|
+
|
|
345
|
+
async def voice_chat(
|
|
346
|
+
self,
|
|
347
|
+
*,
|
|
348
|
+
audio: AudioInput,
|
|
349
|
+
language: str,
|
|
350
|
+
filename: str | None = None,
|
|
351
|
+
instruction: str | None = None,
|
|
352
|
+
temperature: float | None = None,
|
|
353
|
+
max_tokens: int | None = None,
|
|
354
|
+
timeout: float | None = None,
|
|
355
|
+
max_retries: int | None = None,
|
|
356
|
+
) -> VoiceChatResult:
|
|
357
|
+
"""Transcribe a voice note, then ask N-ATLaS to reply."""
|
|
358
|
+
lang = normalise_language(language)
|
|
359
|
+
heard = await self.transcribe(
|
|
360
|
+
audio=audio,
|
|
361
|
+
language=lang,
|
|
362
|
+
filename=filename,
|
|
363
|
+
response_format="json",
|
|
364
|
+
timeout=timeout,
|
|
365
|
+
max_retries=max_retries,
|
|
366
|
+
)
|
|
367
|
+
if not heard.text.strip():
|
|
368
|
+
raise BadRequestError("The transcription was empty, so there is nothing to reply to.")
|
|
369
|
+
result = await self._complete(
|
|
370
|
+
voice_messages(heard.text, lang, instruction),
|
|
371
|
+
language=lang,
|
|
372
|
+
temperature=0.3 if temperature is None else temperature,
|
|
373
|
+
max_tokens=512 if max_tokens is None else max_tokens,
|
|
374
|
+
timeout=timeout,
|
|
375
|
+
max_retries=max_retries,
|
|
376
|
+
)
|
|
377
|
+
return voice_result(heard.text, result, lang)
|
|
378
|
+
|
|
379
|
+
async def _complete(
|
|
380
|
+
self,
|
|
381
|
+
messages: Sequence[Mapping[str, object]],
|
|
382
|
+
*,
|
|
383
|
+
language: str | None,
|
|
384
|
+
temperature: float | None,
|
|
385
|
+
max_tokens: int | None,
|
|
386
|
+
timeout: float | None,
|
|
387
|
+
max_retries: int | None,
|
|
388
|
+
) -> ChatResult:
|
|
389
|
+
return await self.chat(
|
|
390
|
+
messages=messages,
|
|
391
|
+
stream=False,
|
|
392
|
+
language=language,
|
|
393
|
+
temperature=temperature,
|
|
394
|
+
max_tokens=max_tokens,
|
|
395
|
+
timeout=timeout,
|
|
396
|
+
max_retries=max_retries,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _ensure_open(self) -> None:
|
|
400
|
+
if self._closed:
|
|
401
|
+
raise NAtlasError("This client is closed.")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _optional_timeout(value: float | None) -> float | None:
|
|
405
|
+
if value is None:
|
|
406
|
+
return None
|
|
407
|
+
return require_timeout(value, "timeout")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _optional_retries(value: int | None) -> int | None:
|
|
411
|
+
if value is None:
|
|
412
|
+
return None
|
|
413
|
+
return require_retries(value, "max_retries")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Backend adapters.
|
|
2
|
+
|
|
3
|
+
``openai-compatible`` and ``hf-endpoint`` speak the same wire protocol as
|
|
4
|
+
``/serve``. ``official`` and ``local`` are reserved and raise, rather than
|
|
5
|
+
pretending a public NCAIR API or an in-process model exists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from natlas.backends.hf_endpoint import resolve_hf_endpoint_base
|
|
11
|
+
from natlas.backends.local import local_backend_unavailable
|
|
12
|
+
from natlas.backends.official import official_backend_unavailable
|
|
13
|
+
from natlas.backends.openai_compatible import resolve_openai_compatible_base
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"local_backend_unavailable",
|
|
17
|
+
"official_backend_unavailable",
|
|
18
|
+
"resolve_hf_endpoint_base",
|
|
19
|
+
"resolve_openai_compatible_base",
|
|
20
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""A Hugging Face Inference Endpoint running N-ATLaS under vLLM.
|
|
2
|
+
|
|
3
|
+
It speaks the same OpenAI-compatible protocol as ``/serve``. Point
|
|
4
|
+
``base_url`` at that endpoint. Speech routes exist only when the endpoint
|
|
5
|
+
is the N-ATLAS Kit gateway.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from natlas.backends.openai_compatible import (
|
|
11
|
+
resolve_openai_compatible_base as resolve_hf_endpoint_base,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = ["resolve_hf_endpoint_base"]
|
natlas/backends/local.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""In-process weights are a later milestone. This build only speaks HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from natlas.errors import NAtlasError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def local_backend_unavailable() -> None:
|
|
9
|
+
raise NAtlasError(
|
|
10
|
+
"The local transformers backend is not implemented in this version. "
|
|
11
|
+
"Point base_url at a /serve gateway. Loading NCAIR1 weights in-process "
|
|
12
|
+
"needs a GPU and is not part of the HTTP client."
|
|
13
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""There is no public NCAIR API yet. Fail loudly rather than invent one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from natlas.errors import NAtlasError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def official_backend_unavailable() -> None:
|
|
9
|
+
raise NAtlasError(
|
|
10
|
+
"The official NCAIR API is not public yet. Use backend "
|
|
11
|
+
'"openai-compatible" and point base_url at a /serve gateway '
|
|
12
|
+
"(see serve/README.md)."
|
|
13
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""URL rules for the ``/serve`` gateway and any OpenAI-compatible N-ATLaS endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from natlas.errors import BadRequestError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_openai_compatible_base(base_url: str) -> tuple[str, str]:
|
|
11
|
+
"""Return ``(api_base, health_url)``.
|
|
12
|
+
|
|
13
|
+
Both of these are accepted:
|
|
14
|
+
|
|
15
|
+
* ``http://localhost:8080``
|
|
16
|
+
* ``http://localhost:8080/v1``
|
|
17
|
+
|
|
18
|
+
``/health`` is never under ``/v1``. A trailing slash is ignored.
|
|
19
|
+
"""
|
|
20
|
+
parsed = urlsplit(base_url.strip())
|
|
21
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
22
|
+
raise BadRequestError(f"base_url must be an http(s) URL, got {base_url!r}.")
|
|
23
|
+
trimmed = base_url.strip().rstrip("/")
|
|
24
|
+
api_base = trimmed if trimmed.endswith("/v1") else f"{trimmed}/v1"
|
|
25
|
+
root = api_base[: -len("/v1")]
|
|
26
|
+
return api_base, f"{root}/health"
|