splx-proxy-sdk 1.0.9__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.
@@ -0,0 +1,58 @@
1
+ """
2
+ A SDK for building proxy servers for integration between user applications and SplxAI Platform.
3
+ """
4
+
5
+ from splx_proxy_sdk.error import (
6
+ BadRequestException,
7
+ ExceptionCode,
8
+ ForbiddenException,
9
+ InternalServerErrorException,
10
+ NotFoundException,
11
+ ProxyException,
12
+ ProxyExceptionConfig,
13
+ SessionClosedException,
14
+ TooManyRequestsException,
15
+ UnauthorizedException,
16
+ )
17
+ from splx_proxy_sdk.logging import LoggingConfig
18
+ from splx_proxy_sdk.model import (
19
+ CloseSessionRequest,
20
+ CloseSessionResponse,
21
+ MultiModal,
22
+ MultiModalAudio,
23
+ MultiModalImage,
24
+ MultiModalType,
25
+ OpenSessionRequest,
26
+ OpenSessionResponse,
27
+ SendMessageRequest,
28
+ SendMessageResponse,
29
+ )
30
+ from splx_proxy_sdk.server import Server
31
+ from splx_proxy_sdk.version import VERSION
32
+
33
+ __version__ = VERSION
34
+
35
+ __all__ = [
36
+ "ExceptionCode",
37
+ "ProxyException",
38
+ "UnauthorizedException",
39
+ "BadRequestException",
40
+ "NotFoundException",
41
+ "ForbiddenException",
42
+ "TooManyRequestsException",
43
+ "InternalServerErrorException",
44
+ "SessionClosedException",
45
+ "ProxyExceptionConfig",
46
+ "MultiModalType",
47
+ "MultiModal",
48
+ "MultiModalImage",
49
+ "MultiModalAudio",
50
+ "SendMessageRequest",
51
+ "SendMessageResponse",
52
+ "OpenSessionRequest",
53
+ "OpenSessionResponse",
54
+ "CloseSessionRequest",
55
+ "CloseSessionResponse",
56
+ "Server",
57
+ "LoggingConfig",
58
+ ]
splx_proxy_sdk/auth.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ Authentication middleware and configuration.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable
7
+
8
+ from fastapi import Security
9
+
10
+ from splx_proxy_sdk.error import ExceptionCode, UnauthorizedException
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ """Configuration for the authentication middleware."""
16
+
17
+ secret: str
18
+ """
19
+ The secret with which the value should be compared with.
20
+ """
21
+ security: Callable
22
+ """
23
+ The security function to use for extracting the secret from the request.
24
+ More details are in the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/security/).
25
+ """
26
+
27
+
28
+ def _auth_func(api_key: str, base: Callable):
29
+ async def _f(key: str = Security(base)):
30
+ if key != api_key:
31
+ raise UnauthorizedException("Invalid API key", ExceptionCode.UNAUTHORIZED)
32
+ return None
33
+
34
+ return _f
@@ -0,0 +1,279 @@
1
+ """Error definitions and error handling."""
2
+
3
+ import math
4
+ from dataclasses import dataclass, replace
5
+ from datetime import timedelta
6
+ from enum import Enum
7
+
8
+ from fastapi import Request
9
+ from pydantic import BaseModel
10
+ from starlette.exceptions import HTTPException
11
+ from starlette.responses import JSONResponse
12
+
13
+ SESSION_STATUS_HEADER = "X-Splx-Session-Status"
14
+ SESSION_STATUS_OPEN = "open"
15
+ SESSION_STATUS_CLOSED = "closed"
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class ProxyExceptionConfig:
20
+ """Structured exception config used when building proxy exceptions."""
21
+
22
+ session_closed: bool | None = None
23
+ retry_after: timedelta | None = None
24
+ headers: dict[str, str] | None = None
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.headers is not None:
28
+ # Copy to avoid mutating caller-provided dictionaries.
29
+ self.headers = dict(self.headers)
30
+
31
+ def to_headers(self) -> dict[str, str]:
32
+ """Build the headers that should accompany the error response."""
33
+ headers: dict[str, str] = dict(self.headers or {})
34
+
35
+ if self.session_closed is not None:
36
+ headers[SESSION_STATUS_HEADER] = (
37
+ SESSION_STATUS_CLOSED if self.session_closed else SESSION_STATUS_OPEN
38
+ )
39
+
40
+ if self.retry_after is not None:
41
+ headers["Retry-After"] = str(math.ceil(self.retry_after.total_seconds()))
42
+
43
+ return headers
44
+
45
+
46
+ class ProxyHTTPStatusCode(int, Enum):
47
+ """
48
+ HTTP status codes used in the proxy.
49
+ """
50
+
51
+ SESSION_CLOSED = 452
52
+
53
+
54
+ class ExceptionCode(str, Enum):
55
+ """
56
+ Exception codes that can be generated from the proxy.
57
+ These are used so the SplxAI Platform can do error handling for some
58
+ common use cases.
59
+ """
60
+
61
+ UNKNOWN = "UNKNOWN"
62
+
63
+ BAD_REQUEST = "BAD_REQUEST"
64
+ FORBIDDEN = "FORBIDDEN"
65
+ NOT_FOUND = "NOT_FOUND"
66
+ TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS"
67
+ UNAUTHORIZED = "UNAUTHORIZED"
68
+ INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
69
+ SESSION_CLOSED = "SESSION_CLOSED"
70
+
71
+
72
+ class ProxyException(HTTPException):
73
+ """
74
+ A custom exception implementation that includes the `ExceptionCode`.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ status_code: int,
80
+ details: str,
81
+ code: ExceptionCode,
82
+ *,
83
+ config: ProxyExceptionConfig | None = None,
84
+ ):
85
+ """
86
+ Args:
87
+ status_code: the status code that should be returned in the result
88
+ details: details about the exception
89
+ code: the `ExceptionCode` of the error
90
+ response: metadata describing proxy-specific response headers
91
+ """
92
+ super().__init__(status_code, details)
93
+ self.code = code
94
+ self._config = config or ProxyExceptionConfig()
95
+
96
+ def get_headers(self) -> dict[str, str] | None:
97
+ """
98
+ Function returning Probe supported headers formatted as dictionary
99
+ """
100
+ headers = self._config.to_headers()
101
+ return headers or None
102
+
103
+
104
+ class BadRequestException(ProxyException):
105
+ """
106
+ Specialized instance of the `ProxyException` that returns `Bad Request`.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ details: str,
112
+ code: ExceptionCode = ExceptionCode.BAD_REQUEST,
113
+ *,
114
+ config: ProxyExceptionConfig | None = None,
115
+ ):
116
+ super().__init__(
117
+ 400,
118
+ details,
119
+ code,
120
+ config=config,
121
+ )
122
+
123
+
124
+ class UnauthorizedException(ProxyException):
125
+ """
126
+ Specialized instance of the `ProxyException` that returns `Unauthorized`.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ details: str,
132
+ code: ExceptionCode = ExceptionCode.UNAUTHORIZED,
133
+ *,
134
+ config: ProxyExceptionConfig | None = None,
135
+ ):
136
+ super().__init__(
137
+ 401,
138
+ details,
139
+ code,
140
+ config=config,
141
+ )
142
+
143
+
144
+ class ForbiddenException(ProxyException):
145
+ """
146
+ Specialized instance of the `ProxyException` that returns `Forbidden`.
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ details: str,
152
+ code: ExceptionCode = ExceptionCode.FORBIDDEN,
153
+ *,
154
+ config: ProxyExceptionConfig | None = None,
155
+ ):
156
+ super().__init__(
157
+ 403,
158
+ details,
159
+ code,
160
+ config=config,
161
+ )
162
+
163
+
164
+ class NotFoundException(ProxyException):
165
+ """
166
+ Specialized instance of the `ProxyException` that returns `Not Found`.
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ details: str,
172
+ code: ExceptionCode = ExceptionCode.NOT_FOUND,
173
+ *,
174
+ config: ProxyExceptionConfig | None = None,
175
+ ):
176
+ super().__init__(
177
+ 404,
178
+ details,
179
+ code,
180
+ config=config,
181
+ )
182
+
183
+
184
+ class SessionClosedException(ProxyException):
185
+ """
186
+ Specialized instance of the `ProxyException` that informs the Probe that the session is closed.
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ details: str,
192
+ code: ExceptionCode = ExceptionCode.SESSION_CLOSED,
193
+ *,
194
+ config: ProxyExceptionConfig | None = None,
195
+ ):
196
+ config = (
197
+ replace(config, session_closed=True)
198
+ if config
199
+ else ProxyExceptionConfig(session_closed=True)
200
+ )
201
+ super().__init__(
202
+ ProxyHTTPStatusCode.SESSION_CLOSED,
203
+ details,
204
+ code,
205
+ config=config,
206
+ )
207
+
208
+
209
+ class TooManyRequestsException(ProxyException):
210
+ """
211
+ Specialized instance of the `ProxyException` that returns `Too Many Requests`.
212
+ """
213
+
214
+ def __init__(
215
+ self,
216
+ details: str,
217
+ code: ExceptionCode = ExceptionCode.TOO_MANY_REQUESTS,
218
+ *,
219
+ config: ProxyExceptionConfig | None = None,
220
+ ):
221
+ """
222
+ Args:
223
+ details: details about the exception
224
+ code: the `ExceptionCode` of the error (default is TOO_MANY_REQUESTS)
225
+ config: optional exception config controlling headers returned
226
+ """
227
+ super().__init__(
228
+ 429,
229
+ details,
230
+ code,
231
+ config=config,
232
+ )
233
+
234
+
235
+ class InternalServerErrorException(ProxyException):
236
+ """
237
+ Specialized instance of the `ProxyException` that returns `Internal Server Error`.
238
+ """
239
+
240
+ def __init__(
241
+ self,
242
+ details: str,
243
+ code: ExceptionCode = ExceptionCode.INTERNAL_SERVER_ERROR,
244
+ *,
245
+ config: ProxyExceptionConfig | None = None,
246
+ ):
247
+ super().__init__(
248
+ 500,
249
+ details,
250
+ code,
251
+ config=config,
252
+ )
253
+
254
+
255
+ class ExceptionResult(BaseModel):
256
+ """
257
+ The resulting model for the exception response.
258
+
259
+ Args:
260
+ message: the message that should be returned in the result
261
+ code: the `ExceptionCode` of the error
262
+ """
263
+
264
+ message: str
265
+ code: ExceptionCode
266
+
267
+
268
+ def _exception_handler(_: Request, exc: HTTPException):
269
+ code = ExceptionCode.UNKNOWN
270
+ headers = exc.headers
271
+ if isinstance(exc, ProxyException):
272
+ code = exc.code
273
+ headers = exc.get_headers()
274
+
275
+ return JSONResponse(
276
+ status_code=exc.status_code,
277
+ content=ExceptionResult(message=exc.detail, code=code).model_dump(),
278
+ headers=headers,
279
+ )
@@ -0,0 +1,345 @@
1
+ """
2
+ Logging utilities for the SplxAI proxy.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Any, AsyncIterator, Iterable, Mapping
15
+
16
+ from fastapi import Request
17
+ from loguru import logger
18
+ from starlette.middleware.base import BaseHTTPMiddleware
19
+ from starlette.responses import Response
20
+ from starlette.types import ASGIApp
21
+
22
+ DEFAULT_REDACTED = "***REDACTED***"
23
+
24
+
25
+ def _utc_now_iso() -> str:
26
+ """
27
+ Return the current UTC time in ISO-8601 format with a trailing Z for readability.
28
+ """
29
+
30
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def _parse_bool(value: str | None, default: bool) -> bool:
34
+ if value is None:
35
+ return default
36
+ value_lower = value.strip().lower()
37
+ if value_lower in {"1", "true", "t", "yes", "y", "on"}:
38
+ return True
39
+ if value_lower in {"0", "false", "f", "no", "n", "off"}:
40
+ return False
41
+ return default
42
+
43
+
44
+ def _parse_csv(value: str | None, *, default: Iterable[str]) -> tuple[str, ...]:
45
+ if not value:
46
+ return tuple(default)
47
+ return tuple(part.strip() for part in value.split(",") if part.strip())
48
+
49
+
50
+ @dataclass
51
+ class LoggingConfig:
52
+ """
53
+ Configuration options for the structured logging middleware.
54
+ """
55
+
56
+ enabled: bool = True
57
+ log_request_body: bool = True
58
+ log_response_body: bool = True
59
+ max_body_length: int = 4096
60
+ redact_headers: tuple[str, ...] = field(default_factory=lambda: ("x-api-key",))
61
+ redact_fields: tuple[str, ...] = field(
62
+ default_factory=lambda: (
63
+ "api_key",
64
+ "access_token",
65
+ "refresh_token",
66
+ "token",
67
+ "secret",
68
+ "password",
69
+ "authorization",
70
+ )
71
+ )
72
+
73
+ @classmethod
74
+ def from_env(cls) -> "LoggingConfig":
75
+ """
76
+ Build a logging configuration instance from environment variables.
77
+ """
78
+
79
+ enabled = _parse_bool(os.getenv("SPLX_PROXY_LOG_ENABLED"), default=True)
80
+ log_request_body = _parse_bool(
81
+ os.getenv("SPLX_PROXY_LOG_REQUEST_BODY"), default=True
82
+ )
83
+ log_response_body = _parse_bool(
84
+ os.getenv("SPLX_PROXY_LOG_RESPONSE_BODY"), default=True
85
+ )
86
+
87
+ max_body_length_raw = os.getenv("SPLX_PROXY_LOG_MAX_BODY_LENGTH")
88
+ max_body_length = 4096
89
+ if max_body_length_raw:
90
+ try:
91
+ max_body_length = max(0, int(max_body_length_raw))
92
+ except ValueError:
93
+ max_body_length = 4096
94
+
95
+ redact_headers = _parse_csv(
96
+ os.getenv("SPLX_PROXY_LOG_REDACT_HEADERS"),
97
+ default=cls().redact_headers,
98
+ )
99
+ redact_fields = _parse_csv(
100
+ os.getenv("SPLX_PROXY_LOG_REDACT_FIELDS"),
101
+ default=cls().redact_fields,
102
+ )
103
+
104
+ return cls(
105
+ enabled=enabled,
106
+ log_request_body=log_request_body,
107
+ log_response_body=log_response_body,
108
+ max_body_length=max_body_length,
109
+ redact_headers=tuple(header.lower() for header in redact_headers),
110
+ redact_fields=tuple(field.lower() for field in redact_fields),
111
+ )
112
+
113
+ def __post_init__(self) -> None:
114
+ self.redact_headers = tuple(header.lower() for header in self.redact_headers)
115
+ self.redact_fields = tuple(field.lower() for field in self.redact_fields)
116
+
117
+
118
+ # pylint: disable=too-few-public-methods
119
+ class RequestLoggingMiddleware(BaseHTTPMiddleware):
120
+ """
121
+ FastAPI middleware emitting structured request/response logs via Loguru.
122
+ """
123
+
124
+ def __init__(self, app: ASGIApp, config: LoggingConfig):
125
+ super().__init__(app)
126
+ logger.remove()
127
+ logger.add(
128
+ sink=sys.stdout,
129
+ serialize=True,
130
+ backtrace=False,
131
+ diagnose=False,
132
+ enqueue=True,
133
+ )
134
+
135
+ self._context_logger = logger.opt(record=False, raw=True)
136
+
137
+ self._config = config
138
+
139
+ async def dispatch(self, request: Request, call_next):
140
+ if not self._config.enabled:
141
+ return await call_next(request)
142
+
143
+ started_at_iso = _utc_now_iso()
144
+ request_id = str(uuid.uuid4())
145
+ start_time = time.perf_counter()
146
+
147
+ client_host = request.client.host if request.client else None
148
+ client_port = request.client.port if request.client else None
149
+
150
+ client_info: dict[str, int | str] | None = None
151
+ if client_host or client_port:
152
+ client_info = {}
153
+ if client_host:
154
+ client_info["ip"] = client_host
155
+ if client_port:
156
+ client_info["port"] = client_port
157
+
158
+ log_context: dict[str, Any] = {
159
+ "request_id": request_id,
160
+ "started_at": started_at_iso,
161
+ "start_time": start_time,
162
+ "client_info": client_info,
163
+ }
164
+
165
+ request_payload, _ = await self._create_request_payload(request, log_context)
166
+
167
+ self._context_logger.info("Request logged.", payload=request_payload)
168
+
169
+ response = await call_next(request) # type: ignore
170
+
171
+ response_payload, response_body_bytes = await self._create_response_payload(
172
+ request,
173
+ response,
174
+ log_context,
175
+ )
176
+
177
+ self._context_logger.info("Response logged.", payload=response_payload)
178
+
179
+ if response_body_bytes is not None:
180
+ return Response(
181
+ content=response_body_bytes, # type: ignore[attr-defined]
182
+ status_code=response.status_code,
183
+ headers=dict(response.headers),
184
+ media_type=response.media_type,
185
+ )
186
+
187
+ return response
188
+
189
+ async def _create_request_payload(
190
+ self,
191
+ request: Request,
192
+ context: Mapping[str, Any],
193
+ ) -> tuple[dict[str, Any], bytes | None]:
194
+ request_id: str = context["request_id"]
195
+ started_at_iso = context.get("started_at") or _utc_now_iso()
196
+ client_info = context.get("client_info")
197
+ sanitized_headers = self._sanitize_headers(request.headers)
198
+ request_content_length: int | str | None = None
199
+ request_content_length_raw = request.headers.get("content-length")
200
+ if request_content_length_raw is not None:
201
+ try:
202
+ request_content_length = int(request_content_length_raw)
203
+ except ValueError:
204
+ request_content_length = request_content_length_raw
205
+
206
+ request_body_bytes: bytes | None = None
207
+ request_body = None
208
+ request_body_length = None
209
+ if self._config.log_request_body:
210
+ request_body_bytes = await request.body()
211
+ request_body_length = len(request_body_bytes)
212
+ request_body = self._format_body(
213
+ request_body_bytes, request.headers.get("content-type")
214
+ )
215
+
216
+ request_payload: dict[str, Any] = {
217
+ "event": "request",
218
+ "request_id": request_id,
219
+ "method": request.method,
220
+ "url": str(request.url),
221
+ "scheme": request.url.scheme,
222
+ "host": request.url.hostname,
223
+ "path": request.url.path,
224
+ "query": request.url.query or None,
225
+ "http_version": request.scope.get("http_version"),
226
+ "headers": sanitized_headers,
227
+ "started_at": started_at_iso,
228
+ }
229
+ if client_info:
230
+ request_payload["client"] = client_info
231
+ if request_content_length is not None:
232
+ request_payload["content_length"] = request_content_length
233
+ if request_body_length is not None:
234
+ request_payload["body_length"] = request_body_length
235
+ if request_body is not None:
236
+ request_payload["body"] = request_body
237
+
238
+ return request_payload, request_body_bytes
239
+
240
+ async def _create_response_payload(
241
+ self,
242
+ request: Request,
243
+ response: Response,
244
+ context: Mapping[str, Any],
245
+ ) -> tuple[dict[str, Any], bytes | None]:
246
+ start_time = context.get("start_time", time.perf_counter())
247
+ client_info = context.get("client_info")
248
+ response_body = None
249
+ response_body_bytes: bytes | None = None
250
+
251
+ if self._config.log_response_body and hasattr(response, "body_iterator"):
252
+ response_body_bytes = await self._read_bytes(response.body_iterator) # type: ignore
253
+ response_body = self._format_body(
254
+ response_body_bytes, response.headers.get("content-type")
255
+ )
256
+
257
+ completed_at_iso = _utc_now_iso()
258
+ duration_ms = (time.perf_counter() - start_time) * 1000
259
+ response_body_length = len(response_body_bytes) if response_body_bytes else None
260
+ response_content_length: int | str | None = None
261
+ response_content_length_raw = response.headers.get("content-length")
262
+ if response_content_length_raw is not None:
263
+ try:
264
+ response_content_length = int(response_content_length_raw)
265
+ except ValueError:
266
+ response_content_length = response_content_length_raw
267
+ response_headers = self._sanitize_headers(response.headers)
268
+
269
+ response_payload: dict[str, Any] = {
270
+ "event": "response",
271
+ "request_id": context["request_id"],
272
+ "method": request.method,
273
+ "url": str(request.url),
274
+ "path": request.url.path,
275
+ "status_code": response.status_code,
276
+ "duration_ms": round(duration_ms, 3),
277
+ "completed_at": completed_at_iso,
278
+ "headers": response_headers,
279
+ }
280
+ if client_info:
281
+ response_payload["client"] = client_info
282
+ if response_content_length is not None:
283
+ response_payload["content_length"] = response_content_length
284
+ if response_body_length is not None:
285
+ response_payload["body_length"] = response_body_length
286
+ if response_body is not None:
287
+ response_payload["body"] = response_body
288
+
289
+ return response_payload, response_body_bytes
290
+
291
+ def _sanitize_headers(self, headers: Mapping[str, str]) -> dict[str, str]:
292
+ sanitized: dict[str, str] = {}
293
+ for key, value in headers.items():
294
+ key_lower = key.lower()
295
+ if key_lower in self._config.redact_headers:
296
+ sanitized[key] = DEFAULT_REDACTED
297
+ else:
298
+ sanitized[key] = value
299
+ return sanitized
300
+
301
+ def _format_body(self, body: bytes, content_type: str | None) -> Any:
302
+ if not body:
303
+ return None
304
+
305
+ length = len(body)
306
+ if self._config.max_body_length and length > self._config.max_body_length:
307
+ return {"length": length, "truncated": True}
308
+
309
+ if content_type and "json" in content_type.lower():
310
+ try:
311
+ parsed = json.loads(body)
312
+ except (json.JSONDecodeError, UnicodeDecodeError):
313
+ pass
314
+ else:
315
+ return self._sanitize_data(parsed)
316
+
317
+ decoded = body.decode("utf-8", errors="replace")
318
+ if self._config.max_body_length and len(decoded) > self._config.max_body_length:
319
+ return {
320
+ "length": len(decoded),
321
+ "truncated": True,
322
+ }
323
+ return decoded
324
+
325
+ def _sanitize_data(self, data: Any) -> Any:
326
+ if isinstance(data, dict):
327
+ return {
328
+ key: (
329
+ DEFAULT_REDACTED
330
+ if key.lower() in self._config.redact_fields
331
+ else self._sanitize_data(value)
332
+ )
333
+ for key, value in data.items()
334
+ }
335
+ if isinstance(data, list):
336
+ return [self._sanitize_data(value) for value in data]
337
+ if isinstance(data, tuple):
338
+ return tuple(self._sanitize_data(value) for value in data)
339
+ return data
340
+
341
+ async def _read_bytes(self, generator: AsyncIterator[bytes]) -> bytes:
342
+ body = b""
343
+ async for data in generator:
344
+ body += data
345
+ return body
@@ -0,0 +1,127 @@
1
+ """
2
+ Model definitions.
3
+ """
4
+
5
+ from enum import Enum
6
+ from typing import Dict, Generic, Optional, TypeVar, Union
7
+
8
+ from pydantic import BaseModel
9
+
10
+ ExtraArgsT = TypeVar("ExtraArgsT")
11
+
12
+ # Base64 encoded image or audio
13
+ MultiModalImage = str
14
+ MultiModalAudio = str
15
+
16
+
17
+ class MultiModalType(str, Enum):
18
+ """
19
+ The type of multimodal content.
20
+
21
+ Supported types:
22
+ - `image`
23
+ - `audio`
24
+
25
+ """
26
+
27
+ IMAGE = "image"
28
+ AUDIO = "audio"
29
+
30
+
31
+ class MultiModal(BaseModel):
32
+ """
33
+ A multimodal content value.
34
+
35
+ Args:
36
+ type: the type of multimodal content
37
+ content: the encoded multimodal content
38
+ """
39
+
40
+ type: MultiModalType
41
+ content: Union[MultiModalImage, MultiModalAudio]
42
+
43
+
44
+ class OpenSessionRequest(BaseModel, Generic[ExtraArgsT]):
45
+ """
46
+ The request to open a new session.
47
+
48
+ Args:
49
+ session_id: the session id to use.
50
+ If not provided, a new session should be generated from the server.
51
+ extra_args: any extra static attributes or data you want to
52
+ include in the request. Default is None.
53
+ """
54
+
55
+ session_id: Optional[str] = None
56
+
57
+ extra_args: Optional[ExtraArgsT] = None
58
+
59
+
60
+ class OpenSessionResponse(BaseModel):
61
+ """
62
+ The response of opening a new session.
63
+
64
+ Args:
65
+ session_id: the session id that should be used for the session
66
+ """
67
+
68
+ session_id: str
69
+
70
+
71
+ class SendMessageRequest(BaseModel, Generic[ExtraArgsT]):
72
+ """
73
+ The request to send a message to the session.
74
+
75
+ Args:
76
+ session_id: the session id to use
77
+ message: the message to send
78
+ multimodal: the multimodal content to send.
79
+ The key of the dictionary is the name of the multimodal content,
80
+ and the value is the `MultiModal` message.
81
+ extra_args: any extra static attributes or data you want to
82
+ include in the request. Default is None.
83
+ """
84
+
85
+ session_id: str
86
+ message: str
87
+ multimodal: Optional[Dict[str, MultiModal]] = None
88
+
89
+ extra_args: Optional[ExtraArgsT] = None
90
+
91
+
92
+ class SendMessageResponse(BaseModel):
93
+ """
94
+ The response of sending a message to the session.
95
+
96
+ Args:
97
+ session_id: the session id that is used for the session
98
+ message: the message received
99
+ multimodal: the multimodal content received.
100
+ The key of the dictionary is the name of the multimodal content,
101
+ and the value is the `MultiModal` message.
102
+ """
103
+
104
+ session_id: str
105
+ message: str
106
+ multimodal: Optional[Dict[str, MultiModal]] = None
107
+
108
+
109
+ class CloseSessionRequest(BaseModel, Generic[ExtraArgsT]):
110
+ """
111
+ The request to close a session.
112
+
113
+ Args:
114
+ session_id: the session id to close
115
+ extra_args: any extra static attributes or data you want to
116
+ include in the request. Default is None.
117
+ """
118
+
119
+ session_id: str
120
+
121
+ extra_args: Optional[ExtraArgsT] = None
122
+
123
+
124
+ class CloseSessionResponse(BaseModel):
125
+ """
126
+ The response of closing a session.
127
+ """
@@ -0,0 +1,165 @@
1
+ """
2
+ Server definition that the user should extend.
3
+ """
4
+
5
+ import os
6
+ from abc import ABC, abstractmethod
7
+ from typing import Mapping
8
+
9
+ from fastapi import APIRouter, Depends, FastAPI, Request
10
+ from fastapi.security.api_key import APIKeyHeader
11
+ from starlette.exceptions import HTTPException
12
+
13
+ from splx_proxy_sdk.auth import _auth_func
14
+ from splx_proxy_sdk.error import _exception_handler
15
+ from splx_proxy_sdk.logging import LoggingConfig, RequestLoggingMiddleware
16
+ from splx_proxy_sdk.model import (
17
+ CloseSessionRequest,
18
+ CloseSessionResponse,
19
+ OpenSessionRequest,
20
+ OpenSessionResponse,
21
+ SendMessageRequest,
22
+ SendMessageResponse,
23
+ )
24
+
25
+ API_KEY_ENV_VAR = "SPLX_PROXY_API_KEY"
26
+ API_KEY_HEADER_NAME = "x-api-key"
27
+
28
+
29
+ class Server(FastAPI, ABC):
30
+ """
31
+ The server that handles the proxy requests.
32
+ This class should be extended to implement the actual server.
33
+ It also supports all `FastAPI` features.
34
+
35
+ **Example:**
36
+ ```python
37
+ from proxy import Server
38
+ from proxy import (
39
+ OpenSessionRequest,
40
+ OpenSessionResponse,
41
+ CloseSessionRequest,
42
+ CloseSessionResponse,
43
+ SendMessageRequest,
44
+ SendMessageResponse
45
+ )
46
+
47
+ class MyServer(Server):
48
+ def __init__(self, **kwargs):
49
+ super().__init__(**kwargs)
50
+ # some other initialization code
51
+
52
+ async def open_session(
53
+ self, request: OpenSessionRequest, raw: Request
54
+ ) -> OpenSessionResponse:
55
+ return OpenSessionResponse(session_id="my-session-id")
56
+ async def close_session(
57
+ self, request: CloseSessionRequest, raw: Request
58
+ ) -> CloseSessionResponse:
59
+ return CloseSessionResponse()
60
+ async def send_message(
61
+ self, request: SendMessageRequest, raw: Request
62
+ ) -> SendMessageResponse:
63
+ return SendMessageResponse(session_id="my-session-id", message="Hello, world!")
64
+ ```
65
+ You can find more examples in the
66
+ [examples](https://github.com/splx-ai/proxy/tree/main/examples) directory.
67
+ """
68
+
69
+ def __init__(self, logging_config=None, **kwargs):
70
+ """
71
+ The init method of the server.
72
+ It uses the `FastAPI` constructor and adds the routes for the proxy requests.
73
+ Additionally, it adds the authentication middleware with the API key loaded
74
+ from the `SPLX_PROXY_API_KEY` environment variable.
75
+
76
+ Args:
77
+ logging_config: The logging configuration or mapping.
78
+ **kwargs: Additional keyword arguments for the FastAPI constructor.
79
+ """
80
+
81
+ if logging_config is None:
82
+ logging_config = LoggingConfig.from_env()
83
+ elif isinstance(logging_config, Mapping):
84
+ logging_config = LoggingConfig(**logging_config)
85
+ elif not isinstance(logging_config, LoggingConfig):
86
+ raise TypeError("logging_config must be a LoggingConfig or mapping")
87
+
88
+ api_key = os.getenv(API_KEY_ENV_VAR)
89
+ if not api_key:
90
+ raise RuntimeError(
91
+ (
92
+ "SPLX_PROXY_API_KEY environment variable ",
93
+ "must be set before starting the proxy server.",
94
+ )
95
+ )
96
+
97
+ super().__init__(**kwargs)
98
+ if logging_config.enabled:
99
+ self.add_middleware(RequestLoggingMiddleware, config=logging_config)
100
+
101
+ security = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=True)
102
+ dependencies = [Depends(_auth_func(api_key, security))]
103
+
104
+ router = APIRouter(dependencies=dependencies)
105
+
106
+ router.add_api_route(
107
+ "/open-session",
108
+ self.open_session,
109
+ methods=["POST"],
110
+ )
111
+ router.add_api_route(
112
+ "/close-session",
113
+ self.close_session,
114
+ methods=["POST"],
115
+ )
116
+ router.add_api_route(
117
+ "/send-message",
118
+ self.send_message,
119
+ methods=["POST"],
120
+ )
121
+
122
+ self.include_router(router)
123
+ self.exception_handler(HTTPException)(_exception_handler)
124
+
125
+ @abstractmethod
126
+ async def open_session(
127
+ self, request: OpenSessionRequest, raw: Request
128
+ ) -> OpenSessionResponse:
129
+ """
130
+ The method that handles the `/open-session` request.
131
+ It should return an `OpenSessionResponse` object with the session ID.
132
+
133
+ Args:
134
+ request: The request object.
135
+ raw: The raw request object so you can access the headers, query parameters, etc.
136
+ """
137
+ raise NotImplementedError
138
+
139
+ @abstractmethod
140
+ async def close_session(
141
+ self, request: CloseSessionRequest, raw: Request
142
+ ) -> CloseSessionResponse:
143
+ """
144
+ The method that handles the `/close-session` request.
145
+ It should return a `CloseSessionResponse` object.
146
+
147
+ Args:
148
+ request: The request object.
149
+ raw: The raw request object so you can access the headers, query parameters, etc.
150
+ """
151
+ raise NotImplementedError
152
+
153
+ @abstractmethod
154
+ async def send_message(
155
+ self, request: SendMessageRequest, raw: Request
156
+ ) -> SendMessageResponse:
157
+ """
158
+ The method that handles the `/send-message` request.
159
+ It should return a `SendMessageResponse` object with the session ID and the message.
160
+
161
+ Args:
162
+ request: The request object.
163
+ raw: The raw request object so you can access the headers, query parameters, etc.
164
+ """
165
+ raise NotImplementedError
@@ -0,0 +1,8 @@
1
+ """
2
+ The version of the SplxAI Proxy package.
3
+ """
4
+
5
+ __all__ = ["VERSION"]
6
+
7
+ VERSION = "1.0.9"
8
+ """The version of the SplxAI Proxy package."""
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 SplxAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,283 @@
1
+ Metadata-Version: 2.1
2
+ Name: splx-proxy-sdk
3
+ Version: 1.0.9
4
+ Summary: SDK for building proxy servers for integration between user applications and the SPLX Platform
5
+ License: MIT
6
+ Author: Luka Simac
7
+ Author-email: luka.simac@splx.ai
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: fastapi (>=0.116.1,<0.117.0)
15
+ Requires-Dist: loguru (>=0.7.2,<0.8.0)
16
+ Requires-Dist: pydantic (>=2.11.7,<3.0.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # SPLX AI Proxy
20
+
21
+ The SPLX AI Proxy is a lightweight and easy-to-use interface for creating and managing API proxies for integration with the SPLX Platform.
22
+
23
+ ## Getting Started
24
+
25
+ 1. **Configure package source**
26
+
27
+ ```bash
28
+ # poetry
29
+ poetry config repositories.splxai https://splxai-851725337651.d.codeartifact.eu-central-1.amazonaws.com/pypi/pypi/
30
+
31
+ # pip
32
+ pip config set global.extra-index-url https://splxai-851725337651.d.codeartifact.eu-central-1.amazonaws.com/pypi/pypi/
33
+ pip config set global.trusted-host splxai-851725337651.d.codeartifact.eu-central-1.amazonaws.com
34
+ ```
35
+
36
+ Alternatively, add the repository directly in `pyproject.toml`:
37
+
38
+ ```toml
39
+ [[tool.poetry.source]]
40
+ name = "splxai"
41
+ url = "https://splxai-851725337651.d.codeartifact.eu-central-1.amazonaws.com/pypi/pypi/simple"
42
+ priority = "secondary"
43
+ ```
44
+
45
+ 2. **Install the SDK**
46
+
47
+ ```bash
48
+ # poetry
49
+ poetry add splxai-proxy
50
+
51
+ # pip
52
+ pip install splxai-proxy
53
+ ```
54
+
55
+ 3. **Implement your proxy server**
56
+
57
+ ```python
58
+ from fastapi import Request
59
+ from pydantic import BaseModel
60
+
61
+ from splx_proxy_sdk import (
62
+ CloseSessionRequest,
63
+ CloseSessionResponse,
64
+ OpenSessionRequest,
65
+ OpenSessionResponse,
66
+ SendMessageRequest,
67
+ SendMessageResponse,
68
+ Server,
69
+ )
70
+
71
+
72
+ class ExtraArgs(BaseModel):
73
+ tone: str
74
+ token_count: int
75
+
76
+
77
+ class SimpleServer(Server):
78
+ async def open_session(
79
+ self, request: OpenSessionRequest, raw: Request
80
+ ) -> OpenSessionResponse:
81
+ raise NotImplementedError("Implement the open_session method")
82
+
83
+ async def close_session(
84
+ self, request: CloseSessionRequest, raw: Request
85
+ ) -> CloseSessionResponse:
86
+ raise NotImplementedError("Implement the close_session method")
87
+
88
+ async def send_message(
89
+ self, request: SendMessageRequest[ExtraArgs], raw: Request
90
+ ) -> SendMessageResponse:
91
+ if request.extra_args:
92
+ print("Response tone:", request.extra_args.tone)
93
+ print("Response token count:", request.extra_args.token_count)
94
+
95
+ return SendMessageResponse(
96
+ session_id="test", message="Hello, how may I help you?"
97
+ )
98
+
99
+
100
+ # Ensure SPLX_PROXY_API_KEY env. variable is set before instantiating the server
101
+ app = SimpleServer()
102
+ ```
103
+
104
+ 4. **Run the service**
105
+
106
+ ```bash
107
+ gunicorn -w 1 -k uvicorn.workers.UvicornWorker main:app --bind 0.0.0.0:8000
108
+ ```
109
+
110
+ The SPLX Platform must be able to reach the host and port you expose.
111
+
112
+ See [examples/simple_server](examples/simple_server/main.py) for a sample project, including `.env` placeholders for SPLX credentials.
113
+
114
+ ## Features
115
+
116
+ ### Authentication
117
+
118
+ Proxy SDK requires authentication using the `x-api-key` header. To set the secret, you need to set the `SPLX_PROXY_API_KEY` environment variable.
119
+
120
+ ### Extra arguments
121
+
122
+ If you want to use some extra arguments in any of the endpoints, you can parametrize any of the Request classes (OpenSessionRequest, SendMessageRequest, CloseSessionRequest). To do that, define your own class and use it as in the [example](examples/simple_server/main.py) code. This approach provides simple customization together with type safety.
123
+
124
+ ### Logging
125
+
126
+ By default, all requests and responses are logged in JSON format.
127
+
128
+ ```json
129
+ {
130
+ "text": "Request logged.",
131
+ "record": {
132
+ "elapsed": { "repr": "0:00:01.259405", "seconds": 1.259405 },
133
+ "exception": null,
134
+ "extra": {
135
+ "payload": {
136
+ "event": "request",
137
+ "request_id": "1f8c1f39-e2c3-471e-b567-d69b2d58337f",
138
+ "method": "POST",
139
+ "url": "http://127.0.0.1:8000/send-message",
140
+ "scheme": "http",
141
+ "host": "127.0.0.1",
142
+ "path": "/send-message",
143
+ "query": null,
144
+ "http_version": "1.1",
145
+ "headers": {
146
+ "host": "127.0.0.1:8000",
147
+ "connection": "keep-alive",
148
+ "content-length": "342",
149
+ "sec-ch-ua-platform": "\"macOS\"",
150
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
151
+ "accept": "application/json",
152
+ "sec-ch-ua": "\"Chromium\";v=\"142\", \"Brave\";v=\"142\", \"Not_A Brand\";v=\"99\"",
153
+ "content-type": "application/json",
154
+ "x-api-key": "***REDACTED***",
155
+ "sec-ch-ua-mobile": "?0",
156
+ "sec-gpc": "1",
157
+ "accept-language": "en-US,en;q=0.8",
158
+ "origin": "http://127.0.0.1:8000",
159
+ "sec-fetch-site": "same-origin",
160
+ "sec-fetch-mode": "cors",
161
+ "sec-fetch-dest": "empty",
162
+ "referer": "http://127.0.0.1:8000/docs",
163
+ "accept-encoding": "gzip, deflate, br, zstd"
164
+ },
165
+ "started_at": "2025-11-03T09:29:18.151146Z",
166
+ "client": { "ip": "127.0.0.1", "port": 60711 },
167
+ "content_length": 342,
168
+ "body_length": 342,
169
+ "body": {
170
+ "session_id": "string",
171
+ "message": "string",
172
+ "multimodal": {
173
+ "additionalProp1": { "type": "image", "content": "string" },
174
+ "additionalProp2": { "type": "image", "content": "string" },
175
+ "additionalProp3": { "type": "image", "content": "string" }
176
+ },
177
+ "extra_args": "string"
178
+ }
179
+ }
180
+ },
181
+ "file": {
182
+ "name": "logging.py",
183
+ "path": "/Users/ivanvlahov/Desktop/SPLX/splx-proxy-sdk/splx_proxy_sdk/logging.py"
184
+ },
185
+ "function": "dispatch",
186
+ "level": { "icon": "ℹ️", "name": "INFO", "no": 20 },
187
+ "line": 169,
188
+ "message": "Request logged.",
189
+ "module": "logging",
190
+ "name": "splx_proxy_sdk.logging",
191
+ "process": { "id": 49939, "name": "SpawnProcess-1" },
192
+ "thread": { "id": 8591597888, "name": "MainThread" },
193
+ "time": {
194
+ "repr": "2025-11-03 10:29:18.151469+01:00",
195
+ "timestamp": 1762162158.151469
196
+ }
197
+ }
198
+ }
199
+ INFO: 127.0.0.1:60711 - "POST /send-message HTTP/1.1" 200 OK
200
+ {
201
+ "text": "Response logged.",
202
+ "record": {
203
+ "elapsed": { "repr": "0:00:01.262576", "seconds": 1.262576 },
204
+ "exception": null,
205
+ "extra": {
206
+ "payload": {
207
+ "event": "response",
208
+ "request_id": "1f8c1f39-e2c3-471e-b567-d69b2d58337f",
209
+ "method": "POST",
210
+ "url": "http://127.0.0.1:8000/send-message",
211
+ "path": "/send-message",
212
+ "status_code": 200,
213
+ "duration_ms": 3.394,
214
+ "completed_at": "2025-11-03T09:29:18.154589Z",
215
+ "headers": {
216
+ "content-length": "65",
217
+ "content-type": "application/json"
218
+ },
219
+ "client": { "ip": "127.0.0.1", "port": 60711 },
220
+ "content_length": 65,
221
+ "body_length": 65,
222
+ "body": {
223
+ "session_id": "test",
224
+ "message": "Hello, world!",
225
+ "multimodal": null
226
+ }
227
+ }
228
+ },
229
+ "file": {
230
+ "name": "logging.py",
231
+ "path": "/Users/ivanvlahov/Desktop/SPLX/splx-proxy-sdk/splx_proxy_sdk/logging.py"
232
+ },
233
+ "function": "dispatch",
234
+ "level": { "icon": "ℹ️", "name": "INFO", "no": 20 },
235
+ "line": 179,
236
+ "message": "Response logged.",
237
+ "module": "logging",
238
+ "name": "splx_proxy_sdk.logging",
239
+ "process": { "id": 49939, "name": "SpawnProcess-1" },
240
+ "thread": { "id": 8591597888, "name": "MainThread" },
241
+ "time": {
242
+ "repr": "2025-11-03 10:29:18.154640+01:00",
243
+ "timestamp": 1762162158.15464
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ You can use the `LoggingConfig` class to configure the logging in your server’s constructor. Alternatively, you can use environment variables:
250
+
251
+ - `SPLX_PROXY_LOG_ENABLED` - defaults to `True`
252
+ - `SPLX_PROXY_LOG_REQUEST_BODY` - defaults to `True`
253
+ - `SPLX_PROXY_LOG_RESPONSE_BODY` - defaults to `True`
254
+ - `SPLX_PROXY_LOG_MAX_BODY_LENGTH` - defaults to `4096`
255
+ - `SPLX_PROXY_LOG_REDACT_HEADERS` - defaults to `x-api-key`
256
+ - `SPLX_PROXY_LOG_REDACT_FIELDS` - defaults to `api_key`, `access_token`, `refresh_token`, `token`, `secret`, `password`, `authorization`
257
+
258
+ ### Exception Catalogue
259
+
260
+ Proxy SDK exposes multiple exception classes:
261
+
262
+ - `BadRequestException`
263
+ - `UnauthorizedException`
264
+ - `ForbiddenException`
265
+ - `NotFoundException`
266
+ - `SessionClosedException` - error code 452
267
+ - `TooManyRequestsException`
268
+ - `InternalServerErrorException`
269
+
270
+ Each of these exceptions can be raised with the following parameters:
271
+
272
+ - `details: str` - details of the exception
273
+ - `code: ExceptionCode` - each exception has a default enum value
274
+ - `config: ProxyExceptionConfig | None = None` - an exception config object that sets the headers
275
+ - `session_closed: bool | None = None` - tells Probe whether the session was closed as a result of this exception, uses a X-Splx-Session-Status header internally
276
+ - `retry_after: timedelta | None = None` - tells Probe when to retry the request, if needed, uses a Retry-After header internally
277
+ - `headers: dict[str, str]` - any additional headers you want to include in the exception
278
+
279
+ ## Useful links
280
+
281
+ - TODO: Link to public documentation
282
+ - TODO: Link to package repository
283
+
@@ -0,0 +1,11 @@
1
+ splx_proxy_sdk/__init__.py,sha256=qh9iYi13R7HmbmBaWiBOWpR7kW3xv0fgxswNlhE7nZM,1391
2
+ splx_proxy_sdk/auth.py,sha256=g8LxkmxrVs_FtAegx_JGiAnqBOOLqczYqOFYAT55f-A,848
3
+ splx_proxy_sdk/error.py,sha256=0jDL4bLZSj_d3BdyUwMfq4XE3_5_9diDHTs6J6jj2Qk,7173
4
+ splx_proxy_sdk/logging.py,sha256=Nu3Higjv1IYWGxA20mcQGLlKE7bIJOJFPMANd4QVfI4,12083
5
+ splx_proxy_sdk/model.py,sha256=9Cks_tZjx8NugXQsBreyFGjYm1Xe8G0HtgzGWb_f4rQ,2969
6
+ splx_proxy_sdk/server.py,sha256=ZOMj4srn7rUYnFpODkjJdp7xMSY3nzXDBGksj5DlWAM,5495
7
+ splx_proxy_sdk/version.py,sha256=EytKkFXdMsbzzQ4fkmmIFSUypsJPFNRpZKxS8B_LOkw,138
8
+ splx_proxy_sdk-1.0.9.dist-info/LICENSE,sha256=oadOuOQAGm5-LFNdhERvtu9GPus1DRgU0zJtphW17jk,1063
9
+ splx_proxy_sdk-1.0.9.dist-info/METADATA,sha256=cIQqbrhSW4T00jw-m37vdUzOF0Ir8zi3Ragw-TWlTFE,9447
10
+ splx_proxy_sdk-1.0.9.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
11
+ splx_proxy_sdk-1.0.9.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any