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.
@@ -0,0 +1,2 @@
1
+ .planning
2
+ .claude
@@ -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