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 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"]
@@ -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"