auriko 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.
auriko/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Auriko SDK — Intelligent LLM routing."""
2
+
3
+ from ._client import AsyncClient, Client
4
+ from ._version import __version__
5
+ from .errors import (
6
+ APIConnectionError,
7
+ APIStatusError,
8
+ AurikoAPIError,
9
+ AuthenticationError,
10
+ BadRequestError,
11
+ ConflictError,
12
+ InternalServerError,
13
+ NotFoundError,
14
+ PermissionDeniedError,
15
+ RateLimitError,
16
+ map_openai_error,
17
+ )
18
+ from .response_headers import ResponseHeaders
19
+ from ._streaming import AsyncStream, Stream
20
+
21
+ try:
22
+ from ._openai_client import AurikoAsyncOpenAI
23
+ except ModuleNotFoundError:
24
+ pass # Available with: pip install auriko[openai-compat]
auriko/_base_client.py ADDED
@@ -0,0 +1,111 @@
1
+ """Shared non-I/O logic for Client and AsyncClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ from typing import Any, Mapping, Optional, TypeVar
8
+
9
+ import httpx
10
+
11
+ from ._version import __version__
12
+ from .errors.mapping import _map_error_from_envelope
13
+ from .errors.types import APIStatusError, AurikoAPIError, AuthenticationError
14
+ from .response_headers import ResponseHeaders
15
+
16
+ T = TypeVar("T")
17
+
18
+ _DEFAULT_BASE_URL = "https://api.auriko.ai/v1"
19
+ _DEFAULT_TIMEOUT = 60.0
20
+ _DEFAULT_MAX_RETRIES = 2
21
+
22
+ _INITIAL_RETRY_DELAY = 0.5
23
+ _RETRY_DELAY_MULTIPLIER = 1.5
24
+ _MAX_RETRY_DELAY = 30.0
25
+
26
+
27
+ class BaseClient:
28
+ """Shared configuration and pure-logic helpers for sync and async clients."""
29
+
30
+ api_key: str
31
+ base_url: str
32
+ timeout: float
33
+ max_retries: int
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ api_key: Optional[str] = None,
39
+ base_url: Optional[str] = None,
40
+ timeout: Optional[float] = None,
41
+ max_retries: Optional[int] = None,
42
+ ) -> None:
43
+ resolved_key = api_key or os.environ.get("AURIKO_API_KEY")
44
+ if not resolved_key:
45
+ raise AuthenticationError(
46
+ message="API key is missing. Set AURIKO_API_KEY or pass api_key=.",
47
+ status_code=0,
48
+ type="authentication_error",
49
+ code="missing_api_key",
50
+ )
51
+ self.api_key = resolved_key
52
+
53
+ url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
54
+ if not url.endswith("/v1"):
55
+ url += "/v1"
56
+ self.base_url = url + "/"
57
+
58
+ self.timeout = timeout if timeout is not None else _DEFAULT_TIMEOUT
59
+ self.max_retries = max_retries if max_retries is not None else _DEFAULT_MAX_RETRIES
60
+
61
+ @property
62
+ def _default_headers(self) -> dict[str, str]:
63
+ return {
64
+ "Authorization": f"Bearer {self.api_key}",
65
+ "User-Agent": f"auriko-python/{__version__}",
66
+ }
67
+
68
+ @staticmethod
69
+ def _calculate_retry_delay(
70
+ attempt: int,
71
+ retry_after_header: Optional[str] = None,
72
+ ) -> float:
73
+ delay = _INITIAL_RETRY_DELAY * (_RETRY_DELAY_MULTIPLIER ** attempt)
74
+ delay = min(delay, _MAX_RETRY_DELAY)
75
+ delay *= 0.8 + random.random() * 0.4
76
+
77
+ if retry_after_header is not None:
78
+ try:
79
+ retry_after = float(retry_after_header)
80
+ delay = max(delay, retry_after)
81
+ except (ValueError, OverflowError):
82
+ pass
83
+
84
+ return delay
85
+
86
+ def _make_error(
87
+ self,
88
+ status_code: int,
89
+ body: Any,
90
+ *,
91
+ headers: Optional[Mapping[str, str]] = None,
92
+ ) -> AurikoAPIError:
93
+ """Create a typed error from an HTTP error response via strict envelope dispatch."""
94
+ response_headers = ResponseHeaders(headers) if headers is not None else None
95
+ return _map_error_from_envelope(
96
+ status_code, body, response_headers=response_headers,
97
+ )
98
+
99
+ def _parse_response(self, response: httpx.Response, cast_to: type[T]) -> T:
100
+ """Parse response JSON and validate against a Pydantic model."""
101
+ try:
102
+ data = response.json()
103
+ except Exception as e:
104
+ raise APIStatusError(
105
+ message="Response body is not valid JSON.",
106
+ status_code=response.status_code,
107
+ type="api_error",
108
+ code="invalid_response",
109
+ response_headers=ResponseHeaders(response.headers),
110
+ ) from e
111
+ return cast_to.model_validate(data) # type: ignore[union-attr]
auriko/_client.py ADDED
@@ -0,0 +1,372 @@
1
+ """Auriko API client — sync and async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+
10
+ from ._base_client import BaseClient
11
+ from .errors.mapping import is_retryable
12
+ from .errors.types import APIConnectionError
13
+
14
+
15
+ class Client(BaseClient):
16
+ """Synchronous Auriko API client."""
17
+
18
+ _httpx: httpx.Client
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ api_key: Optional[str] = None,
24
+ base_url: Optional[str] = None,
25
+ timeout: Optional[float] = None,
26
+ max_retries: Optional[int] = None,
27
+ ) -> None:
28
+ super().__init__(
29
+ api_key=api_key,
30
+ base_url=base_url,
31
+ timeout=timeout,
32
+ max_retries=max_retries,
33
+ )
34
+ self._httpx = httpx.Client(
35
+ base_url=self.base_url,
36
+ headers=self._default_headers,
37
+ timeout=self.timeout,
38
+ follow_redirects=True,
39
+ )
40
+
41
+ from .resources.chat import Chat
42
+ from .resources.me import Me
43
+ from .resources.models import Models
44
+
45
+ self.chat = Chat(self)
46
+ self.models = Models(self)
47
+ self.me = Me(self)
48
+
49
+ def _request(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ *,
54
+ body: Optional[dict[str, Any]] = None,
55
+ params: Optional[dict[str, Any]] = None,
56
+ headers: Optional[dict[str, str]] = None,
57
+ ) -> httpx.Response:
58
+ request = self._httpx.build_request(
59
+ method, path, json=body, params=params, headers=headers,
60
+ )
61
+ retries_remaining = self.max_retries
62
+
63
+ while True:
64
+ try:
65
+ response = self._httpx.send(request)
66
+ except httpx.TimeoutException as e:
67
+ if retries_remaining > 0:
68
+ retries_remaining -= 1
69
+ time.sleep(self._calculate_retry_delay(
70
+ self.max_retries - retries_remaining - 1,
71
+ ))
72
+ continue
73
+ raise APIConnectionError(
74
+ message="Request timed out before a response was received.",
75
+ cause=e,
76
+ ) from e
77
+ except httpx.NetworkError as e:
78
+ if retries_remaining > 0:
79
+ retries_remaining -= 1
80
+ time.sleep(self._calculate_retry_delay(
81
+ self.max_retries - retries_remaining - 1,
82
+ ))
83
+ continue
84
+ raise APIConnectionError(cause=e) from e
85
+ except httpx.TransportError as e:
86
+ raise APIConnectionError(cause=e) from e
87
+
88
+ if response.status_code >= 400:
89
+ try:
90
+ error_body = response.json()
91
+ except Exception:
92
+ text = response.text
93
+ error_body = text[:4096] if text else None
94
+
95
+ exc = self._make_error(
96
+ response.status_code, error_body, headers=response.headers,
97
+ )
98
+ if is_retryable(exc) and retries_remaining > 0:
99
+ retries_remaining -= 1
100
+ retry_after = (
101
+ exc.response_headers.get("Retry-After")
102
+ if exc.response_headers is not None
103
+ else None
104
+ )
105
+ response.close()
106
+ time.sleep(self._calculate_retry_delay(
107
+ self.max_retries - retries_remaining - 1,
108
+ retry_after,
109
+ ))
110
+ continue
111
+ raise exc
112
+
113
+ return response
114
+
115
+ def _request_stream(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ *,
120
+ body: Optional[dict[str, Any]] = None,
121
+ params: Optional[dict[str, Any]] = None,
122
+ headers: Optional[dict[str, str]] = None,
123
+ ) -> httpx.Response:
124
+ request = self._httpx.build_request(
125
+ method, path, json=body, params=params, headers=headers,
126
+ )
127
+ retries_remaining = self.max_retries
128
+
129
+ while True:
130
+ try:
131
+ response = self._httpx.send(request, stream=True)
132
+ except httpx.TimeoutException as e:
133
+ if retries_remaining > 0:
134
+ retries_remaining -= 1
135
+ time.sleep(self._calculate_retry_delay(
136
+ self.max_retries - retries_remaining - 1,
137
+ ))
138
+ continue
139
+ raise APIConnectionError(
140
+ message="Request timed out before a response was received.",
141
+ cause=e,
142
+ ) from e
143
+ except httpx.NetworkError as e:
144
+ if retries_remaining > 0:
145
+ retries_remaining -= 1
146
+ time.sleep(self._calculate_retry_delay(
147
+ self.max_retries - retries_remaining - 1,
148
+ ))
149
+ continue
150
+ raise APIConnectionError(cause=e) from e
151
+ except httpx.TransportError as e:
152
+ raise APIConnectionError(cause=e) from e
153
+
154
+ if response.status_code >= 400:
155
+ response.read()
156
+ try:
157
+ error_body = response.json()
158
+ except Exception:
159
+ text = response.text
160
+ error_body = text[:4096] if text else None
161
+
162
+ exc = self._make_error(
163
+ response.status_code, error_body, headers=response.headers,
164
+ )
165
+ if is_retryable(exc) and retries_remaining > 0:
166
+ retries_remaining -= 1
167
+ retry_after = (
168
+ exc.response_headers.get("Retry-After")
169
+ if exc.response_headers is not None
170
+ else None
171
+ )
172
+ response.close()
173
+ time.sleep(self._calculate_retry_delay(
174
+ self.max_retries - retries_remaining - 1,
175
+ retry_after,
176
+ ))
177
+ continue
178
+ response.close()
179
+ raise exc
180
+
181
+ return response
182
+
183
+ def close(self) -> None:
184
+ self._httpx.close()
185
+
186
+ def __enter__(self) -> Client:
187
+ return self
188
+
189
+ def __exit__(self, *args: object) -> None:
190
+ self.close()
191
+
192
+
193
+ class AsyncClient(BaseClient):
194
+ """Asynchronous Auriko API client."""
195
+
196
+ _httpx: httpx.AsyncClient
197
+
198
+ def __init__(
199
+ self,
200
+ *,
201
+ api_key: Optional[str] = None,
202
+ base_url: Optional[str] = None,
203
+ timeout: Optional[float] = None,
204
+ max_retries: Optional[int] = None,
205
+ ) -> None:
206
+ super().__init__(
207
+ api_key=api_key,
208
+ base_url=base_url,
209
+ timeout=timeout,
210
+ max_retries=max_retries,
211
+ )
212
+ self._httpx = httpx.AsyncClient(
213
+ base_url=self.base_url,
214
+ headers=self._default_headers,
215
+ timeout=self.timeout,
216
+ follow_redirects=True,
217
+ )
218
+
219
+ from .resources.chat import AsyncChat
220
+ from .resources.me import AsyncMe
221
+ from .resources.models import AsyncModels
222
+
223
+ self.chat = AsyncChat(self)
224
+ self.models = AsyncModels(self)
225
+ self.me = AsyncMe(self)
226
+
227
+ async def _request(
228
+ self,
229
+ method: str,
230
+ path: str,
231
+ *,
232
+ body: Optional[dict[str, Any]] = None,
233
+ params: Optional[dict[str, Any]] = None,
234
+ headers: Optional[dict[str, str]] = None,
235
+ ) -> httpx.Response:
236
+ import anyio
237
+
238
+ request = self._httpx.build_request(
239
+ method, path, json=body, params=params, headers=headers,
240
+ )
241
+ retries_remaining = self.max_retries
242
+
243
+ while True:
244
+ try:
245
+ response = await self._httpx.send(request)
246
+ except httpx.TimeoutException as e:
247
+ if retries_remaining > 0:
248
+ retries_remaining -= 1
249
+ await anyio.sleep(self._calculate_retry_delay(
250
+ self.max_retries - retries_remaining - 1,
251
+ ))
252
+ continue
253
+ raise APIConnectionError(
254
+ message="Request timed out before a response was received.",
255
+ cause=e,
256
+ ) from e
257
+ except httpx.NetworkError as e:
258
+ if retries_remaining > 0:
259
+ retries_remaining -= 1
260
+ await anyio.sleep(self._calculate_retry_delay(
261
+ self.max_retries - retries_remaining - 1,
262
+ ))
263
+ continue
264
+ raise APIConnectionError(cause=e) from e
265
+ except httpx.TransportError as e:
266
+ raise APIConnectionError(cause=e) from e
267
+
268
+ if response.status_code >= 400:
269
+ try:
270
+ error_body = response.json()
271
+ except Exception:
272
+ text = response.text
273
+ error_body = text[:4096] if text else None
274
+
275
+ exc = self._make_error(
276
+ response.status_code, error_body, headers=response.headers,
277
+ )
278
+ if is_retryable(exc) and retries_remaining > 0:
279
+ retries_remaining -= 1
280
+ retry_after = (
281
+ exc.response_headers.get("Retry-After")
282
+ if exc.response_headers is not None
283
+ else None
284
+ )
285
+ await response.aclose()
286
+ await anyio.sleep(self._calculate_retry_delay(
287
+ self.max_retries - retries_remaining - 1,
288
+ retry_after,
289
+ ))
290
+ continue
291
+ raise exc
292
+
293
+ return response
294
+
295
+ async def _request_stream(
296
+ self,
297
+ method: str,
298
+ path: str,
299
+ *,
300
+ body: Optional[dict[str, Any]] = None,
301
+ params: Optional[dict[str, Any]] = None,
302
+ headers: Optional[dict[str, str]] = None,
303
+ ) -> httpx.Response:
304
+ import anyio
305
+
306
+ request = self._httpx.build_request(
307
+ method, path, json=body, params=params, headers=headers,
308
+ )
309
+ retries_remaining = self.max_retries
310
+
311
+ while True:
312
+ try:
313
+ response = await self._httpx.send(request, stream=True)
314
+ except httpx.TimeoutException as e:
315
+ if retries_remaining > 0:
316
+ retries_remaining -= 1
317
+ await anyio.sleep(self._calculate_retry_delay(
318
+ self.max_retries - retries_remaining - 1,
319
+ ))
320
+ continue
321
+ raise APIConnectionError(
322
+ message="Request timed out before a response was received.",
323
+ cause=e,
324
+ ) from e
325
+ except httpx.NetworkError as e:
326
+ if retries_remaining > 0:
327
+ retries_remaining -= 1
328
+ await anyio.sleep(self._calculate_retry_delay(
329
+ self.max_retries - retries_remaining - 1,
330
+ ))
331
+ continue
332
+ raise APIConnectionError(cause=e) from e
333
+ except httpx.TransportError as e:
334
+ raise APIConnectionError(cause=e) from e
335
+
336
+ if response.status_code >= 400:
337
+ await response.aread()
338
+ try:
339
+ error_body = response.json()
340
+ except Exception:
341
+ text = response.text
342
+ error_body = text[:4096] if text else None
343
+
344
+ exc = self._make_error(
345
+ response.status_code, error_body, headers=response.headers,
346
+ )
347
+ if is_retryable(exc) and retries_remaining > 0:
348
+ retries_remaining -= 1
349
+ retry_after = (
350
+ exc.response_headers.get("Retry-After")
351
+ if exc.response_headers is not None
352
+ else None
353
+ )
354
+ await response.aclose()
355
+ await anyio.sleep(self._calculate_retry_delay(
356
+ self.max_retries - retries_remaining - 1,
357
+ retry_after,
358
+ ))
359
+ continue
360
+ await response.aclose()
361
+ raise exc
362
+
363
+ return response
364
+
365
+ async def close(self) -> None:
366
+ await self._httpx.aclose()
367
+
368
+ async def __aenter__(self) -> AsyncClient:
369
+ return self
370
+
371
+ async def __aexit__(self, *args: object) -> None:
372
+ await self.close()
@@ -0,0 +1,196 @@
1
+ """AurikoAsyncOpenAI — AsyncOpenAI subclass with automatic routing metadata capture and error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ from collections.abc import AsyncIterator
10
+ from typing import Any, Callable
11
+
12
+ import httpx
13
+ import openai
14
+ from openai import AsyncOpenAI, DefaultAsyncHttpxClient
15
+
16
+ from .errors.openai_bridge import make_bridge_error
17
+ from .errors.types import AuthenticationError
18
+ from .route_types import RoutingMetadata
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _resolve_api_key(api_key: str | None) -> str:
24
+ """Resolve API key: explicit arg > AURIKO_API_KEY env > raise.
25
+
26
+ Does NOT fall back to OPENAI_API_KEY — consistent with
27
+ _base_client.py:43-53 and SDK_STRATEGY.md.
28
+ """
29
+ resolved = api_key or os.environ.get("AURIKO_API_KEY")
30
+ if not resolved:
31
+ raise AuthenticationError(
32
+ message="API key is missing. Set AURIKO_API_KEY or pass api_key=.",
33
+ status_code=0,
34
+ type="authentication_error",
35
+ code="missing_api_key",
36
+ )
37
+ return resolved
38
+
39
+
40
+ class _MetadataCapturingStream(httpx.AsyncByteStream):
41
+ """Wraps an SSE byte stream to capture routing_metadata during iteration.
42
+
43
+ Yields every chunk immediately (zero buffering, no TTFT impact).
44
+ Scans accumulated buffer for the metadata event on natural completion
45
+ or on aclose() (which fires first when the OpenAI SDK breaks on [DONE]).
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ inner: httpx.AsyncByteStream,
51
+ on_metadata: Callable[[RoutingMetadata], None],
52
+ ) -> None:
53
+ self._inner = inner
54
+ self._on_metadata = on_metadata
55
+ self._buffer = bytearray()
56
+ self._extracted = False
57
+
58
+ async def __aiter__(self) -> AsyncIterator[bytes]:
59
+ async for chunk in self._inner:
60
+ self._buffer.extend(chunk)
61
+ yield chunk
62
+ self._extract_metadata()
63
+
64
+ async def aclose(self) -> None:
65
+ self._extract_metadata()
66
+ await self._inner.aclose()
67
+
68
+ def _extract_metadata(self) -> None:
69
+ """Scan buffer for the last data: event before [DONE], extract routing_metadata."""
70
+ if self._extracted:
71
+ return
72
+ self._extracted = True
73
+ try:
74
+ text = self._buffer.decode("utf-8", errors="replace")
75
+ last_data: str | None = None
76
+ for line in text.splitlines():
77
+ stripped = line.strip()
78
+ if stripped.startswith("data: ") and stripped != "data: [DONE]":
79
+ last_data = stripped[6:]
80
+ if last_data is None:
81
+ return
82
+ payload = json.loads(last_data)
83
+ raw_metadata = payload.get("routing_metadata")
84
+ if raw_metadata is None:
85
+ return
86
+ metadata = RoutingMetadata.model_validate(raw_metadata)
87
+ self._on_metadata(metadata)
88
+ except Exception:
89
+ logger.debug("Failed to extract routing_metadata from SSE stream", exc_info=True)
90
+
91
+
92
+ class AurikoAsyncOpenAI(AsyncOpenAI):
93
+ """**Experimental.** AsyncOpenAI subclass with automatic routing metadata capture and error mapping.
94
+
95
+ Most users should use ``auriko.AsyncClient`` (native SDK) or ``AsyncOpenAI(base_url=...)``
96
+ (plain OpenAI-compatible). Use ``AurikoAsyncOpenAI`` only when passing an OpenAI client to
97
+ a framework that discards routing metadata from responses.
98
+
99
+ Usage::
100
+
101
+ client = AurikoAsyncOpenAI(api_key="ak-...")
102
+ response = await client.chat.completions.create(
103
+ model="gpt-4o-mini",
104
+ messages=[{"role": "user", "content": "Hello"}],
105
+ )
106
+ print(client.last_routing_metadata)
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ *,
112
+ api_key: str | None = None,
113
+ base_url: str = "https://api.auriko.ai/v1",
114
+ on_response: Callable[[RoutingMetadata], Any] | None = None,
115
+ **kwargs: Any,
116
+ ) -> None:
117
+ if "http_client" in kwargs:
118
+ raise TypeError(
119
+ "AurikoAsyncOpenAI does not accept http_client. "
120
+ "Remove it or use AsyncOpenAI(base_url=...) directly."
121
+ )
122
+ if on_response is not None and asyncio.iscoroutinefunction(on_response):
123
+ raise TypeError("on_response must be a sync callable, not async")
124
+
125
+ resolved_key = _resolve_api_key(api_key)
126
+
127
+ self._last_metadata: RoutingMetadata | None = None
128
+ self._on_response = on_response
129
+
130
+ async def _request_hook(request: httpx.Request) -> None:
131
+ self._last_metadata = None
132
+
133
+ def _deliver_metadata(metadata: RoutingMetadata) -> None:
134
+ self._last_metadata = metadata
135
+ if self._on_response is not None:
136
+ try:
137
+ self._on_response(metadata)
138
+ except Exception:
139
+ logger.debug("on_response callback raised", exc_info=True)
140
+
141
+ async def _response_hook(response: httpx.Response) -> None:
142
+ try:
143
+ if response.status_code >= 400:
144
+ return
145
+
146
+ content_type = response.headers.get("content-type", "")
147
+
148
+ if "text/event-stream" in content_type:
149
+ response.stream = _MetadataCapturingStream(
150
+ response.stream, _deliver_metadata,
151
+ )
152
+ else:
153
+ await response.aread()
154
+ body = response.json()
155
+ raw_metadata = body.get("routing_metadata")
156
+ if raw_metadata is not None:
157
+ metadata = RoutingMetadata.model_validate(raw_metadata)
158
+ _deliver_metadata(metadata)
159
+ except Exception:
160
+ logger.debug(
161
+ "Failed to capture routing_metadata from response",
162
+ exc_info=True,
163
+ )
164
+
165
+ http_client = DefaultAsyncHttpxClient(
166
+ event_hooks={
167
+ "request": [_request_hook],
168
+ "response": [_response_hook],
169
+ },
170
+ )
171
+
172
+ super().__init__(
173
+ api_key=resolved_key,
174
+ base_url=base_url,
175
+ http_client=http_client,
176
+ **kwargs,
177
+ )
178
+
179
+ def _make_status_error(
180
+ self,
181
+ err_msg: str,
182
+ *,
183
+ body: object,
184
+ response: httpx.Response,
185
+ ) -> openai.APIStatusError:
186
+ openai_error = super()._make_status_error(err_msg, body=body, response=response)
187
+ return make_bridge_error(openai_error, response=response, raw_body=body)
188
+
189
+ @property
190
+ def last_routing_metadata(self) -> RoutingMetadata | None:
191
+ """Routing metadata from the most recent successful response.
192
+
193
+ Returns None if no metadata is available (before any request, after an error,
194
+ or if the response did not contain routing_metadata).
195
+ """
196
+ return self._last_metadata