zarx 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.
- zarx/__init__.py +64 -0
- zarx/__main__.py +3 -0
- zarx/_client.py +66 -0
- zarx/_config.py +67 -0
- zarx/_forecast_result.py +100 -0
- zarx/_realtime.py +149 -0
- zarx/_responses.py +36 -0
- zarx/_series.py +121 -0
- zarx/_speech_stream.py +53 -0
- zarx/_version.py +1 -0
- zarx/audio.py +246 -0
- zarx/cli/__init__.py +1 -0
- zarx/cli/audio_cmd.py +53 -0
- zarx/cli/chat_cmd.py +60 -0
- zarx/cli/console.py +18 -0
- zarx/cli/forecast_cmd.py +64 -0
- zarx/cli/main.py +71 -0
- zarx/cli/models_cmd.py +24 -0
- zarx/errors.py +36 -0
- zarx/forecasting.py +135 -0
- zarx/models.py +52 -0
- zarx/py.typed +0 -0
- zarx/reasoning.py +14 -0
- zarx/voices.py +58 -0
- zarx-0.2.0.dist-info/METADATA +246 -0
- zarx-0.2.0.dist-info/RECORD +30 -0
- zarx-0.2.0.dist-info/WHEEL +4 -0
- zarx-0.2.0.dist-info/entry_points.txt +2 -0
- zarx-0.2.0.dist-info/licenses/LICENSE +202 -0
- zarx-0.2.0.dist-info/licenses/NOTICE +4 -0
zarx/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Zarx: OpenAI-compatible inference from NPAW's own GPUs in Europe."""
|
|
2
|
+
|
|
3
|
+
from . import audio, models, voices
|
|
4
|
+
from ._client import AsyncZarx, Zarx
|
|
5
|
+
from ._config import BETA_BASE_URL, ENVIRONMENTS, PRODUCTION_BASE_URL, STAGE_BASE_URL
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
from .errors import (
|
|
8
|
+
APIConnectionError,
|
|
9
|
+
APIError,
|
|
10
|
+
APIStatusError,
|
|
11
|
+
APITimeoutError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
BadRequestError,
|
|
14
|
+
InternalServerError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
OpenAIError,
|
|
17
|
+
PermissionDeniedError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ZarxConfigError,
|
|
20
|
+
)
|
|
21
|
+
from .forecasting import Forecast, ForecastPoint, forecast, forecast_csv
|
|
22
|
+
from .models import TIERS, Z_FAST, Z_FORECAST, Z_PRO, Z_SPEECH, Z_TRANSCRIBE, Z_TRANSLATE, Z_ULTRA, Z_VOICE, pin, tier_of
|
|
23
|
+
from .reasoning import reasoning_text
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Zarx",
|
|
27
|
+
"AsyncZarx",
|
|
28
|
+
"PRODUCTION_BASE_URL",
|
|
29
|
+
"BETA_BASE_URL",
|
|
30
|
+
"STAGE_BASE_URL",
|
|
31
|
+
"ENVIRONMENTS",
|
|
32
|
+
"__version__",
|
|
33
|
+
"audio",
|
|
34
|
+
"models",
|
|
35
|
+
"voices",
|
|
36
|
+
"TIERS",
|
|
37
|
+
"Z_ULTRA",
|
|
38
|
+
"Z_PRO",
|
|
39
|
+
"Z_FAST",
|
|
40
|
+
"Z_TRANSCRIBE",
|
|
41
|
+
"Z_SPEECH",
|
|
42
|
+
"Z_TRANSLATE",
|
|
43
|
+
"Z_VOICE",
|
|
44
|
+
"Z_FORECAST",
|
|
45
|
+
"pin",
|
|
46
|
+
"tier_of",
|
|
47
|
+
"reasoning_text",
|
|
48
|
+
"forecast",
|
|
49
|
+
"forecast_csv",
|
|
50
|
+
"Forecast",
|
|
51
|
+
"ForecastPoint",
|
|
52
|
+
"APIConnectionError",
|
|
53
|
+
"APIError",
|
|
54
|
+
"APIStatusError",
|
|
55
|
+
"APITimeoutError",
|
|
56
|
+
"AuthenticationError",
|
|
57
|
+
"BadRequestError",
|
|
58
|
+
"InternalServerError",
|
|
59
|
+
"NotFoundError",
|
|
60
|
+
"OpenAIError",
|
|
61
|
+
"PermissionDeniedError",
|
|
62
|
+
"RateLimitError",
|
|
63
|
+
"ZarxConfigError",
|
|
64
|
+
]
|
zarx/__main__.py
ADDED
zarx/_client.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Zarx clients: the official OpenAI clients pointed at Zarx with Zarx defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import openai
|
|
8
|
+
|
|
9
|
+
from ._config import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, resolve_api_key, resolve_base_url
|
|
10
|
+
from ._responses import AsyncZarxResponses, ZarxResponses
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _zarx_kwargs(api_key: Optional[str], base_url: Optional[str], environment: Optional[str], kwargs: dict) -> dict:
|
|
15
|
+
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
|
|
16
|
+
kwargs.setdefault("max_retries", DEFAULT_MAX_RETRIES)
|
|
17
|
+
return {"api_key": resolve_api_key(api_key), "base_url": resolve_base_url(base_url, environment), **kwargs}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _user_agent() -> str:
|
|
21
|
+
return f"OpenAI/Python {openai.__version__} zarx-python/{__version__}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Zarx(openai.OpenAI):
|
|
25
|
+
"""Synchronous Zarx client. Everything the OpenAI client does, against Zarx.
|
|
26
|
+
|
|
27
|
+
Key: ``api_key`` or ``ZARX_API_KEY``. Endpoint: ``base_url``, then ``environment``
|
|
28
|
+
(``"production"``, the default, at https://api.zarx.ai/v1, or ``"beta"`` at
|
|
29
|
+
https://api-stage.zarx.ai/v1, which gets new models and features first), then
|
|
30
|
+
``ZARX_BASE_URL``, then ``ZARX_ENVIRONMENT``. Responses default to ``store=False``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
api_key: Optional[str] = None,
|
|
37
|
+
base_url: Optional[str] = None,
|
|
38
|
+
environment: Optional[str] = None,
|
|
39
|
+
**kwargs: Any,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(**_zarx_kwargs(api_key, base_url, environment, kwargs))
|
|
42
|
+
# Older openai releases build resources in __init__, newer ones lazily: assign after both.
|
|
43
|
+
self.responses = ZarxResponses(self) # type: ignore[misc,assignment]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def user_agent(self) -> str:
|
|
47
|
+
return _user_agent()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AsyncZarx(openai.AsyncOpenAI):
|
|
51
|
+
"""Asynchronous Zarx client. Same defaults as :class:`Zarx`."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
base_url: Optional[str] = None,
|
|
58
|
+
environment: Optional[str] = None,
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(**_zarx_kwargs(api_key, base_url, environment, kwargs))
|
|
62
|
+
self.responses = AsyncZarxResponses(self) # type: ignore[misc,assignment]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def user_agent(self) -> str:
|
|
66
|
+
return _user_agent()
|
zarx/_config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Resolve where and how the client talks to Zarx: environment, base URL, key, timeouts, retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import openai
|
|
9
|
+
|
|
10
|
+
PRODUCTION_BASE_URL = "https://api.zarx.ai/v1"
|
|
11
|
+
BETA_BASE_URL = "https://api-stage.zarx.ai/v1"
|
|
12
|
+
# Beta is served by the stage environment; the old name stays for existing code.
|
|
13
|
+
STAGE_BASE_URL = BETA_BASE_URL
|
|
14
|
+
|
|
15
|
+
# Production is the default. Beta gets new models and features first and may change.
|
|
16
|
+
ENVIRONMENTS: Dict[str, str] = {"production": PRODUCTION_BASE_URL, "beta": BETA_BASE_URL}
|
|
17
|
+
_ENVIRONMENT_ALIASES = {"prod": "production", "stage": "beta"}
|
|
18
|
+
|
|
19
|
+
API_KEY_ENV = "ZARX_API_KEY"
|
|
20
|
+
BASE_URL_ENV = "ZARX_BASE_URL"
|
|
21
|
+
ENVIRONMENT_ENV = "ZARX_ENVIRONMENT"
|
|
22
|
+
|
|
23
|
+
# Reasoning tiers can think for a while before the first token: allow long reads,
|
|
24
|
+
# but fail fast when the host cannot be reached.
|
|
25
|
+
DEFAULT_TIMEOUT = openai.Timeout(300.0, connect=10.0)
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ZarxConfigError(openai.OpenAIError):
|
|
30
|
+
"""Raised when the client cannot be configured (for example, no API key)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_environment(environment: Optional[str]) -> str:
|
|
34
|
+
"""Argument first, then ZARX_ENVIRONMENT, then production. Returns "production" or "beta"."""
|
|
35
|
+
raw = environment or os.environ.get(ENVIRONMENT_ENV) or "production"
|
|
36
|
+
name = str(raw).strip().lower()
|
|
37
|
+
name = _ENVIRONMENT_ALIASES.get(name, name)
|
|
38
|
+
if name not in ENVIRONMENTS:
|
|
39
|
+
source = "environment" if environment else ENVIRONMENT_ENV
|
|
40
|
+
valid = ", ".join(f'"{key}"' for key in ENVIRONMENTS)
|
|
41
|
+
raise ZarxConfigError(f"Unknown Zarx {source} {raw!r}. Use {valid} (aliases: \"prod\", \"stage\").")
|
|
42
|
+
return name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_base_url(base_url: Optional[Any], environment: Optional[str] = None) -> str:
|
|
46
|
+
"""base_url, then environment, then ZARX_BASE_URL, then ZARX_ENVIRONMENT, then production.
|
|
47
|
+
|
|
48
|
+
What the code says beats what the shell says. Never OPENAI_BASE_URL, so an
|
|
49
|
+
OpenAI setting never redirects Zarx traffic.
|
|
50
|
+
"""
|
|
51
|
+
# client.copy() passes the current base URL back as a URL object, not a str.
|
|
52
|
+
value = str(base_url or "")
|
|
53
|
+
if not value and environment:
|
|
54
|
+
value = ENVIRONMENTS[resolve_environment(environment)]
|
|
55
|
+
if not value:
|
|
56
|
+
value = os.environ.get(BASE_URL_ENV) or ENVIRONMENTS[resolve_environment(None)]
|
|
57
|
+
return value.rstrip("/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
61
|
+
"""Argument first, then ZARX_API_KEY. Never OPENAI_API_KEY, so an OpenAI key is never sent to Zarx."""
|
|
62
|
+
value = api_key or os.environ.get(API_KEY_ENV)
|
|
63
|
+
if not value:
|
|
64
|
+
raise ZarxConfigError(
|
|
65
|
+
f"No Zarx API key. Pass api_key=... or set the {API_KEY_ENV} environment variable."
|
|
66
|
+
)
|
|
67
|
+
return value
|
zarx/_forecast_result.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The typed result of a forecast: points with a value, an optional band and the percentiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ForecastPoint:
|
|
11
|
+
"""One forecast period."""
|
|
12
|
+
|
|
13
|
+
timestamp: str
|
|
14
|
+
"""ISO 8601 time of the period, as the API returned it."""
|
|
15
|
+
value: float
|
|
16
|
+
"""The point forecast."""
|
|
17
|
+
low: Optional[float] = None
|
|
18
|
+
"""Lower bound of the band (``include_band=True``), else None."""
|
|
19
|
+
high: Optional[float] = None
|
|
20
|
+
"""Upper bound of the band (``include_band=True``), else None."""
|
|
21
|
+
percentiles: Dict[str, float] = field(default_factory=dict)
|
|
22
|
+
"""Percentile values keyed by percentile (``"10"``, ``"50"``, ``"90"``, plus any requested)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Forecast:
|
|
27
|
+
"""What ``zarx.forecast`` returns. Iterate it, index it, or read ``values``."""
|
|
28
|
+
|
|
29
|
+
points: List[ForecastPoint]
|
|
30
|
+
raw: Dict[str, Any]
|
|
31
|
+
"""The response exactly as the API returned it."""
|
|
32
|
+
billable_points: int = 0
|
|
33
|
+
"""Data points the call is billed for: ``usage.datapoints`` from the response when the API
|
|
34
|
+
sends it, otherwise computed as the API bills (the context points sent, capped by
|
|
35
|
+
``data_points``, plus the forecast points)."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def values(self) -> List[float]:
|
|
39
|
+
return [p.value for p in self.points]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def timestamps(self) -> List[str]:
|
|
43
|
+
return [p.timestamp for p in self.points]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def lows(self) -> List[Optional[float]]:
|
|
47
|
+
return [p.low for p in self.points]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def highs(self) -> List[Optional[float]]:
|
|
51
|
+
return [p.high for p in self.points]
|
|
52
|
+
|
|
53
|
+
def __len__(self) -> int:
|
|
54
|
+
return len(self.points)
|
|
55
|
+
|
|
56
|
+
def __iter__(self) -> Iterator[ForecastPoint]:
|
|
57
|
+
return iter(self.points)
|
|
58
|
+
|
|
59
|
+
def __getitem__(self, index: int) -> ForecastPoint:
|
|
60
|
+
return self.points[index]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _optional_float(value: Any) -> Optional[float]:
|
|
64
|
+
return None if value is None else float(value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _usage_datapoints(raw: Mapping[str, Any]) -> Optional[int]:
|
|
68
|
+
"""``usage.datapoints`` when the response carries a whole non-negative number there, else None."""
|
|
69
|
+
usage = raw.get("usage")
|
|
70
|
+
if not isinstance(usage, Mapping):
|
|
71
|
+
return None
|
|
72
|
+
value = usage.get("datapoints")
|
|
73
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
74
|
+
return None
|
|
75
|
+
if value < 0 or value != int(value):
|
|
76
|
+
return None
|
|
77
|
+
return int(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_forecast(raw: Mapping[str, Any], context_points: int = 0) -> Forecast:
|
|
81
|
+
"""Build a :class:`Forecast` from the JSON of ``POST /v1/forecasts``; ``context_points`` is
|
|
82
|
+
what was sent as context, used for ``billable_points`` when the response has no usage."""
|
|
83
|
+
rows = raw.get("forecast")
|
|
84
|
+
if not isinstance(rows, list):
|
|
85
|
+
raise ValueError("forecast response has no 'forecast' list")
|
|
86
|
+
points = [
|
|
87
|
+
ForecastPoint(
|
|
88
|
+
timestamp=str(row.get("timestamp", "")),
|
|
89
|
+
value=float(row["forecast"]),
|
|
90
|
+
low=_optional_float(row.get("p_low")),
|
|
91
|
+
high=_optional_float(row.get("p_high")),
|
|
92
|
+
percentiles={str(k): float(v) for k, v in (row.get("percentiles") or {}).items()},
|
|
93
|
+
)
|
|
94
|
+
for row in rows
|
|
95
|
+
]
|
|
96
|
+
billed = _usage_datapoints(raw)
|
|
97
|
+
if billed is None:
|
|
98
|
+
billed = context_points + len(points)
|
|
99
|
+
return Forecast(points=points, raw=dict(raw), billable_points=billed)
|
|
100
|
+
|
zarx/_realtime.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Realtime transcription over Zarx's WebSocket route (not the OpenAI Realtime API).
|
|
2
|
+
|
|
3
|
+
Route: ``wss://<host>/v1/audio/transcriptions/realtime?model=<model>`` with the key as a bearer
|
|
4
|
+
token. The client sends ``session.update``, then ``input_audio_buffer.append`` events with
|
|
5
|
+
base64 PCM16 mono 16 kHz, then ``input_audio_buffer.commit`` with ``final: true``. The server
|
|
6
|
+
answers ``transcript.delta`` (``text``), ``transcript.segment`` (``index``, ``text``) and
|
|
7
|
+
``done`` with a ``summary`` whose ``final_text`` is the transcript. Verified on stage on
|
|
8
|
+
2026-11-01. Needs the ``websockets`` package: ``pip install "zarx[realtime]"``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import wave
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import IO, Any, Callable, Iterable, Iterator, List, Optional, Union
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
|
|
22
|
+
import openai
|
|
23
|
+
|
|
24
|
+
from ._client import Zarx
|
|
25
|
+
|
|
26
|
+
REALTIME_PATH = "/audio/transcriptions/realtime"
|
|
27
|
+
SAMPLE_RATE = 16_000
|
|
28
|
+
CHUNK_BYTES = 3_200 # 100 ms of PCM16 mono at 16 kHz
|
|
29
|
+
|
|
30
|
+
PcmSource = Union[str, Path, bytes, IO[bytes], Iterable[bytes]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RealtimeError(openai.OpenAIError):
|
|
34
|
+
"""The realtime route reported an error event or closed before the transcript was done."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def realtime_url(client: Zarx, model: str) -> str:
|
|
38
|
+
base = str(client.base_url).rstrip("/")
|
|
39
|
+
if base.startswith("https://"):
|
|
40
|
+
base = "wss://" + base[len("https://"):]
|
|
41
|
+
elif base.startswith("http://"):
|
|
42
|
+
base = "ws://" + base[len("http://"):]
|
|
43
|
+
return f"{base}{REALTIME_PATH}?model={quote(model, safe='')}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pcm_chunks(audio: PcmSource) -> Iterator[bytes]:
|
|
47
|
+
"""PCM16 mono 16 kHz chunks from a WAV path or file, raw PCM bytes, or an iterable of chunks."""
|
|
48
|
+
if isinstance(audio, (str, Path)):
|
|
49
|
+
with open(audio, "rb") as handle:
|
|
50
|
+
yield from _chunks(_wav_pcm(handle.read()))
|
|
51
|
+
elif isinstance(audio, (bytes, bytearray)):
|
|
52
|
+
data = bytes(audio)
|
|
53
|
+
yield from _chunks(_wav_pcm(data) if data[:4] == b"RIFF" else data)
|
|
54
|
+
elif hasattr(audio, "read"):
|
|
55
|
+
yield from _chunks(_wav_pcm(audio.read())) # type: ignore[union-attr]
|
|
56
|
+
else:
|
|
57
|
+
for chunk in audio: # type: ignore[union-attr]
|
|
58
|
+
if chunk:
|
|
59
|
+
yield bytes(chunk)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _source(audio: PcmSource) -> Iterable[bytes]:
|
|
63
|
+
"""Files and bytes are read and checked before connecting; an iterable (a microphone) streams."""
|
|
64
|
+
if isinstance(audio, (str, Path, bytes, bytearray)) or hasattr(audio, "read"):
|
|
65
|
+
return list(pcm_chunks(audio))
|
|
66
|
+
return pcm_chunks(audio)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _wav_pcm(data: bytes) -> bytes:
|
|
70
|
+
if data[:4] != b"RIFF":
|
|
71
|
+
return data # raw PCM
|
|
72
|
+
with wave.open(io.BytesIO(data)) as reader:
|
|
73
|
+
rate, channels, width = reader.getframerate(), reader.getnchannels(), reader.getsampwidth()
|
|
74
|
+
if (rate, channels, width) != (SAMPLE_RATE, 1, 2):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"realtime transcription takes PCM16 mono {SAMPLE_RATE} Hz; this WAV is {rate} Hz, "
|
|
77
|
+
f"{channels} channel(s), {width * 8} bit. Convert it, for example: "
|
|
78
|
+
f"ffmpeg -i in.wav -ar {SAMPLE_RATE} -ac 1 -sample_fmt s16 out.wav"
|
|
79
|
+
)
|
|
80
|
+
return reader.readframes(reader.getnframes())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _chunks(pcm: bytes) -> Iterator[bytes]:
|
|
84
|
+
for start in range(0, len(pcm), CHUNK_BYTES):
|
|
85
|
+
yield pcm[start : start + CHUNK_BYTES]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _connect(url: str, api_key: str, timeout: float) -> Any:
|
|
89
|
+
try:
|
|
90
|
+
from websockets.sync.client import connect
|
|
91
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
92
|
+
raise ImportError('realtime transcription needs the websockets package: pip install "zarx[realtime]"') from exc
|
|
93
|
+
return connect(url, additional_headers={"Authorization": f"Bearer {api_key}"}, open_timeout=timeout)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def transcribe_realtime(
|
|
97
|
+
audio: PcmSource,
|
|
98
|
+
*,
|
|
99
|
+
client: Zarx,
|
|
100
|
+
model: str,
|
|
101
|
+
on_delta: Optional[Callable[[str], None]] = None,
|
|
102
|
+
on_segment: Optional[Callable[[str], None]] = None,
|
|
103
|
+
timeout: float = 60.0,
|
|
104
|
+
) -> str:
|
|
105
|
+
"""Send the audio and return the final transcript. ``on_delta`` gets each piece of text as
|
|
106
|
+
it is recognised; ``on_segment`` gets each finished sentence. The audio is sent from a
|
|
107
|
+
thread while events are read, so text arrives while a long file or a microphone still plays."""
|
|
108
|
+
chunks = _source(audio)
|
|
109
|
+
segments: List[str] = []
|
|
110
|
+
send_errors: List[BaseException] = []
|
|
111
|
+
with _connect(realtime_url(client, model), client.api_key, timeout) as ws:
|
|
112
|
+
ws.send(json.dumps({"type": "session.update", "model": model}))
|
|
113
|
+
|
|
114
|
+
def send_audio() -> None:
|
|
115
|
+
try:
|
|
116
|
+
for chunk in chunks:
|
|
117
|
+
ws.send(json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(chunk).decode("ascii")}))
|
|
118
|
+
ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
|
|
119
|
+
except BaseException as exc: # surfaced by the receiving loop
|
|
120
|
+
send_errors.append(exc)
|
|
121
|
+
ws.close()
|
|
122
|
+
|
|
123
|
+
sender = threading.Thread(target=send_audio, name="zarx-realtime-sender", daemon=True)
|
|
124
|
+
sender.start()
|
|
125
|
+
while True:
|
|
126
|
+
try:
|
|
127
|
+
message = ws.recv(timeout=timeout)
|
|
128
|
+
except TimeoutError as exc:
|
|
129
|
+
raise RealtimeError(f"no event from the realtime route in {timeout:g} s") from exc
|
|
130
|
+
except Exception as exc: # the server closed the socket before 'done'
|
|
131
|
+
if send_errors:
|
|
132
|
+
raise send_errors[0]
|
|
133
|
+
raise RealtimeError(f"realtime connection closed before the transcript was done: {exc}") from exc
|
|
134
|
+
event = json.loads(message)
|
|
135
|
+
kind = event.get("type")
|
|
136
|
+
if kind == "transcript.delta" and on_delta:
|
|
137
|
+
on_delta(event.get("text") or "")
|
|
138
|
+
elif kind == "transcript.segment":
|
|
139
|
+
segments.append(event.get("text") or "")
|
|
140
|
+
if on_segment:
|
|
141
|
+
on_segment(event.get("text") or "")
|
|
142
|
+
elif kind == "error":
|
|
143
|
+
raise RealtimeError(str(event.get("message") or event))
|
|
144
|
+
elif kind == "done":
|
|
145
|
+
final = (event.get("summary") or {}).get("final_text")
|
|
146
|
+
return str(final).strip() if final else " ".join(s.strip() for s in segments)
|
|
147
|
+
elif kind == "session.closed":
|
|
148
|
+
raise RealtimeError("realtime session closed before the transcript was done")
|
|
149
|
+
|
zarx/_responses.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Responses resource that defaults to store=False, so no response is stored for retrieval unless asked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from openai.resources.responses import AsyncResponses, Responses
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _default_store(kwargs: dict) -> dict:
|
|
11
|
+
# Resuming a stream by response_id does not take a store flag.
|
|
12
|
+
if "response_id" not in kwargs:
|
|
13
|
+
kwargs.setdefault("store", False)
|
|
14
|
+
return kwargs
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ZarxResponses(Responses):
|
|
18
|
+
def create(self, *args: Any, **kwargs: Any) -> Any:
|
|
19
|
+
return super().create(*args, **_default_store(kwargs))
|
|
20
|
+
|
|
21
|
+
def stream(self, *args: Any, **kwargs: Any) -> Any:
|
|
22
|
+
return super().stream(*args, **_default_store(kwargs))
|
|
23
|
+
|
|
24
|
+
def parse(self, *args: Any, **kwargs: Any) -> Any:
|
|
25
|
+
return super().parse(*args, **_default_store(kwargs))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncZarxResponses(AsyncResponses):
|
|
29
|
+
async def create(self, *args: Any, **kwargs: Any) -> Any:
|
|
30
|
+
return await super().create(*args, **_default_store(kwargs))
|
|
31
|
+
|
|
32
|
+
def stream(self, *args: Any, **kwargs: Any) -> Any:
|
|
33
|
+
return super().stream(*args, **_default_store(kwargs))
|
|
34
|
+
|
|
35
|
+
async def parse(self, *args: Any, **kwargs: Any) -> Any:
|
|
36
|
+
return await super().parse(*args, **_default_store(kwargs))
|
zarx/_series.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Turn what a caller has (numbers, pairs, dicts, a mapping) into the points /v1/forecasts takes.
|
|
2
|
+
|
|
3
|
+
The API wants ``[{"timestamp": <ISO 8601>, "value": <number>, ...covariates}]`` with at least
|
|
4
|
+
three points. Bare numbers get generated timestamps so the simplest call stays one line.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from datetime import date, datetime, timedelta, timezone
|
|
11
|
+
from numbers import Real
|
|
12
|
+
from typing import Any, Dict, Iterable, List, Mapping, Sequence, Union
|
|
13
|
+
|
|
14
|
+
Timestamp = Union[str, datetime, date, int, float]
|
|
15
|
+
Point = Dict[str, Any]
|
|
16
|
+
|
|
17
|
+
MIN_POINTS = 3
|
|
18
|
+
DEFAULT_STEP = "1h"
|
|
19
|
+
|
|
20
|
+
_STEP = re.compile(r"^\s*(\d+)\s*(s|sec|m|min|h|d|w)\s*$", re.IGNORECASE)
|
|
21
|
+
_STEP_SECONDS = {"s": 1, "sec": 1, "m": 60, "min": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_step(step: Union[str, timedelta]) -> timedelta:
|
|
25
|
+
"""``"15min"``, ``"1h"``, ``"1d"``, ``"30s"``, ``"1w"`` or a timedelta."""
|
|
26
|
+
if isinstance(step, timedelta):
|
|
27
|
+
if step <= timedelta(0):
|
|
28
|
+
raise ValueError("step must be positive")
|
|
29
|
+
return step
|
|
30
|
+
match = _STEP.match(str(step))
|
|
31
|
+
if not match:
|
|
32
|
+
raise ValueError(f"step must look like 30s, 15min, 1h, 1d or 1w, got {step!r}")
|
|
33
|
+
return timedelta(seconds=int(match.group(1)) * _STEP_SECONDS[match.group(2).lower()])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def iso_timestamp(value: Timestamp) -> str:
|
|
37
|
+
"""ISO 8601 text for a timestamp. Naive datetimes are taken as UTC, numbers as Unix seconds."""
|
|
38
|
+
if isinstance(value, datetime):
|
|
39
|
+
if value.tzinfo is None:
|
|
40
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
41
|
+
return value.isoformat()
|
|
42
|
+
if isinstance(value, date):
|
|
43
|
+
return value.isoformat()
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
raise TypeError("a timestamp cannot be a bool")
|
|
46
|
+
if isinstance(value, Real):
|
|
47
|
+
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
|
|
48
|
+
if isinstance(value, str) and value.strip():
|
|
49
|
+
return value.strip()
|
|
50
|
+
raise TypeError(f"timestamp must be text, a datetime, a date or Unix seconds, got {type(value).__name__}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _number(value: Any, where: str) -> float:
|
|
54
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
55
|
+
raise TypeError(f"{where} must be a number, got {type(value).__name__}")
|
|
56
|
+
return float(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _generated_timestamps(count: int, step: Union[str, timedelta], start: Union[Timestamp, None]) -> List[str]:
|
|
60
|
+
delta = parse_step(step)
|
|
61
|
+
if start is None:
|
|
62
|
+
# The series ends now, on a whole step, so the forecast covers the coming periods.
|
|
63
|
+
now = datetime.now(timezone.utc).replace(microsecond=0)
|
|
64
|
+
whole = int(delta.total_seconds())
|
|
65
|
+
end = datetime.fromtimestamp((int(now.timestamp()) // whole) * whole, tz=timezone.utc)
|
|
66
|
+
first = end - delta * (count - 1)
|
|
67
|
+
else:
|
|
68
|
+
first = _as_datetime(start)
|
|
69
|
+
return [(first + delta * i).isoformat() for i in range(count)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _as_datetime(value: Timestamp) -> datetime:
|
|
73
|
+
if isinstance(value, datetime):
|
|
74
|
+
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
75
|
+
if isinstance(value, date):
|
|
76
|
+
return datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
|
|
77
|
+
if isinstance(value, Real) and not isinstance(value, bool):
|
|
78
|
+
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
79
|
+
text = str(value).strip().replace("Z", "+00:00")
|
|
80
|
+
parsed = datetime.fromisoformat(text)
|
|
81
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def to_points(series: Any, *, step: Union[str, timedelta] = DEFAULT_STEP, start: Union[Timestamp, None] = None) -> List[Point]:
|
|
85
|
+
"""Accepts bare numbers, ``(timestamp, value)`` pairs, dicts with ``timestamp`` and ``value``
|
|
86
|
+
(extra keys travel as covariates), or a mapping of timestamp to value (a dict, a pandas Series).
|
|
87
|
+
|
|
88
|
+
Bare numbers get one timestamp every ``step`` (default one hour), ending now unless ``start``
|
|
89
|
+
is given. At least three points are required.
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(series, (str, bytes)):
|
|
92
|
+
raise TypeError("series must be a sequence of numbers, pairs or points, not text")
|
|
93
|
+
items: Iterable[Any] = series.items() if hasattr(series, "items") else series
|
|
94
|
+
rows = list(items)
|
|
95
|
+
if len(rows) < MIN_POINTS:
|
|
96
|
+
raise ValueError(f"series must contain at least {MIN_POINTS} points, got {len(rows)}")
|
|
97
|
+
|
|
98
|
+
first = rows[0]
|
|
99
|
+
if isinstance(first, Mapping):
|
|
100
|
+
return [_point_from_mapping(row, i) for i, row in enumerate(rows)]
|
|
101
|
+
if isinstance(first, Sequence) and not isinstance(first, str):
|
|
102
|
+
return [_point_from_pair(row, i) for i, row in enumerate(rows)]
|
|
103
|
+
values = [_number(v, f"series[{i}]") for i, v in enumerate(rows)]
|
|
104
|
+
stamps = _generated_timestamps(len(values), step, start)
|
|
105
|
+
return [{"timestamp": ts, "value": v} for ts, v in zip(stamps, values)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _point_from_mapping(row: Mapping[str, Any], index: int) -> Point:
|
|
109
|
+
if "timestamp" not in row or "value" not in row:
|
|
110
|
+
raise ValueError(f"series[{index}] needs 'timestamp' and 'value' keys")
|
|
111
|
+
point = dict(row)
|
|
112
|
+
point["timestamp"] = iso_timestamp(point["timestamp"])
|
|
113
|
+
point["value"] = _number(point["value"], f"series[{index}]['value']")
|
|
114
|
+
return point
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _point_from_pair(row: Sequence[Any], index: int) -> Point:
|
|
118
|
+
if len(row) != 2:
|
|
119
|
+
raise ValueError(f"series[{index}] must be a (timestamp, value) pair, got {len(row)} items")
|
|
120
|
+
return {"timestamp": iso_timestamp(row[0]), "value": _number(row[1], f"series[{index}][1]")}
|
|
121
|
+
|
zarx/_speech_stream.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Speech that arrives in pieces: the server-sent events of ``stream_format="sse"``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Callable, ContextManager, Dict, Iterable, Iterator, Optional
|
|
8
|
+
|
|
9
|
+
DELTA = "speech.audio.delta"
|
|
10
|
+
DONE = "speech.audio.done"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SpeechStream:
|
|
14
|
+
"""Iterate it to get the audio bytes piece by piece; ``usage`` is set once it is exhausted.
|
|
15
|
+
|
|
16
|
+
Where the endpoint does not stream (a deployment that ignores ``stream_format``),
|
|
17
|
+
the whole audio comes as a single piece and ``usage`` stays None.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, open_response: Callable[[], ContextManager[Any]]) -> None:
|
|
21
|
+
self._open = open_response
|
|
22
|
+
self.usage: Optional[Dict[str, Any]] = None
|
|
23
|
+
|
|
24
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
25
|
+
with self._open() as response:
|
|
26
|
+
if "text/event-stream" not in response.headers.get("content-type", ""):
|
|
27
|
+
yield response.read()
|
|
28
|
+
return
|
|
29
|
+
for event in parse_events(response.iter_lines()):
|
|
30
|
+
if event.get("type") == DELTA and event.get("audio"):
|
|
31
|
+
yield base64.b64decode(event["audio"])
|
|
32
|
+
elif event.get("type") == DONE:
|
|
33
|
+
self.usage = event.get("usage")
|
|
34
|
+
|
|
35
|
+
def read(self) -> bytes:
|
|
36
|
+
"""All the audio at once."""
|
|
37
|
+
return b"".join(self)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_events(lines: Iterable[str]) -> Iterator[Dict[str, Any]]:
|
|
41
|
+
"""The JSON payload of every ``data:`` line; other lines (comments, blanks) are skipped."""
|
|
42
|
+
for line in lines:
|
|
43
|
+
if not line.startswith("data:"):
|
|
44
|
+
continue
|
|
45
|
+
payload = line[len("data:"):].strip()
|
|
46
|
+
if not payload or payload == "[DONE]":
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
event = json.loads(payload)
|
|
50
|
+
except ValueError:
|
|
51
|
+
continue
|
|
52
|
+
if isinstance(event, dict):
|
|
53
|
+
yield event
|
zarx/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|