nero-client 0.1.0__tar.gz
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.
- nero_client-0.1.0/.gitignore +2 -0
- nero_client-0.1.0/PKG-INFO +73 -0
- nero_client-0.1.0/README.md +46 -0
- nero_client-0.1.0/pyproject.toml +47 -0
- nero_client-0.1.0/src/nero_client/__init__.py +17 -0
- nero_client-0.1.0/src/nero_client/client.py +214 -0
- nero_client-0.1.0/src/nero_client/exceptions.py +23 -0
- nero_client-0.1.0/src/nero_client/py.typed +0 -0
- nero_client-0.1.0/tests/__init__.py +0 -0
- nero_client-0.1.0/tests/test_client.py +201 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nero-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight Python client for the Nero voice-agent monitoring platform
|
|
5
|
+
Project-URL: Homepage, https://github.com/nurixlabs/nero
|
|
6
|
+
Project-URL: Documentation, https://docs.nero.dev
|
|
7
|
+
Author: Nero Team
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: monitoring,nero,observability,voice-agent
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx>=0.24.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# nero-client
|
|
29
|
+
|
|
30
|
+
Lightweight Python client for the [Nero](https://nero.dev) voice-agent monitoring platform.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install nero-client
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from nero_client import NeroClient
|
|
42
|
+
|
|
43
|
+
nero = NeroClient(api_key="nero_...")
|
|
44
|
+
|
|
45
|
+
nero.end_session(
|
|
46
|
+
transcript=[
|
|
47
|
+
{"role": "assistant", "content": "Hi, this is Alex from ...", "timestamp": "2026-04-09T10:00:01Z"},
|
|
48
|
+
{"role": "user", "content": "I'm not interested", "timestamp": "2026-04-09T10:00:04Z"},
|
|
49
|
+
],
|
|
50
|
+
latency_metrics={"stt_ms": 180, "llm_ms": 620, "tts_ms": 240, "round_trip_ms": 1040},
|
|
51
|
+
tool_calls=[{"name": "lookup_customer", "params": {"phone": "+1234567890"}, "result": {"name": "Jane"}}],
|
|
52
|
+
metadata={"caller_id": "+1234567890", "campaign": "q2_upsell", "duration_ms": 45000},
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Async
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
await nero.end_session_async(transcript=[...])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Configuration
|
|
63
|
+
|
|
64
|
+
| Parameter | Env var | Default |
|
|
65
|
+
|-----------|---------|---------|
|
|
66
|
+
| `api_key` | `NERO_API_KEY` | _(required)_ |
|
|
67
|
+
| `base_url` | `NERO_BASE_URL` | `https://api.nero.dev` |
|
|
68
|
+
| `timeout` | — | `10.0` |
|
|
69
|
+
| `max_retries` | — | `3` |
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# nero-client
|
|
2
|
+
|
|
3
|
+
Lightweight Python client for the [Nero](https://nero.dev) voice-agent monitoring platform.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nero-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from nero_client import NeroClient
|
|
15
|
+
|
|
16
|
+
nero = NeroClient(api_key="nero_...")
|
|
17
|
+
|
|
18
|
+
nero.end_session(
|
|
19
|
+
transcript=[
|
|
20
|
+
{"role": "assistant", "content": "Hi, this is Alex from ...", "timestamp": "2026-04-09T10:00:01Z"},
|
|
21
|
+
{"role": "user", "content": "I'm not interested", "timestamp": "2026-04-09T10:00:04Z"},
|
|
22
|
+
],
|
|
23
|
+
latency_metrics={"stt_ms": 180, "llm_ms": 620, "tts_ms": 240, "round_trip_ms": 1040},
|
|
24
|
+
tool_calls=[{"name": "lookup_customer", "params": {"phone": "+1234567890"}, "result": {"name": "Jane"}}],
|
|
25
|
+
metadata={"caller_id": "+1234567890", "campaign": "q2_upsell", "duration_ms": 45000},
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Async
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
await nero.end_session_async(transcript=[...])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Configuration
|
|
36
|
+
|
|
37
|
+
| Parameter | Env var | Default |
|
|
38
|
+
|-----------|---------|---------|
|
|
39
|
+
| `api_key` | `NERO_API_KEY` | _(required)_ |
|
|
40
|
+
| `base_url` | `NERO_BASE_URL` | `https://api.nero.dev` |
|
|
41
|
+
| `timeout` | — | `10.0` |
|
|
42
|
+
| `max_retries` | — | `3` |
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nero-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight Python client for the Nero voice-agent monitoring platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Nero Team" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["nero", "voice-agent", "monitoring", "observability"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.24.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0",
|
|
35
|
+
"pytest-asyncio>=0.21",
|
|
36
|
+
"respx>=0.21",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/nurixlabs/nero"
|
|
41
|
+
Documentation = "https://docs.nero.dev"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/nero_client"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""nero-client — Lightweight Python client for the Nero voice-agent monitoring platform."""
|
|
2
|
+
|
|
3
|
+
from .client import NeroClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
NeroAuthError,
|
|
6
|
+
NeroError,
|
|
7
|
+
NeroTransportError,
|
|
8
|
+
NeroValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"NeroClient",
|
|
13
|
+
"NeroError",
|
|
14
|
+
"NeroAuthError",
|
|
15
|
+
"NeroValidationError",
|
|
16
|
+
"NeroTransportError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Nero client — lightweight session reporter for voice agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .exceptions import NeroAuthError, NeroTransportError, NeroValidationError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("nero_client")
|
|
15
|
+
|
|
16
|
+
_DEFAULT_BASE_URL = "https://api.nero.dev"
|
|
17
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
18
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
19
|
+
_BACKOFF_BASE = 1 # seconds — retries at 1s, 3s, 9s (×3 each)
|
|
20
|
+
_BACKOFF_MULTIPLIER = 3
|
|
21
|
+
_SESSIONS_PATH = "/v1/sessions"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NeroClient:
|
|
25
|
+
"""Client for sending voice-agent session data to the Nero platform.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
api_key: Nero API key (``nero_...``). Falls back to ``NERO_API_KEY`` env var.
|
|
29
|
+
base_url: Nero API base URL. Falls back to ``NERO_BASE_URL`` env var,
|
|
30
|
+
then to ``https://api.nero.dev``.
|
|
31
|
+
timeout: HTTP request timeout in seconds.
|
|
32
|
+
max_retries: Number of retries on transient (5xx / timeout) failures.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
base_url: Optional[str] = None,
|
|
39
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
40
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.api_key = api_key or os.environ.get("NERO_API_KEY", "")
|
|
43
|
+
if not self.api_key:
|
|
44
|
+
raise NeroAuthError(
|
|
45
|
+
"API key is required. Pass api_key= or set NERO_API_KEY env var."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.base_url = (
|
|
49
|
+
base_url or os.environ.get("NERO_BASE_URL", _DEFAULT_BASE_URL)
|
|
50
|
+
).rstrip("/")
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
self.max_retries = max_retries
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Public API
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def end_session(
|
|
59
|
+
self,
|
|
60
|
+
transcript: List[Dict[str, Any]],
|
|
61
|
+
latency_metrics: Optional[Dict[str, Any]] = None,
|
|
62
|
+
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
|
63
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
64
|
+
) -> Dict[str, str]:
|
|
65
|
+
"""Send a completed session to Nero (synchronous).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
``{"conversation_id": "<uuid>"}`` on success.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
NeroAuthError: 401 — invalid API key.
|
|
72
|
+
NeroValidationError: 422 — malformed payload.
|
|
73
|
+
NeroTransportError: After retries exhausted on 5xx / timeout.
|
|
74
|
+
"""
|
|
75
|
+
payload = self._build_payload(transcript, latency_metrics, tool_calls, metadata)
|
|
76
|
+
return self._post_with_retry(payload)
|
|
77
|
+
|
|
78
|
+
async def end_session_async(
|
|
79
|
+
self,
|
|
80
|
+
transcript: List[Dict[str, Any]],
|
|
81
|
+
latency_metrics: Optional[Dict[str, Any]] = None,
|
|
82
|
+
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
|
83
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
84
|
+
) -> Dict[str, str]:
|
|
85
|
+
"""Send a completed session to Nero (async).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
``{"conversation_id": "<uuid>"}`` on success.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
NeroAuthError: 401 — invalid API key.
|
|
92
|
+
NeroValidationError: 422 — malformed payload.
|
|
93
|
+
NeroTransportError: After retries exhausted on 5xx / timeout.
|
|
94
|
+
"""
|
|
95
|
+
payload = self._build_payload(transcript, latency_metrics, tool_calls, metadata)
|
|
96
|
+
return await self._post_with_retry_async(payload)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Internal
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _build_payload(
|
|
103
|
+
self,
|
|
104
|
+
transcript: List[Dict[str, Any]],
|
|
105
|
+
latency_metrics: Optional[Dict[str, Any]],
|
|
106
|
+
tool_calls: Optional[List[Dict[str, Any]]],
|
|
107
|
+
metadata: Optional[Dict[str, Any]],
|
|
108
|
+
) -> Dict[str, Any]:
|
|
109
|
+
if not transcript:
|
|
110
|
+
raise NeroValidationError("transcript must be a non-empty list")
|
|
111
|
+
|
|
112
|
+
payload: Dict[str, Any] = {"transcript": transcript}
|
|
113
|
+
if latency_metrics is not None:
|
|
114
|
+
payload["latency_metrics"] = latency_metrics
|
|
115
|
+
if tool_calls is not None:
|
|
116
|
+
payload["tool_calls"] = tool_calls
|
|
117
|
+
if metadata is not None:
|
|
118
|
+
payload["metadata"] = metadata
|
|
119
|
+
return payload
|
|
120
|
+
|
|
121
|
+
def _headers(self) -> Dict[str, str]:
|
|
122
|
+
return {
|
|
123
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def _url(self) -> str:
|
|
128
|
+
return f"{self.base_url}{_SESSIONS_PATH}"
|
|
129
|
+
|
|
130
|
+
# ---- sync retry loop ---------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _post_with_retry(self, payload: Dict[str, Any]) -> Dict[str, str]:
|
|
133
|
+
last_exc: Optional[Exception] = None
|
|
134
|
+
for attempt in range(self.max_retries + 1):
|
|
135
|
+
try:
|
|
136
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
137
|
+
resp = client.post(
|
|
138
|
+
self._url(), json=payload, headers=self._headers()
|
|
139
|
+
)
|
|
140
|
+
return self._handle_response(resp)
|
|
141
|
+
except (NeroAuthError, NeroValidationError):
|
|
142
|
+
raise
|
|
143
|
+
except httpx.HTTPStatusError as exc:
|
|
144
|
+
last_exc = exc
|
|
145
|
+
except httpx.TransportError as exc:
|
|
146
|
+
last_exc = exc
|
|
147
|
+
|
|
148
|
+
if attempt < self.max_retries:
|
|
149
|
+
delay = _BACKOFF_BASE * (_BACKOFF_MULTIPLIER**attempt)
|
|
150
|
+
logger.warning(
|
|
151
|
+
"Nero: request failed (attempt %d/%d), retrying in %ds — %s",
|
|
152
|
+
attempt + 1,
|
|
153
|
+
self.max_retries + 1,
|
|
154
|
+
delay,
|
|
155
|
+
last_exc,
|
|
156
|
+
)
|
|
157
|
+
time.sleep(delay)
|
|
158
|
+
|
|
159
|
+
raise NeroTransportError(
|
|
160
|
+
f"Failed after {self.max_retries + 1} attempts: {last_exc}"
|
|
161
|
+
) from last_exc
|
|
162
|
+
|
|
163
|
+
# ---- async retry loop --------------------------------------------
|
|
164
|
+
|
|
165
|
+
async def _post_with_retry_async(
|
|
166
|
+
self, payload: Dict[str, Any]
|
|
167
|
+
) -> Dict[str, str]:
|
|
168
|
+
import asyncio
|
|
169
|
+
|
|
170
|
+
last_exc: Optional[Exception] = None
|
|
171
|
+
for attempt in range(self.max_retries + 1):
|
|
172
|
+
try:
|
|
173
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
174
|
+
resp = await client.post(
|
|
175
|
+
self._url(), json=payload, headers=self._headers()
|
|
176
|
+
)
|
|
177
|
+
return self._handle_response(resp)
|
|
178
|
+
except (NeroAuthError, NeroValidationError):
|
|
179
|
+
raise
|
|
180
|
+
except httpx.HTTPStatusError as exc:
|
|
181
|
+
last_exc = exc
|
|
182
|
+
except httpx.TransportError as exc:
|
|
183
|
+
last_exc = exc
|
|
184
|
+
|
|
185
|
+
if attempt < self.max_retries:
|
|
186
|
+
delay = _BACKOFF_BASE * (_BACKOFF_MULTIPLIER**attempt)
|
|
187
|
+
logger.warning(
|
|
188
|
+
"Nero: request failed (attempt %d/%d), retrying in %ds — %s",
|
|
189
|
+
attempt + 1,
|
|
190
|
+
self.max_retries + 1,
|
|
191
|
+
delay,
|
|
192
|
+
last_exc,
|
|
193
|
+
)
|
|
194
|
+
await asyncio.sleep(delay)
|
|
195
|
+
|
|
196
|
+
raise NeroTransportError(
|
|
197
|
+
f"Failed after {self.max_retries + 1} attempts: {last_exc}"
|
|
198
|
+
) from last_exc
|
|
199
|
+
|
|
200
|
+
# ---- response handling -------------------------------------------
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _handle_response(resp: httpx.Response) -> Dict[str, str]:
|
|
204
|
+
if resp.status_code == 401:
|
|
205
|
+
raise NeroAuthError("Invalid or revoked API key")
|
|
206
|
+
if resp.status_code == 422:
|
|
207
|
+
detail = resp.json().get("detail") if resp.content else None
|
|
208
|
+
raise NeroValidationError("Payload validation failed", detail=detail)
|
|
209
|
+
if resp.status_code >= 500:
|
|
210
|
+
resp.raise_for_status() # raises httpx.HTTPStatusError → caught by retry
|
|
211
|
+
if resp.status_code >= 400:
|
|
212
|
+
resp.raise_for_status()
|
|
213
|
+
|
|
214
|
+
return resp.json()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Nero client exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NeroError(Exception):
|
|
7
|
+
"""Base exception for all Nero client errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NeroAuthError(NeroError):
|
|
11
|
+
"""Raised on 401 — invalid or revoked API key. Not retried."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NeroValidationError(NeroError):
|
|
15
|
+
"""Raised on 422 — malformed payload. Not retried."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, detail: object = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.detail = detail
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NeroTransportError(NeroError):
|
|
23
|
+
"""Raised after retries are exhausted on 5xx / timeout / connection error."""
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Tests for nero_client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
import respx
|
|
10
|
+
|
|
11
|
+
from nero_client import (
|
|
12
|
+
NeroAuthError,
|
|
13
|
+
NeroClient,
|
|
14
|
+
NeroTransportError,
|
|
15
|
+
NeroValidationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
API_KEY = "nero_test1234567890"
|
|
19
|
+
BASE_URL = "https://api.nero.test"
|
|
20
|
+
SESSIONS_URL = f"{BASE_URL}/v1/sessions"
|
|
21
|
+
|
|
22
|
+
SAMPLE_TRANSCRIPT = [
|
|
23
|
+
{"role": "assistant", "content": "Hi there!", "timestamp": "2026-04-09T10:00:01Z"},
|
|
24
|
+
{"role": "user", "content": "Not interested", "timestamp": "2026-04-09T10:00:04Z"},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_client(**kwargs) -> NeroClient:
|
|
29
|
+
defaults = {"api_key": API_KEY, "base_url": BASE_URL}
|
|
30
|
+
defaults.update(kwargs)
|
|
31
|
+
return NeroClient(**defaults)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
# Initialization
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestInit:
|
|
40
|
+
def test_requires_api_key(self):
|
|
41
|
+
with pytest.raises(NeroAuthError, match="API key is required"):
|
|
42
|
+
NeroClient()
|
|
43
|
+
|
|
44
|
+
def test_api_key_from_env(self, monkeypatch):
|
|
45
|
+
monkeypatch.setenv("NERO_API_KEY", API_KEY)
|
|
46
|
+
client = NeroClient(base_url=BASE_URL)
|
|
47
|
+
assert client.api_key == API_KEY
|
|
48
|
+
|
|
49
|
+
def test_base_url_from_env(self, monkeypatch):
|
|
50
|
+
monkeypatch.setenv("NERO_BASE_URL", "https://custom.nero.test")
|
|
51
|
+
client = NeroClient(api_key=API_KEY)
|
|
52
|
+
assert client.base_url == "https://custom.nero.test"
|
|
53
|
+
|
|
54
|
+
def test_defaults(self):
|
|
55
|
+
client = _make_client()
|
|
56
|
+
assert client.timeout == 10.0
|
|
57
|
+
assert client.max_retries == 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# Sync end_session
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestEndSession:
|
|
66
|
+
@respx.mock
|
|
67
|
+
def test_success(self):
|
|
68
|
+
respx.post(SESSIONS_URL).mock(
|
|
69
|
+
return_value=httpx.Response(
|
|
70
|
+
202, json={"conversation_id": "conv-123"}
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
client = _make_client()
|
|
74
|
+
result = client.end_session(transcript=SAMPLE_TRANSCRIPT)
|
|
75
|
+
assert result == {"conversation_id": "conv-123"}
|
|
76
|
+
|
|
77
|
+
@respx.mock
|
|
78
|
+
def test_sends_full_payload(self):
|
|
79
|
+
route = respx.post(SESSIONS_URL).mock(
|
|
80
|
+
return_value=httpx.Response(
|
|
81
|
+
202, json={"conversation_id": "conv-456"}
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
client = _make_client()
|
|
85
|
+
client.end_session(
|
|
86
|
+
transcript=SAMPLE_TRANSCRIPT,
|
|
87
|
+
latency_metrics={"stt_ms": 100, "llm_ms": 200, "tts_ms": 150, "round_trip_ms": 450},
|
|
88
|
+
tool_calls=[{"name": "lookup", "params": {"id": "1"}, "result": {"ok": True}}],
|
|
89
|
+
metadata={"caller_id": "+1234", "campaign": "test"},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
sent = route.calls[0].request
|
|
93
|
+
import json
|
|
94
|
+
body = json.loads(sent.content)
|
|
95
|
+
assert body["transcript"] == SAMPLE_TRANSCRIPT
|
|
96
|
+
assert body["latency_metrics"]["stt_ms"] == 100
|
|
97
|
+
assert body["tool_calls"][0]["name"] == "lookup"
|
|
98
|
+
assert body["metadata"]["campaign"] == "test"
|
|
99
|
+
|
|
100
|
+
@respx.mock
|
|
101
|
+
def test_bearer_token_sent(self):
|
|
102
|
+
route = respx.post(SESSIONS_URL).mock(
|
|
103
|
+
return_value=httpx.Response(202, json={"conversation_id": "c"})
|
|
104
|
+
)
|
|
105
|
+
_make_client().end_session(transcript=SAMPLE_TRANSCRIPT)
|
|
106
|
+
auth = route.calls[0].request.headers["authorization"]
|
|
107
|
+
assert auth == f"Bearer {API_KEY}"
|
|
108
|
+
|
|
109
|
+
def test_empty_transcript_raises(self):
|
|
110
|
+
client = _make_client()
|
|
111
|
+
with pytest.raises(NeroValidationError, match="non-empty"):
|
|
112
|
+
client.end_session(transcript=[])
|
|
113
|
+
|
|
114
|
+
@respx.mock
|
|
115
|
+
def test_401_raises_auth_error(self):
|
|
116
|
+
respx.post(SESSIONS_URL).mock(
|
|
117
|
+
return_value=httpx.Response(401, json={"detail": "invalid key"})
|
|
118
|
+
)
|
|
119
|
+
with pytest.raises(NeroAuthError):
|
|
120
|
+
_make_client().end_session(transcript=SAMPLE_TRANSCRIPT)
|
|
121
|
+
|
|
122
|
+
@respx.mock
|
|
123
|
+
def test_422_raises_validation_error(self):
|
|
124
|
+
respx.post(SESSIONS_URL).mock(
|
|
125
|
+
return_value=httpx.Response(
|
|
126
|
+
422, json={"detail": [{"msg": "bad field"}]}
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
with pytest.raises(NeroValidationError):
|
|
130
|
+
_make_client().end_session(transcript=SAMPLE_TRANSCRIPT)
|
|
131
|
+
|
|
132
|
+
@respx.mock
|
|
133
|
+
def test_retries_on_500(self):
|
|
134
|
+
route = respx.post(SESSIONS_URL)
|
|
135
|
+
route.side_effect = [
|
|
136
|
+
httpx.Response(500),
|
|
137
|
+
httpx.Response(500),
|
|
138
|
+
httpx.Response(202, json={"conversation_id": "conv-ok"}),
|
|
139
|
+
]
|
|
140
|
+
client = _make_client(max_retries=2)
|
|
141
|
+
result = client.end_session(transcript=SAMPLE_TRANSCRIPT)
|
|
142
|
+
assert result == {"conversation_id": "conv-ok"}
|
|
143
|
+
assert route.call_count == 3
|
|
144
|
+
|
|
145
|
+
@respx.mock
|
|
146
|
+
def test_exhausts_retries_raises_transport_error(self):
|
|
147
|
+
respx.post(SESSIONS_URL).mock(return_value=httpx.Response(503))
|
|
148
|
+
client = _make_client(max_retries=1)
|
|
149
|
+
with pytest.raises(NeroTransportError, match="2 attempts"):
|
|
150
|
+
client.end_session(transcript=SAMPLE_TRANSCRIPT)
|
|
151
|
+
|
|
152
|
+
@respx.mock
|
|
153
|
+
def test_no_retry_on_401(self):
|
|
154
|
+
route = respx.post(SESSIONS_URL).mock(
|
|
155
|
+
return_value=httpx.Response(401, json={"detail": "bad key"})
|
|
156
|
+
)
|
|
157
|
+
with pytest.raises(NeroAuthError):
|
|
158
|
+
_make_client().end_session(transcript=SAMPLE_TRANSCRIPT)
|
|
159
|
+
assert route.call_count == 1
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Async end_session_async
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestEndSessionAsync:
|
|
168
|
+
@respx.mock
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_success(self):
|
|
171
|
+
respx.post(SESSIONS_URL).mock(
|
|
172
|
+
return_value=httpx.Response(
|
|
173
|
+
202, json={"conversation_id": "async-123"}
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
client = _make_client()
|
|
177
|
+
result = await client.end_session_async(transcript=SAMPLE_TRANSCRIPT)
|
|
178
|
+
assert result == {"conversation_id": "async-123"}
|
|
179
|
+
|
|
180
|
+
@respx.mock
|
|
181
|
+
@pytest.mark.asyncio
|
|
182
|
+
async def test_retries_on_500(self):
|
|
183
|
+
route = respx.post(SESSIONS_URL)
|
|
184
|
+
route.side_effect = [
|
|
185
|
+
httpx.Response(500),
|
|
186
|
+
httpx.Response(202, json={"conversation_id": "async-ok"}),
|
|
187
|
+
]
|
|
188
|
+
client = _make_client(max_retries=1)
|
|
189
|
+
result = await client.end_session_async(transcript=SAMPLE_TRANSCRIPT)
|
|
190
|
+
assert result == {"conversation_id": "async-ok"}
|
|
191
|
+
assert route.call_count == 2
|
|
192
|
+
|
|
193
|
+
@respx.mock
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_401_raises_immediately(self):
|
|
196
|
+
route = respx.post(SESSIONS_URL).mock(
|
|
197
|
+
return_value=httpx.Response(401, json={"detail": "bad"})
|
|
198
|
+
)
|
|
199
|
+
with pytest.raises(NeroAuthError):
|
|
200
|
+
await _make_client().end_session_async(transcript=SAMPLE_TRANSCRIPT)
|
|
201
|
+
assert route.call_count == 1
|