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.
- splx_proxy_sdk/__init__.py +58 -0
- splx_proxy_sdk/auth.py +34 -0
- splx_proxy_sdk/error.py +279 -0
- splx_proxy_sdk/logging.py +345 -0
- splx_proxy_sdk/model.py +127 -0
- splx_proxy_sdk/server.py +165 -0
- splx_proxy_sdk/version.py +8 -0
- splx_proxy_sdk-1.0.9.dist-info/LICENSE +21 -0
- splx_proxy_sdk-1.0.9.dist-info/METADATA +283 -0
- splx_proxy_sdk-1.0.9.dist-info/RECORD +11 -0
- splx_proxy_sdk-1.0.9.dist-info/WHEEL +4 -0
|
@@ -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
|
splx_proxy_sdk/error.py
ADDED
|
@@ -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
|
splx_proxy_sdk/model.py
ADDED
|
@@ -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
|
+
"""
|
splx_proxy_sdk/server.py
ADDED
|
@@ -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,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,,
|