deepailab 0.2.0b1__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.
deepailab/__init__.py ADDED
@@ -0,0 +1,142 @@
1
+ """
2
+ DeepAI Lab Python SDK
3
+
4
+ Official Python SDK for the DeepAI Lab API platform.
5
+ Supports OpenAI-compatible endpoints, model marketplace, and enterprise features.
6
+ """
7
+
8
+ from .client import DeepAILab, AsyncDeepAILab
9
+ from .exceptions import (
10
+ DeepAILabError,
11
+ APIError,
12
+ AuthenticationError,
13
+ RateLimitError,
14
+ TimeoutError,
15
+ ValidationError,
16
+ )
17
+
18
+ from .auth import (
19
+ OIDCClient,
20
+ OIDCError,
21
+ OIDCTokenSet,
22
+ DeviceAuthorization,
23
+ TokenStore,
24
+ MemoryTokenStore,
25
+ FileTokenStore,
26
+ generate_code_verifier,
27
+ code_challenge,
28
+ DEFAULT_ISSUER,
29
+ DEFAULT_SCOPES,
30
+ )
31
+ from .portal import (
32
+ PortalClient,
33
+ PortalError,
34
+ DEFAULT_BRIDGE_BASE_URL,
35
+ )
36
+
37
+ # Type exports
38
+ from .types import (
39
+ # Client configuration
40
+ ClientOptions,
41
+ RequestOptions,
42
+ OnBehalfOfOptions,
43
+
44
+ # OpenAI-compatible types
45
+ ChatCompletionMessage,
46
+ ChatCompletionRequest,
47
+ ChatCompletionResponse,
48
+ ChatCompletionChoice,
49
+ ChatCompletionUsage,
50
+
51
+ EmbeddingRequest,
52
+ EmbeddingResponse,
53
+ Embedding,
54
+
55
+ ModelListResponse,
56
+ Model,
57
+
58
+ # Model marketplace types
59
+ InferenceRequest,
60
+ InferenceResponse,
61
+ BatchRequest,
62
+ BatchResponse,
63
+ ModelStatus,
64
+
65
+ # Observability types
66
+ RequestMetrics,
67
+ UsageInfo,
68
+ CostInfo,
69
+
70
+ # Streaming types
71
+ StreamingResponse,
72
+ StreamChunk,
73
+ )
74
+
75
+ # Version information
76
+ __version__ = "0.2.0b1"
77
+ __author__ = "DeepAI Lab"
78
+ __email__ = "sdk@deepailab.ai"
79
+ __license__ = "MIT"
80
+
81
+ # Public API
82
+ __all__ = [
83
+ # Main client
84
+ "DeepAILab",
85
+ "AsyncDeepAILab",
86
+
87
+ # Auth (Keycloak OIDC)
88
+ "OIDCClient",
89
+ "OIDCError",
90
+ "OIDCTokenSet",
91
+ "DeviceAuthorization",
92
+ "TokenStore",
93
+ "MemoryTokenStore",
94
+ "FileTokenStore",
95
+ "generate_code_verifier",
96
+ "code_challenge",
97
+ "DEFAULT_ISSUER",
98
+ "DEFAULT_SCOPES",
99
+ # Portal aggregation
100
+ "PortalClient",
101
+ "PortalError",
102
+ "DEFAULT_BRIDGE_BASE_URL",
103
+
104
+ # Exceptions
105
+ "DeepAILabError",
106
+ "APIError",
107
+ "AuthenticationError",
108
+ "RateLimitError",
109
+ "TimeoutError",
110
+ "ValidationError",
111
+
112
+ # Types
113
+ "ClientOptions",
114
+ "RequestOptions",
115
+ "OnBehalfOfOptions",
116
+ "ChatCompletionMessage",
117
+ "ChatCompletionRequest",
118
+ "ChatCompletionResponse",
119
+ "ChatCompletionChoice",
120
+ "ChatCompletionUsage",
121
+ "EmbeddingRequest",
122
+ "EmbeddingResponse",
123
+ "Embedding",
124
+ "ModelListResponse",
125
+ "Model",
126
+ "InferenceRequest",
127
+ "InferenceResponse",
128
+ "BatchRequest",
129
+ "BatchResponse",
130
+ "ModelStatus",
131
+ "RequestMetrics",
132
+ "UsageInfo",
133
+ "CostInfo",
134
+ "StreamingResponse",
135
+ "StreamChunk",
136
+
137
+ # Version info
138
+ "__version__",
139
+ "__author__",
140
+ "__email__",
141
+ "__license__",
142
+ ]
deepailab/auth.py ADDED
@@ -0,0 +1,369 @@
1
+ """
2
+ Keycloak OIDC authentication for the DeepAILab platform (Python).
3
+
4
+ Implements the same contract as the JS SDK:
5
+ - OIDC discovery (cached)
6
+ - PKCE (S256) helpers
7
+ - Device Authorization Grant (RFC 8628) for CLI
8
+ - Authorization Code + PKCE for desktop/web
9
+ - refresh_token rotation
10
+ - logout (end-session)
11
+
12
+ Authoritative contract: docs/architecture/stack-a2/OAUTH2_CLIENT_INTEGRATION.md
13
+ Compatible with Python 3.8+.
14
+ """
15
+
16
+ import base64
17
+ import hashlib
18
+ import os
19
+ import secrets
20
+ import time
21
+ from abc import ABC, abstractmethod
22
+ from dataclasses import dataclass
23
+ from typing import Callable, Dict, List, Optional, Tuple
24
+
25
+ import httpx
26
+
27
+ DEFAULT_ISSUER = "https://auth.deepailab.ai/realms/deepailab"
28
+ DEFAULT_SCOPES = "openid profile email offline_access"
29
+
30
+
31
+ class OIDCError(Exception):
32
+ """Raised on any OIDC/token error."""
33
+
34
+ def __init__(self, message: str, code: str = "oidc_error", status: Optional[int] = None) -> None:
35
+ super().__init__(message)
36
+ self.code = code
37
+ self.status = status
38
+
39
+
40
+ @dataclass
41
+ class OIDCTokenSet:
42
+ access_token: str
43
+ expires_at: int # epoch seconds
44
+ refresh_token: Optional[str] = None
45
+ id_token: Optional[str] = None
46
+ scope: Optional[str] = None
47
+ token_type: Optional[str] = None
48
+
49
+ def to_dict(self) -> Dict[str, object]:
50
+ return {k: v for k, v in self.__dict__.items() if v is not None}
51
+
52
+ @classmethod
53
+ def from_dict(cls, d: Dict[str, object]) -> "OIDCTokenSet":
54
+ return cls(
55
+ access_token=str(d["access_token"]),
56
+ expires_at=int(d["expires_at"]), # type: ignore[arg-type]
57
+ refresh_token=_opt_str(d.get("refresh_token")),
58
+ id_token=_opt_str(d.get("id_token")),
59
+ scope=_opt_str(d.get("scope")),
60
+ token_type=_opt_str(d.get("token_type")),
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class DeviceAuthorization:
66
+ device_code: str
67
+ user_code: str
68
+ verification_uri: str
69
+ expires_in: int
70
+ interval: int
71
+ verification_uri_complete: Optional[str] = None
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # Token storage
76
+ # --------------------------------------------------------------------------
77
+
78
+ class TokenStore(ABC):
79
+ @abstractmethod
80
+ def load(self) -> Optional[OIDCTokenSet]: ...
81
+
82
+ @abstractmethod
83
+ def save(self, tokens: OIDCTokenSet) -> None: ...
84
+
85
+ @abstractmethod
86
+ def clear(self) -> None: ...
87
+
88
+
89
+ class MemoryTokenStore(TokenStore):
90
+ def __init__(self) -> None:
91
+ self._tokens: Optional[OIDCTokenSet] = None
92
+
93
+ def load(self) -> Optional[OIDCTokenSet]:
94
+ return self._tokens
95
+
96
+ def save(self, tokens: OIDCTokenSet) -> None:
97
+ self._tokens = tokens
98
+
99
+ def clear(self) -> None:
100
+ self._tokens = None
101
+
102
+
103
+ class FileTokenStore(TokenStore):
104
+ """Persists tokens as JSON with 0600 permissions (CLI/desktop)."""
105
+
106
+ def __init__(self, file_path: str) -> None:
107
+ if not file_path:
108
+ raise ValueError("FileTokenStore requires a file path")
109
+ self.file_path = file_path
110
+
111
+ def load(self) -> Optional[OIDCTokenSet]:
112
+ import json
113
+ try:
114
+ with open(self.file_path, "r", encoding="utf-8") as fh:
115
+ data = json.load(fh)
116
+ except FileNotFoundError:
117
+ return None
118
+ if not isinstance(data, dict) or "access_token" not in data:
119
+ return None
120
+ return OIDCTokenSet.from_dict(data)
121
+
122
+ def save(self, tokens: OIDCTokenSet) -> None:
123
+ import json
124
+ directory = os.path.dirname(self.file_path)
125
+ if directory:
126
+ os.makedirs(directory, exist_ok=True)
127
+ tmp = self.file_path + ".tmp"
128
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
129
+ try:
130
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
131
+ json.dump(tokens.to_dict(), fh)
132
+ finally:
133
+ os.chmod(tmp, 0o600)
134
+ os.replace(tmp, self.file_path)
135
+ os.chmod(self.file_path, 0o600)
136
+
137
+ def clear(self) -> None:
138
+ try:
139
+ os.unlink(self.file_path)
140
+ except FileNotFoundError:
141
+ pass
142
+
143
+
144
+ # --------------------------------------------------------------------------
145
+ # PKCE helpers
146
+ # --------------------------------------------------------------------------
147
+
148
+ def _b64url(raw: bytes) -> str:
149
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
150
+
151
+
152
+ def generate_code_verifier() -> str:
153
+ return _b64url(secrets.token_bytes(64))
154
+
155
+
156
+ def code_challenge(verifier: str) -> str:
157
+ return _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
158
+
159
+
160
+ def random_state() -> str:
161
+ return _b64url(secrets.token_bytes(16))
162
+
163
+
164
+ def _opt_str(v: object) -> Optional[str]:
165
+ return None if v is None else str(v)
166
+
167
+
168
+ # --------------------------------------------------------------------------
169
+ # OIDC client
170
+ # --------------------------------------------------------------------------
171
+
172
+ class OIDCClient:
173
+ def __init__(
174
+ self,
175
+ client_id: str,
176
+ issuer: str = DEFAULT_ISSUER,
177
+ scope: str = DEFAULT_SCOPES,
178
+ store: Optional[TokenStore] = None,
179
+ refresh_skew: int = 60,
180
+ http: Optional[httpx.Client] = None,
181
+ now: Optional[Callable[[], float]] = None,
182
+ ) -> None:
183
+ if not client_id:
184
+ raise OIDCError("client_id is required", "config_error")
185
+ self.client_id = client_id
186
+ self.issuer = issuer.rstrip("/")
187
+ self.scope = scope
188
+ self.store: TokenStore = store or MemoryTokenStore()
189
+ self.refresh_skew = refresh_skew
190
+ self._http = http or httpx.Client(timeout=30.0)
191
+ self._now = now or time.time
192
+ self._discovery: Optional[Dict[str, object]] = None
193
+
194
+ # --- discovery --------------------------------------------------------
195
+
196
+ def discover(self) -> Dict[str, object]:
197
+ if self._discovery is not None:
198
+ return self._discovery
199
+ url = self.issuer + "/.well-known/openid-configuration"
200
+ resp = self._http.get(url, headers={"Accept": "application/json"})
201
+ if resp.status_code != 200:
202
+ raise OIDCError("Discovery failed: %s %s" % (resp.status_code, url), "discovery_failed", resp.status_code)
203
+ self._discovery = resp.json()
204
+ return self._discovery
205
+
206
+ def _endpoint(self, key: str) -> str:
207
+ disc = self.discover()
208
+ val = disc.get(key)
209
+ if not isinstance(val, str):
210
+ raise OIDCError("Realm discovery has no %s" % key, "endpoint_missing")
211
+ return val
212
+
213
+ def _post_token(self, params: Dict[str, str]) -> OIDCTokenSet:
214
+ resp = self._http.post(
215
+ self._endpoint("token_endpoint"),
216
+ data=params,
217
+ headers={"Accept": "application/json"},
218
+ )
219
+ try:
220
+ body = resp.json()
221
+ except Exception:
222
+ raise OIDCError("Non-JSON token response", "invalid_response", resp.status_code)
223
+ if resp.status_code >= 400 or "error" in body:
224
+ raise OIDCError(
225
+ str(body.get("error_description") or body.get("error") or resp.status_code),
226
+ str(body.get("error") or "token_error"),
227
+ resp.status_code,
228
+ )
229
+ return self._to_token_set(body)
230
+
231
+ def _to_token_set(self, body: Dict[str, object]) -> OIDCTokenSet:
232
+ expires_in = int(body.get("expires_in", 300)) # type: ignore[arg-type]
233
+ tokens = OIDCTokenSet(
234
+ access_token=str(body["access_token"]),
235
+ expires_at=int(self._now()) + expires_in,
236
+ refresh_token=_opt_str(body.get("refresh_token")),
237
+ id_token=_opt_str(body.get("id_token")),
238
+ scope=_opt_str(body.get("scope")),
239
+ token_type=_opt_str(body.get("token_type")),
240
+ )
241
+ return tokens
242
+
243
+ # --- device flow ------------------------------------------------------
244
+
245
+ def start_device_authorization(self) -> DeviceAuthorization:
246
+ endpoint = self._endpoint("device_authorization_endpoint")
247
+ resp = self._http.post(
248
+ endpoint,
249
+ data={"client_id": self.client_id, "scope": self.scope},
250
+ headers={"Accept": "application/json"},
251
+ )
252
+ body = resp.json()
253
+ if resp.status_code >= 400:
254
+ raise OIDCError(str(body.get("error_description") or body.get("error") or resp.status_code), "device_start_failed", resp.status_code)
255
+ return DeviceAuthorization(
256
+ device_code=str(body["device_code"]),
257
+ user_code=str(body["user_code"]),
258
+ verification_uri=str(body["verification_uri"]),
259
+ expires_in=int(body.get("expires_in", 600)),
260
+ interval=int(body.get("interval", 5)),
261
+ verification_uri_complete=_opt_str(body.get("verification_uri_complete")),
262
+ )
263
+
264
+ def poll_device_token(self, device: DeviceAuthorization, sleep: Optional[Callable[[float], None]] = None) -> OIDCTokenSet:
265
+ do_sleep = sleep or time.sleep
266
+ interval = device.interval
267
+ deadline = self._now() + device.expires_in
268
+ while True:
269
+ if self._now() > deadline:
270
+ raise OIDCError("Device code expired", "expired_token")
271
+ do_sleep(interval)
272
+ try:
273
+ tokens = self._post_token({
274
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
275
+ "device_code": device.device_code,
276
+ "client_id": self.client_id,
277
+ })
278
+ self.store.save(tokens)
279
+ return tokens
280
+ except OIDCError as err:
281
+ if err.code == "authorization_pending":
282
+ continue
283
+ if err.code == "slow_down":
284
+ interval += 5
285
+ continue
286
+ raise
287
+
288
+ def login_with_device_flow(self, on_prompt: Callable[[DeviceAuthorization], None], sleep: Optional[Callable[[float], None]] = None) -> OIDCTokenSet:
289
+ device = self.start_device_authorization()
290
+ on_prompt(device)
291
+ return self.poll_device_token(device, sleep=sleep)
292
+
293
+ # --- authorization code + PKCE ---------------------------------------
294
+
295
+ def create_authorization_request(self, redirect_uri: str) -> Tuple[str, str, str]:
296
+ """Returns (authorization_url, code_verifier, state)."""
297
+ from urllib.parse import urlencode
298
+ verifier = generate_code_verifier()
299
+ challenge = code_challenge(verifier)
300
+ state = random_state()
301
+ query = urlencode({
302
+ "response_type": "code",
303
+ "client_id": self.client_id,
304
+ "redirect_uri": redirect_uri,
305
+ "scope": self.scope,
306
+ "state": state,
307
+ "code_challenge": challenge,
308
+ "code_challenge_method": "S256",
309
+ })
310
+ return self._endpoint("authorization_endpoint") + "?" + query, verifier, state
311
+
312
+ def exchange_authorization_code(self, code: str, code_verifier: str, redirect_uri: str) -> OIDCTokenSet:
313
+ tokens = self._post_token({
314
+ "grant_type": "authorization_code",
315
+ "code": code,
316
+ "redirect_uri": redirect_uri,
317
+ "client_id": self.client_id,
318
+ "code_verifier": code_verifier,
319
+ })
320
+ self.store.save(tokens)
321
+ return tokens
322
+
323
+ # --- session ----------------------------------------------------------
324
+
325
+ def get_access_token(self) -> str:
326
+ current = self.store.load()
327
+ if current is None:
328
+ raise OIDCError("Not authenticated", "no_session")
329
+ if current.expires_at - self.refresh_skew > int(self._now()):
330
+ return current.access_token
331
+ if not current.refresh_token:
332
+ raise OIDCError("Access token expired and no refresh_token available", "session_expired")
333
+ return self.refresh().access_token
334
+
335
+ def refresh(self) -> OIDCTokenSet:
336
+ current = self.store.load()
337
+ if current is None or not current.refresh_token:
338
+ raise OIDCError("No refresh_token to refresh", "no_refresh_token")
339
+ tokens = self._post_token({
340
+ "grant_type": "refresh_token",
341
+ "refresh_token": current.refresh_token,
342
+ "client_id": self.client_id,
343
+ })
344
+ if not tokens.refresh_token and current.refresh_token:
345
+ tokens.refresh_token = current.refresh_token
346
+ self.store.save(tokens)
347
+ return tokens
348
+
349
+ def is_authenticated(self) -> bool:
350
+ current = self.store.load()
351
+ if current is None:
352
+ return False
353
+ if current.expires_at > int(self._now()):
354
+ return True
355
+ return bool(current.refresh_token)
356
+
357
+ def logout(self) -> None:
358
+ current = self.store.load()
359
+ try:
360
+ if current is not None and current.refresh_token:
361
+ disc = self.discover()
362
+ endpoint = disc.get("end_session_endpoint")
363
+ if isinstance(endpoint, str):
364
+ try:
365
+ self._http.post(endpoint, data={"client_id": self.client_id, "refresh_token": current.refresh_token})
366
+ except Exception:
367
+ pass
368
+ finally:
369
+ self.store.clear()