wlt-platform 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wlt/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """WLT Python SDK -- Official client library for the WLT (White Label Token) Console API.
2
+
3
+ Quick start::
4
+
5
+ from wlt import WltClient
6
+
7
+ client = WltClient(api_key="sk-xxx")
8
+ result = client.secrets.list()
9
+ print(result.data)
10
+
11
+ For async usage::
12
+
13
+ from wlt import AsyncWltClient
14
+
15
+ async def main():
16
+ async with AsyncWltClient(api_key="sk-xxx") as client:
17
+ result = await client.secrets.list()
18
+ """
19
+
20
+ from .client import AsyncWltClient, WltClient
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "WltClient",
26
+ "AsyncWltClient",
27
+ "__version__",
28
+ ]
wlt/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Allow running as ``python -m wlt`` or ``python -m wlt.cli``."""
2
+ from wlt.cli import main
3
+
4
+ main()
wlt/_config.py ADDED
@@ -0,0 +1,34 @@
1
+ """Client configuration for the WLT SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class ClientConfig:
10
+ """Configuration for the WLT client.
11
+
12
+ Attributes:
13
+ api_key: API key used to obtain a Bearer Token via loginByApiKey.
14
+ The SDK automatically calls ``POST /xt-console/api/user/loginByApiKey``
15
+ to exchange the api_key for a short-lived token, then uses that token
16
+ in the ``Authorization: Bearer {token}`` header for all subsequent requests.
17
+ base_url: The base URL of the WLT Console API.
18
+ Trailing slashes are stripped automatically.
19
+ timeout: Request timeout in seconds. Default is 30.
20
+ max_retries: Maximum number of retry attempts for transient failures.
21
+ Default is 2. Retries use exponential backoff with jitter.
22
+ default_headers: Additional headers to include in every request.
23
+ """
24
+
25
+ api_key: str = ""
26
+ base_url: str = "https://daily.sssxuntui.com"
27
+ timeout: float = 30.0
28
+ max_retries: int = 2
29
+ default_headers: dict[str, str] = field(default_factory=dict)
30
+
31
+ def __post_init__(self) -> None:
32
+ self.base_url = self.base_url.rstrip("/")
33
+ if not self.api_key:
34
+ raise ValueError("api_key is required and must not be empty")
wlt/_exceptions.py ADDED
@@ -0,0 +1,120 @@
1
+ """Exception hierarchy for the WLT SDK.
2
+
3
+ The exception hierarchy maps business error codes from BaseResponse.code
4
+ to specific exception types:
5
+
6
+ WltError (base)
7
+ +-- APIError (generic API error with code/message)
8
+ | +-- AuthenticationError (code 2000 / 2002 / 3001)
9
+ | +-- PermissionError (code 2007)
10
+ | +-- NotFoundError (code 1002)
11
+ | +-- ValidationError (code 1001)
12
+ | +-- RateLimitError (code 3002)
13
+ | +-- InternalError (code 1000)
14
+ +-- ConnectionError (network-level failures)
15
+ +-- TimeoutError (request timeout)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+
21
+ class WltError(Exception):
22
+ """Base exception for all WLT SDK errors."""
23
+
24
+ def __init__(self, message: str = "An error occurred") -> None:
25
+ self.message = message
26
+ super().__init__(message)
27
+
28
+
29
+ class APIError(WltError):
30
+ """Generic API error carrying business code and message from BaseResponse."""
31
+
32
+ def __init__(self, code: int, message: str) -> None:
33
+ self.code = code
34
+ super().__init__(message)
35
+
36
+ def __str__(self) -> str:
37
+ return f"APIError(code={self.code}, message={self.message!r})"
38
+
39
+ def __repr__(self) -> str:
40
+ return f"APIError(code={self.code}, message={self.message!r})"
41
+
42
+
43
+ class AuthenticationError(APIError):
44
+ """Raised when authentication fails (business code 2000, 2002, or 3001)."""
45
+
46
+ def __str__(self) -> str:
47
+ return f"AuthenticationError(code={self.code}, message={self.message!r})"
48
+
49
+
50
+ class PermissionError(APIError):
51
+ """Raised when the user lacks permission (business code 2007)."""
52
+
53
+ def __str__(self) -> str:
54
+ return f"PermissionError(code={self.code}, message={self.message!r})"
55
+
56
+
57
+ class NotFoundError(APIError):
58
+ """Raised when the requested resource is not found (business code 1002)."""
59
+
60
+ def __str__(self) -> str:
61
+ return f"NotFoundError(code={self.code}, message={self.message!r})"
62
+
63
+
64
+ class ValidationError(APIError):
65
+ """Raised when request parameters are invalid (business code 1001)."""
66
+
67
+ def __str__(self) -> str:
68
+ return f"ValidationError(code={self.code}, message={self.message!r})"
69
+
70
+
71
+ class RateLimitError(APIError):
72
+ """Raised when the request rate limit is exceeded (business code 3002)."""
73
+
74
+ def __str__(self) -> str:
75
+ return f"RateLimitError(code={self.code}, message={self.message!r})"
76
+
77
+
78
+ class InternalError(APIError):
79
+ """Raised on system-level errors (business code 1000)."""
80
+
81
+ def __str__(self) -> str:
82
+ return f"InternalError(code={self.code}, message={self.message!r})"
83
+
84
+
85
+ class ConnectionError(WltError):
86
+ """Raised when a network connection error occurs."""
87
+
88
+ pass
89
+
90
+
91
+ class TimeoutError(WltError):
92
+ """Raised when a request times out."""
93
+
94
+ pass
95
+
96
+
97
+ # Mapping from business error code to exception class
98
+ _ERROR_CODE_MAP: dict[int, type[APIError]] = {
99
+ 1000: InternalError,
100
+ 1001: ValidationError,
101
+ 1002: NotFoundError,
102
+ 2000: AuthenticationError,
103
+ 2002: AuthenticationError,
104
+ 2007: PermissionError,
105
+ 3001: AuthenticationError,
106
+ 3002: RateLimitError,
107
+ }
108
+
109
+
110
+ def raise_for_code(code: int, message: str) -> None:
111
+ """Raise the appropriate exception for the given business error code.
112
+
113
+ If the code is 200 (success), this function does nothing. Otherwise,
114
+ it looks up the code in the error map and raises the corresponding
115
+ exception, falling back to APIError for unknown codes.
116
+ """
117
+ if code == 200:
118
+ return
119
+ exc_cls = _ERROR_CODE_MAP.get(code, APIError)
120
+ raise exc_cls(code=code, message=message)
wlt/_http.py ADDED
@@ -0,0 +1,436 @@
1
+ """Low-level HTTP transport for the WLT SDK.
2
+
3
+ Provides synchronous and asynchronous HTTP clients built on httpx,
4
+ with automatic two-step token authentication (apiKey -> token),
5
+ retry with exponential backoff, and business-error-code checking.
6
+
7
+ Authentication flow:
8
+ 1. On construction, call POST /xt-console/api/user/loginByApiKey?apiKey={api_key}
9
+ to exchange the API key for a short-lived Bearer token.
10
+ 2. All subsequent requests use Authorization: Bearer {token}.
11
+ 3. When a response returns business code 3001 (token expired / permission denied),
12
+ the client automatically re-authenticates and retries the original request once.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import random
19
+ import time
20
+ from typing import Any, Generator, Iterator
21
+
22
+ import httpx
23
+
24
+ from ._config import ClientConfig
25
+ from ._exceptions import (
26
+ AuthenticationError,
27
+ ConnectionError,
28
+ TimeoutError,
29
+ raise_for_code,
30
+ )
31
+
32
+ _LOGIN_PATH = "/xt-console/api/user/loginByApiKey"
33
+ _CODE_TOKEN_EXPIRED = 3001
34
+
35
+
36
+ def _build_url(config: ClientConfig, path: str) -> str:
37
+ """Build the full URL from config base_url and an API path."""
38
+ if path.startswith("http://") or path.startswith("https://"):
39
+ return path
40
+ return f"{config.base_url}{path}"
41
+
42
+
43
+ def _backoff_delay(attempt: int) -> float:
44
+ """Calculate exponential backoff delay with jitter.
45
+
46
+ delay = min(2^attempt * 0.5, 8.0) + random jitter in [0, 0.5)
47
+ """
48
+ base = min(2**attempt * 0.5, 8.0)
49
+ jitter = random.random() * 0.5 # noqa: S311
50
+ return base + jitter
51
+
52
+
53
+ class SyncHTTPClient:
54
+ """Synchronous HTTP client wrapping httpx.Client with two-step token authentication.
55
+
56
+ The client automatically exchanges the api_key for a Bearer token via
57
+ ``loginByApiKey`` at construction time, and refreshes it when the server
58
+ returns business code 3001 (token expired / permission denied).
59
+ """
60
+
61
+ def __init__(self, config: ClientConfig) -> None:
62
+ self._config = config
63
+ self._token: str = ""
64
+ self._client = httpx.Client(
65
+ timeout=httpx.Timeout(config.timeout),
66
+ headers=config.default_headers,
67
+ follow_redirects=True,
68
+ )
69
+ # Obtain initial token
70
+ self._authenticate()
71
+
72
+ def _authenticate(self) -> None:
73
+ """Call loginByApiKey to exchange api_key for a Bearer token."""
74
+ url = _build_url(self._config, _LOGIN_PATH)
75
+ try:
76
+ response = self._client.request(
77
+ "POST",
78
+ url,
79
+ params={"apiKey": self._config.api_key},
80
+ )
81
+ data = response.json()
82
+ code = data.get("code", 0)
83
+ if code != 200:
84
+ message = data.get("message", "loginByApiKey failed")
85
+ raise AuthenticationError(code=code, message=message)
86
+ token = data.get("data", "")
87
+ if not token:
88
+ raise AuthenticationError(
89
+ code=code,
90
+ message="loginByApiKey returned empty token",
91
+ )
92
+ self._token = token
93
+ except httpx.TimeoutException as exc:
94
+ raise TimeoutError(
95
+ f"loginByApiKey timed out after {self._config.timeout}s"
96
+ ) from exc
97
+ except httpx.ConnectError as exc:
98
+ raise ConnectionError(f"loginByApiKey connection failed: {exc}") from exc
99
+
100
+ def request(
101
+ self,
102
+ method: str,
103
+ path: str,
104
+ *,
105
+ json: dict[str, Any] | None = None,
106
+ params: dict[str, Any] | None = None,
107
+ ) -> dict[str, Any]:
108
+ """Send an HTTP request with retry logic and return parsed JSON.
109
+
110
+ If the server returns business code 3001 (token expired / permission
111
+ denied), the client automatically re-authenticates via loginByApiKey
112
+ and retries the request once.
113
+
114
+ Args:
115
+ method: HTTP method (GET, POST, PUT, DELETE).
116
+ path: API path, e.g. "/xt-console/api/secret/list".
117
+ json: Request body as JSON dict (for POST/PUT).
118
+ params: Query parameters dict (for GET/DELETE).
119
+
120
+ Returns:
121
+ Parsed JSON response as a dict.
122
+
123
+ Raises:
124
+ ConnectionError: On network-level failures.
125
+ TimeoutError: When the request times out.
126
+ APIError (or subclass): When the business code indicates an error.
127
+ """
128
+ url = _build_url(self._config, path)
129
+ last_exc: Exception | None = None
130
+
131
+ for attempt in range(self._config.max_retries + 1):
132
+ try:
133
+ response = self._client.request(
134
+ method,
135
+ url,
136
+ json=json,
137
+ params=params,
138
+ headers={"Authorization": f"Bearer {self._token}"},
139
+ )
140
+ # Handle HTTP-level errors (e.g. 500)
141
+ if response.status_code >= 500:
142
+ if attempt < self._config.max_retries:
143
+ time.sleep(_backoff_delay(attempt))
144
+ continue
145
+ response.raise_for_status()
146
+
147
+ data = response.json()
148
+
149
+ # Check business error code
150
+ code = data.get("code", 200)
151
+ message = data.get("message", "")
152
+
153
+ # Auto-refresh token on code 3001 (token expired / permission denied)
154
+ if code == _CODE_TOKEN_EXPIRED:
155
+ self._authenticate()
156
+ # Retry the original request once with new token
157
+ response = self._client.request(
158
+ method,
159
+ url,
160
+ json=json,
161
+ params=params,
162
+ headers={"Authorization": f"Bearer {self._token}"},
163
+ )
164
+ data = response.json()
165
+ code = data.get("code", 200)
166
+ message = data.get("message", "")
167
+
168
+ raise_for_code(code, message)
169
+
170
+ return data
171
+
172
+ except httpx.TimeoutException as exc:
173
+ last_exc = exc
174
+ if attempt < self._config.max_retries:
175
+ time.sleep(_backoff_delay(attempt))
176
+ continue
177
+ raise TimeoutError(f"Request timed out after {self._config.timeout}s") from exc
178
+
179
+ except httpx.ConnectError as exc:
180
+ last_exc = exc
181
+ if attempt < self._config.max_retries:
182
+ time.sleep(_backoff_delay(attempt))
183
+ continue
184
+ raise ConnectionError(f"Connection failed: {exc}") from exc
185
+
186
+ except httpx.HTTPStatusError as exc:
187
+ raise ConnectionError(
188
+ f"HTTP {exc.response.status_code}: {exc.response.text}"
189
+ ) from exc
190
+
191
+ # Should not reach here, but just in case
192
+ if last_exc is not None:
193
+ raise ConnectionError(f"Request failed after retries: {last_exc}") from last_exc
194
+ raise ConnectionError("Request failed after retries")
195
+
196
+ def stream_request(
197
+ self,
198
+ method: str,
199
+ path: str,
200
+ *,
201
+ json_body: dict[str, Any] | None = None,
202
+ params: dict[str, Any] | None = None,
203
+ ) -> Iterator[str]:
204
+ """Send an HTTP request and yield SSE data lines.
205
+
206
+ This method is designed for Server-Sent Events (SSE) endpoints
207
+ like model stream inference. It yields each ``data:`` line content
208
+ as a string (without the ``data:`` prefix).
209
+
210
+ Args:
211
+ method: HTTP method (GET, POST).
212
+ path: API path.
213
+ json_body: Request body as JSON dict.
214
+ params: Query parameters dict.
215
+
216
+ Yields:
217
+ Each SSE data line content as a string.
218
+
219
+ Raises:
220
+ ConnectionError: On network-level failures.
221
+ TimeoutError: When the request times out.
222
+ """
223
+ url = _build_url(self._config, path)
224
+ try:
225
+ with self._client.stream(
226
+ method,
227
+ url,
228
+ json=json_body,
229
+ params=params,
230
+ headers={"Authorization": f"Bearer {self._token}"},
231
+ ) as response:
232
+ for line in response.iter_lines():
233
+ if line.startswith("data:"):
234
+ yield line[len("data:"):].strip()
235
+ except httpx.TimeoutException as exc:
236
+ raise TimeoutError(f"Stream request timed out after {self._config.timeout}s") from exc
237
+ except httpx.ConnectError as exc:
238
+ raise ConnectionError(f"Stream connection failed: {exc}") from exc
239
+
240
+ def close(self) -> None:
241
+ """Close the underlying HTTP client and release resources."""
242
+ self._client.close()
243
+
244
+
245
+ class AsyncHTTPClient:
246
+ """Asynchronous HTTP client wrapping httpx.AsyncClient with two-step token authentication.
247
+
248
+ The client automatically exchanges the api_key for a Bearer token via
249
+ ``loginByApiKey`` before the first request, and refreshes it when the server
250
+ returns business code 3001 (token expired / permission denied).
251
+ """
252
+
253
+ def __init__(self, config: ClientConfig) -> None:
254
+ self._config = config
255
+ self._token: str = ""
256
+ self._authenticated = False
257
+ self._client = httpx.AsyncClient(
258
+ timeout=httpx.Timeout(config.timeout),
259
+ headers=config.default_headers,
260
+ follow_redirects=True,
261
+ )
262
+
263
+ async def _authenticate(self) -> None:
264
+ """Call loginByApiKey to exchange api_key for a Bearer token."""
265
+ url = _build_url(self._config, _LOGIN_PATH)
266
+ try:
267
+ response = await self._client.request(
268
+ "POST",
269
+ url,
270
+ params={"apiKey": self._config.api_key},
271
+ )
272
+ data = response.json()
273
+ code = data.get("code", 0)
274
+ if code != 200:
275
+ message = data.get("message", "loginByApiKey failed")
276
+ raise AuthenticationError(code=code, message=message)
277
+ token = data.get("data", "")
278
+ if not token:
279
+ raise AuthenticationError(
280
+ code=code,
281
+ message="loginByApiKey returned empty token",
282
+ )
283
+ self._token = token
284
+ self._authenticated = True
285
+ except httpx.TimeoutException as exc:
286
+ raise TimeoutError(
287
+ f"loginByApiKey timed out after {self._config.timeout}s"
288
+ ) from exc
289
+ except httpx.ConnectError as exc:
290
+ raise ConnectionError(f"loginByApiKey connection failed: {exc}") from exc
291
+
292
+ async def _ensure_authenticated(self) -> None:
293
+ """Ensure the client has a valid token (lazy initialization for async)."""
294
+ if not self._authenticated:
295
+ await self._authenticate()
296
+
297
+ async def request(
298
+ self,
299
+ method: str,
300
+ path: str,
301
+ *,
302
+ json: dict[str, Any] | None = None,
303
+ params: dict[str, Any] | None = None,
304
+ ) -> dict[str, Any]:
305
+ """Send an async HTTP request with retry logic and return parsed JSON.
306
+
307
+ If the server returns business code 3001 (token expired / permission
308
+ denied), the client automatically re-authenticates via loginByApiKey
309
+ and retries the request once.
310
+
311
+ Args:
312
+ method: HTTP method (GET, POST, PUT, DELETE).
313
+ path: API path, e.g. "/xt-console/api/secret/list".
314
+ json: Request body as JSON dict (for POST/PUT).
315
+ params: Query parameters dict (for GET/DELETE).
316
+
317
+ Returns:
318
+ Parsed JSON response as a dict.
319
+
320
+ Raises:
321
+ ConnectionError: On network-level failures.
322
+ TimeoutError: When the request times out.
323
+ APIError (or subclass): When the business code indicates an error.
324
+ """
325
+ import asyncio
326
+
327
+ await self._ensure_authenticated()
328
+
329
+ url = _build_url(self._config, path)
330
+ last_exc: Exception | None = None
331
+
332
+ for attempt in range(self._config.max_retries + 1):
333
+ try:
334
+ response = await self._client.request(
335
+ method,
336
+ url,
337
+ json=json,
338
+ params=params,
339
+ headers={"Authorization": f"Bearer {self._token}"},
340
+ )
341
+ if response.status_code >= 500:
342
+ if attempt < self._config.max_retries:
343
+ await asyncio.sleep(_backoff_delay(attempt))
344
+ continue
345
+ response.raise_for_status()
346
+
347
+ data = response.json()
348
+
349
+ code = data.get("code", 200)
350
+ message = data.get("message", "")
351
+
352
+ # Auto-refresh token on code 3001 (token expired / permission denied)
353
+ if code == _CODE_TOKEN_EXPIRED:
354
+ await self._authenticate()
355
+ # Retry the original request once with new token
356
+ response = await self._client.request(
357
+ method,
358
+ url,
359
+ json=json,
360
+ params=params,
361
+ headers={"Authorization": f"Bearer {self._token}"},
362
+ )
363
+ data = response.json()
364
+ code = data.get("code", 200)
365
+ message = data.get("message", "")
366
+
367
+ raise_for_code(code, message)
368
+
369
+ return data
370
+
371
+ except httpx.TimeoutException as exc:
372
+ last_exc = exc
373
+ if attempt < self._config.max_retries:
374
+ await asyncio.sleep(_backoff_delay(attempt))
375
+ continue
376
+ raise TimeoutError(f"Request timed out after {self._config.timeout}s") from exc
377
+
378
+ except httpx.ConnectError as exc:
379
+ last_exc = exc
380
+ if attempt < self._config.max_retries:
381
+ await asyncio.sleep(_backoff_delay(attempt))
382
+ continue
383
+ raise ConnectionError(f"Connection failed: {exc}") from exc
384
+
385
+ except httpx.HTTPStatusError as exc:
386
+ raise ConnectionError(
387
+ f"HTTP {exc.response.status_code}: {exc.response.text}"
388
+ ) from exc
389
+
390
+ if last_exc is not None:
391
+ raise ConnectionError(f"Request failed after retries: {last_exc}") from last_exc
392
+ raise ConnectionError("Request failed after retries")
393
+
394
+ async def stream_request(
395
+ self,
396
+ method: str,
397
+ path: str,
398
+ *,
399
+ json_body: dict[str, Any] | None = None,
400
+ params: dict[str, Any] | None = None,
401
+ ):
402
+ """Send an async HTTP request and yield SSE data lines.
403
+
404
+ This method is designed for Server-Sent Events (SSE) endpoints.
405
+ It yields each ``data:`` line content as a string.
406
+
407
+ Args:
408
+ method: HTTP method (GET, POST).
409
+ path: API path.
410
+ json_body: Request body as JSON dict.
411
+ params: Query parameters dict.
412
+
413
+ Yields:
414
+ Each SSE data line content as a string.
415
+ """
416
+ await self._ensure_authenticated()
417
+ url = _build_url(self._config, path)
418
+ try:
419
+ async with self._client.stream(
420
+ method,
421
+ url,
422
+ json=json_body,
423
+ params=params,
424
+ headers={"Authorization": f"Bearer {self._token}"},
425
+ ) as response:
426
+ async for line in response.aiter_lines():
427
+ if line.startswith("data:"):
428
+ yield line[len("data:"):].strip()
429
+ except httpx.TimeoutException as exc:
430
+ raise TimeoutError(f"Stream request timed out after {self._config.timeout}s") from exc
431
+ except httpx.ConnectError as exc:
432
+ raise ConnectionError(f"Stream connection failed: {exc}") from exc
433
+
434
+ async def close(self) -> None:
435
+ """Close the underlying async HTTP client and release resources."""
436
+ await self._client.aclose()
wlt/_types.py ADDED
@@ -0,0 +1,38 @@
1
+ """Common type definitions for the WLT SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypeVar
6
+
7
+ from pydantic import BaseModel
8
+
9
+ T = TypeVar("T")
10
+
11
+ # Sentinel for distinguishing "not provided" from None
12
+ _UNSET = object()
13
+
14
+
15
+ class _UnsetType:
16
+ """Sentinel type used to distinguish 'not provided' from None."""
17
+
18
+ _instance: _UnsetType | None = None
19
+
20
+ def __new__(cls) -> _UnsetType:
21
+ if cls._instance is None:
22
+ cls._instance = super().__new__(cls)
23
+ return cls._instance
24
+
25
+ def __bool__(self) -> bool:
26
+ return False
27
+
28
+ def __repr__(self) -> str:
29
+ return "UNSET"
30
+
31
+
32
+ UNSET = _UnsetType()
33
+
34
+
35
+ class HeadersDict(dict):
36
+ """A dict subclass for HTTP headers."""
37
+
38
+ pass