newapi-python 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.
newapi/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ from ._client import NewAPI
2
+ from ._exceptions import (
3
+ APIError,
4
+ AuthenticationError,
5
+ ConfigurationError,
6
+ ConflictError,
7
+ NewAPIError,
8
+ NotFoundError,
9
+ PermissionDeniedError,
10
+ ProtocolError,
11
+ RateLimitError,
12
+ TransportError,
13
+ TwoFactorRequired,
14
+ ValidationError,
15
+ )
16
+ from ._models import (
17
+ Announcement,
18
+ APIKey,
19
+ Balance,
20
+ CheckinResult,
21
+ CheckinStatus,
22
+ Group,
23
+ Log,
24
+ LoginSession,
25
+ Page,
26
+ PaymentConfig,
27
+ PaymentLink,
28
+ Redemption,
29
+ Resource,
30
+ SessionTokens,
31
+ Subscription,
32
+ SubscriptionPlan,
33
+ Token,
34
+ TopUpOrder,
35
+ UsageRecord,
36
+ User,
37
+ )
38
+
39
+ __all__ = [
40
+ "APIError",
41
+ "APIKey",
42
+ "Announcement",
43
+ "AuthenticationError",
44
+ "Balance",
45
+ "CheckinResult",
46
+ "CheckinStatus",
47
+ "ConfigurationError",
48
+ "ConflictError",
49
+ "Group",
50
+ "Log",
51
+ "LoginSession",
52
+ "NewAPI",
53
+ "NewAPIError",
54
+ "NotFoundError",
55
+ "Page",
56
+ "PaymentConfig",
57
+ "PaymentLink",
58
+ "PermissionDeniedError",
59
+ "ProtocolError",
60
+ "RateLimitError",
61
+ "Redemption",
62
+ "Resource",
63
+ "SessionTokens",
64
+ "Subscription",
65
+ "SubscriptionPlan",
66
+ "Token",
67
+ "TopUpOrder",
68
+ "TransportError",
69
+ "TwoFactorRequired",
70
+ "UsageRecord",
71
+ "User",
72
+ "ValidationError",
73
+ ]
74
+
75
+ __version__ = "0.1.0"
newapi/_client.py ADDED
@@ -0,0 +1,620 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import threading
5
+ from collections.abc import Mapping
6
+ from typing import Any, cast
7
+ from urllib.parse import unquote, urlsplit, urlunsplit
8
+
9
+ from curl_cffi import requests as curl_requests
10
+
11
+ from ._crypto import rsa_oaep_sha256_encrypt
12
+ from ._exceptions import (
13
+ APIError,
14
+ AuthenticationError,
15
+ ConfigurationError,
16
+ ConflictError,
17
+ NotFoundError,
18
+ PermissionDeniedError,
19
+ ProtocolError,
20
+ RateLimitError,
21
+ TransportError,
22
+ TwoFactorRequired,
23
+ ValidationError,
24
+ )
25
+ from ._models import Balance, Resource, SessionTokens, User
26
+ from ._resources import (
27
+ AccountResource,
28
+ DashboardResource,
29
+ GroupsResource,
30
+ LogsResource,
31
+ PaymentResource,
32
+ SubscriptionResource,
33
+ TokensResource,
34
+ TopUpResource,
35
+ )
36
+
37
+ _ERROR_TYPES: dict[int, type[APIError]] = {
38
+ 400: ValidationError,
39
+ 401: AuthenticationError,
40
+ 403: PermissionDeniedError,
41
+ 404: NotFoundError,
42
+ 409: ConflictError,
43
+ 422: ValidationError,
44
+ 429: RateLimitError,
45
+ }
46
+ _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
47
+ _REFRESH_COOKIE = "new_api_refresh"
48
+ _REFRESH_COOKIE_PATTERN = re.compile(rf"(?:^|[;,\s]){_REFRESH_COOKIE}=([^;\s]+)")
49
+ _DEFAULT_QUOTA_PER_UNIT = 500000.0
50
+
51
+
52
+ def _api_base_url(value: str, allow_insecure: bool) -> str:
53
+ candidate = value.strip()
54
+ if not candidate:
55
+ raise ConfigurationError("base_url must not be empty")
56
+ if "://" not in candidate:
57
+ candidate = f"https://{candidate}"
58
+ parsed = urlsplit(candidate)
59
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
60
+ raise ConfigurationError("base_url must be an HTTP or HTTPS instance URL")
61
+ if parsed.username or parsed.password:
62
+ raise ConfigurationError("base_url must not contain credentials")
63
+ if parsed.query or parsed.fragment:
64
+ raise ConfigurationError("base_url must not contain a query string or fragment")
65
+ local = parsed.hostname in _LOCAL_HOSTS or parsed.hostname.endswith(".localhost")
66
+ if parsed.scheme == "http" and not local and not allow_insecure:
67
+ raise ConfigurationError(
68
+ "remote HTTP would expose credentials; use HTTPS or set allow_insecure=True"
69
+ )
70
+ path = parsed.path.rstrip("/")
71
+ if not path.endswith("/api"):
72
+ path = f"{path}/api"
73
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
74
+
75
+
76
+ def _request_path(value: str) -> str:
77
+ parsed = urlsplit(value)
78
+ if parsed.scheme or parsed.netloc or parsed.fragment:
79
+ raise ConfigurationError("request path must be relative to the configured instance")
80
+ if parsed.query:
81
+ raise ConfigurationError("request path must not contain a query string; use params instead")
82
+ path = unquote(parsed.path).lstrip("/")
83
+ if path == "api":
84
+ path = ""
85
+ elif path.startswith("api/"):
86
+ path = path[len("api/") :]
87
+ if any(segment in {"..", "."} for segment in path.split("/")):
88
+ raise ConfigurationError("request path must not contain parent traversal")
89
+ if not path:
90
+ raise ConfigurationError("request path must identify an endpoint")
91
+ return path
92
+
93
+
94
+ def _query_value(value: Any) -> Any:
95
+ if isinstance(value, bool):
96
+ return "true" if value else "false"
97
+ return value
98
+
99
+
100
+ def _query(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
101
+ if params is None:
102
+ return None
103
+ return {
104
+ str(key): _query_value(value)
105
+ for key, value in params.items()
106
+ if value is not None and value != ""
107
+ }
108
+
109
+
110
+ def _success(response: Any) -> bool:
111
+ return 200 <= int(response.status_code) < 300
112
+
113
+
114
+ def _retry_after(response: Any) -> float | None:
115
+ value = response.headers.get("Retry-After")
116
+ if value is None:
117
+ return None
118
+ try:
119
+ return max(0.0, float(value))
120
+ except ValueError:
121
+ return None
122
+
123
+
124
+ def _set_cookie_values(response: Any) -> list[str]:
125
+ headers = response.headers
126
+ get_list = getattr(headers, "get_list", None)
127
+ if callable(get_list):
128
+ return [str(value) for value in get_list("set-cookie")]
129
+ raw = headers.get("set-cookie")
130
+ if raw is None:
131
+ return []
132
+ if isinstance(raw, str):
133
+ return [raw]
134
+ return [str(value) for value in raw]
135
+
136
+
137
+ class NewAPI:
138
+ """An authenticated user session for one new-api instance."""
139
+
140
+ def __init__(
141
+ self,
142
+ base_url: str,
143
+ *,
144
+ access_token: str | None = None,
145
+ refresh_token: str | None = None,
146
+ expires_at: float | None = None,
147
+ session_id: str | None = None,
148
+ timeout: float | tuple[float, float] = 30.0,
149
+ language: str = "en",
150
+ allow_insecure: bool = False,
151
+ impersonate: str | None = "chrome",
152
+ session: Any | None = None,
153
+ ) -> None:
154
+ self.base_url = _api_base_url(base_url, allow_insecure)
155
+ self.language = language
156
+ self._tokens = SessionTokens(
157
+ access_token=access_token,
158
+ refresh_token=refresh_token,
159
+ expires_at=expires_at,
160
+ session_id=session_id,
161
+ )
162
+ self._refresh_lock = threading.RLock()
163
+ self._pending_2fa_token: str | None = None
164
+ self.timeout = timeout
165
+ self.impersonate = impersonate
166
+ self._quota_per_unit: float | None = None
167
+ self._owns_http_client = session is None
168
+ if session is None:
169
+ if impersonate:
170
+ self._http: Any = cast(Any, curl_requests.Session)(impersonate=impersonate)
171
+ else:
172
+ self._http = cast(Any, curl_requests.Session)()
173
+ else:
174
+ self._http = session
175
+ self.account = AccountResource(self)
176
+ self.tokens = TokensResource(self)
177
+ self.keys = self.tokens
178
+ self.logs = LogsResource(self)
179
+ self.usage = self.logs
180
+ self.history = self.logs
181
+ self.groups = GroupsResource(self)
182
+ self.dashboard = DashboardResource(self)
183
+ self.topup = TopUpResource(self)
184
+ self.payment = PaymentResource(self)
185
+ self.subscriptions = SubscriptionResource(self)
186
+
187
+ @property
188
+ def session(self) -> SessionTokens:
189
+ return self._tokens
190
+
191
+ @property
192
+ def is_authenticated(self) -> bool:
193
+ return self._tokens.authenticated
194
+
195
+ @property
196
+ def quota_per_unit(self) -> float | None:
197
+ return self._quota_per_unit
198
+
199
+ def login(
200
+ self,
201
+ username: str,
202
+ password: str,
203
+ *,
204
+ turnstile_token: str | None = None,
205
+ encrypt_password: bool = True,
206
+ ) -> User:
207
+ if not username.strip() or not password:
208
+ raise ValueError("username and password are required")
209
+ payload: dict[str, Any] = {"username": username}
210
+ key = self._password_encryption_key() if encrypt_password else None
211
+ if key is not None:
212
+ payload["password_encrypted"] = rsa_oaep_sha256_encrypt(
213
+ password.encode(), key["public_key"]
214
+ )
215
+ payload["encryption_key_id"] = key["kid"]
216
+ else:
217
+ payload["password"] = password
218
+ params = {"turnstile": turnstile_token} if turnstile_token else None
219
+ data, response = self._request_with_response(
220
+ "POST",
221
+ "user/login",
222
+ params=params,
223
+ json=payload,
224
+ authenticated=False,
225
+ allow_refresh=False,
226
+ )
227
+ auth = self._auth_mapping(data, "login")
228
+ if auth.get("require_2fa") is True:
229
+ flow_token = auth.get("flow_token")
230
+ if not isinstance(flow_token, str) or not flow_token:
231
+ raise ProtocolError("2FA login response did not contain a flow token")
232
+ self._pending_2fa_token = flow_token
233
+ expires_at = auth.get("expires_at")
234
+ raise TwoFactorRequired(
235
+ flow_token,
236
+ float(expires_at) if isinstance(expires_at, (int, float)) else None,
237
+ )
238
+ return self._accept_auth(auth, response)
239
+
240
+ def complete_2fa(self, code: str, *, flow_token: str | None = None) -> User:
241
+ token = flow_token or self._pending_2fa_token
242
+ if not token:
243
+ raise ValueError("flow_token is required when no 2FA login is pending")
244
+ if not code.strip():
245
+ raise ValueError("code must not be empty")
246
+ data, response = self._request_with_response(
247
+ "POST",
248
+ "user/login/2fa",
249
+ json={"code": code, "flow_token": token},
250
+ authenticated=False,
251
+ allow_refresh=False,
252
+ )
253
+ user = self._accept_auth(self._auth_mapping(data, "2FA login"), response)
254
+ self._pending_2fa_token = None
255
+ return user
256
+
257
+ def refresh(self) -> SessionTokens:
258
+ with self._refresh_lock:
259
+ refresh_token = self._tokens.refresh_token
260
+ if not refresh_token:
261
+ raise AuthenticationError(
262
+ "No refresh token is available",
263
+ status_code=401,
264
+ code="NO_REFRESH_TOKEN",
265
+ )
266
+ try:
267
+ data, response = self._request_with_response(
268
+ "POST",
269
+ "user/auth/refresh",
270
+ refresh_cookie=refresh_token,
271
+ authenticated=False,
272
+ allow_refresh=False,
273
+ )
274
+ auth = self._auth_mapping(data, "token refresh")
275
+ access_token = auth.get("access_token")
276
+ if not isinstance(access_token, str) or not access_token:
277
+ raise ProtocolError("token refresh did not return an access token")
278
+ rotated = self._harvest_refresh_token(response) or refresh_token
279
+ self._tokens = self._tokens_from_auth(auth, rotated)
280
+ return self._tokens
281
+ except APIError as error:
282
+ if 400 <= error.status_code < 500:
283
+ self.clear_auth()
284
+ raise
285
+
286
+ def logout(self) -> None:
287
+ refresh_token = self._tokens.refresh_token
288
+ headers: dict[str, str] = {"Origin": self._origin()}
289
+ if self._tokens.access_token:
290
+ headers["Authorization"] = f"{self._tokens.token_type} {self._tokens.access_token}"
291
+ try:
292
+ self._request(
293
+ "POST",
294
+ "user/auth/logout",
295
+ refresh_cookie=refresh_token,
296
+ headers=headers,
297
+ authenticated=False,
298
+ allow_refresh=False,
299
+ )
300
+ finally:
301
+ self.clear_auth()
302
+
303
+ def clear_auth(self) -> None:
304
+ self._tokens = SessionTokens()
305
+ self._pending_2fa_token = None
306
+
307
+ def me(self) -> User:
308
+ return self.account.profile()
309
+
310
+ def balance(self) -> Balance:
311
+ profile = self.account.profile()
312
+ quota_per_unit = self._resolve_quota_per_unit()
313
+ try:
314
+ return Balance(
315
+ quota=int(profile.get("quota", 0)),
316
+ used_quota=int(profile.get("used_quota", 0)),
317
+ aff_quota=int(profile.get("aff_quota", 0)),
318
+ quota_per_unit=quota_per_unit,
319
+ )
320
+ except (TypeError, ValueError) as error:
321
+ raise ProtocolError("profile response did not contain valid quota values") from error
322
+
323
+ def status(self) -> Resource:
324
+ data = self._request(
325
+ "GET",
326
+ "status",
327
+ authenticated=False,
328
+ allow_refresh=False,
329
+ )
330
+ if not isinstance(data, Mapping):
331
+ raise ProtocolError("status returned a non-object response")
332
+ quota_per_unit = data.get("quota_per_unit")
333
+ if isinstance(quota_per_unit, (int, float)) and quota_per_unit > 0:
334
+ self._quota_per_unit = float(quota_per_unit)
335
+ return Resource(data)
336
+
337
+ def notice(self) -> Any:
338
+ return self._request("GET", "notice", authenticated=False, allow_refresh=False)
339
+
340
+ def about(self) -> Any:
341
+ return self._request("GET", "about", authenticated=False, allow_refresh=False)
342
+
343
+ def request(
344
+ self,
345
+ method: str,
346
+ path: str,
347
+ *,
348
+ params: Mapping[str, Any] | None = None,
349
+ json: Any = None,
350
+ headers: Mapping[str, str] | None = None,
351
+ authenticated: bool = True,
352
+ ) -> Any:
353
+ return self._request(
354
+ method,
355
+ path,
356
+ params=params,
357
+ json=json,
358
+ headers=headers,
359
+ authenticated=authenticated,
360
+ allow_refresh=True,
361
+ )
362
+
363
+ def close(self) -> None:
364
+ if self._owns_http_client:
365
+ self._http.close()
366
+
367
+ def __enter__(self) -> NewAPI:
368
+ return self
369
+
370
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
371
+ self.close()
372
+
373
+ def _request(
374
+ self,
375
+ method: str,
376
+ path: str,
377
+ *,
378
+ params: Mapping[str, Any] | None = None,
379
+ json: Any = None,
380
+ headers: Mapping[str, str] | None = None,
381
+ authenticated: bool,
382
+ allow_refresh: bool,
383
+ refresh_cookie: str | None = None,
384
+ ) -> Any:
385
+ return self._request_with_response(
386
+ method,
387
+ path,
388
+ params=params,
389
+ json=json,
390
+ headers=headers,
391
+ authenticated=authenticated,
392
+ allow_refresh=allow_refresh,
393
+ refresh_cookie=refresh_cookie,
394
+ )[0]
395
+
396
+ def _request_with_response(
397
+ self,
398
+ method: str,
399
+ path: str,
400
+ *,
401
+ params: Mapping[str, Any] | None = None,
402
+ json: Any = None,
403
+ headers: Mapping[str, str] | None = None,
404
+ authenticated: bool,
405
+ allow_refresh: bool,
406
+ refresh_cookie: str | None = None,
407
+ ) -> tuple[Any, Any]:
408
+ normalized_method = method.upper()
409
+ if authenticated and allow_refresh:
410
+ self._refresh_if_expiring()
411
+ failed_token = self._tokens.access_token
412
+ response = self._send(
413
+ normalized_method,
414
+ path,
415
+ params=params,
416
+ json=json,
417
+ headers=headers,
418
+ authenticated=authenticated,
419
+ refresh_cookie=refresh_cookie,
420
+ )
421
+ if (
422
+ response.status_code == 401
423
+ and authenticated
424
+ and allow_refresh
425
+ and self._tokens.refresh_token
426
+ ):
427
+ self._refresh_after_unauthorized(failed_token)
428
+ response = self._send(
429
+ normalized_method,
430
+ path,
431
+ params=params,
432
+ json=json,
433
+ headers=headers,
434
+ authenticated=authenticated,
435
+ )
436
+ return self._decode(response), response
437
+
438
+ def _send(
439
+ self,
440
+ method: str,
441
+ path: str,
442
+ *,
443
+ params: Mapping[str, Any] | None,
444
+ json: Any,
445
+ headers: Mapping[str, str] | None,
446
+ authenticated: bool,
447
+ refresh_cookie: str | None = None,
448
+ ) -> Any:
449
+ relative_path = _request_path(path)
450
+ request_headers = {
451
+ "Accept": "application/json",
452
+ "Accept-Language": self.language,
453
+ "User-Agent": "newapi-python/0.1.0",
454
+ }
455
+ if authenticated and self._tokens.access_token:
456
+ request_headers["Authorization"] = (
457
+ f"{self._tokens.token_type} {self._tokens.access_token}"
458
+ )
459
+ if refresh_cookie and not self._session_has_refresh_cookie():
460
+ request_headers["Cookie"] = f"{_REFRESH_COOKIE}={refresh_cookie}"
461
+ if headers:
462
+ request_headers.update(headers)
463
+ try:
464
+ return cast(Any, self._http).request(
465
+ method,
466
+ f"{self.base_url}/{relative_path}",
467
+ params=_query(params),
468
+ json=json,
469
+ headers=request_headers,
470
+ timeout=self.timeout,
471
+ allow_redirects=False,
472
+ )
473
+ except curl_requests.RequestsError as error:
474
+ raise TransportError(f"{method} request to the new-api instance failed") from error
475
+
476
+ def _decode(self, response: Any) -> Any:
477
+ if not response.content:
478
+ if _success(response):
479
+ return None
480
+ self._raise_api_error(response, {})
481
+ try:
482
+ payload = response.json()
483
+ except ValueError as error:
484
+ if not _success(response):
485
+ raise ProtocolError(
486
+ f"new-api returned non-JSON HTTP {response.status_code}"
487
+ ) from error
488
+ raise ProtocolError("new-api returned a non-JSON success response") from error
489
+ if not _success(response):
490
+ self._raise_api_error(response, payload)
491
+ if isinstance(payload, Mapping):
492
+ if "success" in payload:
493
+ if payload.get("success") is not True:
494
+ self._raise_api_error(response, payload)
495
+ return payload.get("data")
496
+ if "message" in payload:
497
+ if payload.get("message") != "success":
498
+ self._raise_api_error(response, payload)
499
+ return payload
500
+ return payload
501
+
502
+ def _raise_api_error(self, response: Any, payload: Any) -> None:
503
+ data = payload if isinstance(payload, Mapping) else {}
504
+ code = data.get("code")
505
+ fallback_message = (
506
+ getattr(response, "reason_phrase", None)
507
+ or getattr(response, "reason", None)
508
+ or f"HTTP {response.status_code}"
509
+ )
510
+ message: Any = data.get("message")
511
+ detail = data.get("data")
512
+ if not isinstance(message, str) or message == "" or message == "error":
513
+ message = detail if isinstance(detail, str) and detail else fallback_message
514
+ status_code = response.status_code
515
+ error_type = _ERROR_TYPES.get(status_code, APIError)
516
+ raise error_type(
517
+ str(message),
518
+ status_code=status_code,
519
+ code=code if isinstance(code, (int, str)) else None,
520
+ retry_after=_retry_after(response),
521
+ )
522
+
523
+ def _origin(self) -> str:
524
+ parsed = urlsplit(self.base_url)
525
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
526
+
527
+ def _password_encryption_key(self) -> Resource | None:
528
+ data = self._request(
529
+ "GET",
530
+ "user/login/encryption-key",
531
+ authenticated=False,
532
+ allow_refresh=False,
533
+ )
534
+ if not isinstance(data, Mapping):
535
+ raise ProtocolError("login encryption key response was not an object")
536
+ key = Resource(data)
537
+ if not key.get("enabled"):
538
+ return None
539
+ if not key.get("kid") or not key.get("public_key"):
540
+ raise ProtocolError("login encryption key response was incomplete")
541
+ return key
542
+
543
+ def _harvest_refresh_token(self, response: Any) -> str | None:
544
+ for value in _set_cookie_values(response):
545
+ match = _REFRESH_COOKIE_PATTERN.search(value)
546
+ if match:
547
+ return match.group(1)
548
+ jar = getattr(self._http, "cookies", None)
549
+ if jar is not None:
550
+ try:
551
+ stored = jar.get(_REFRESH_COOKIE)
552
+ except Exception:
553
+ stored = None
554
+ if isinstance(stored, str) and stored:
555
+ return stored
556
+ return None
557
+
558
+ def _session_has_refresh_cookie(self) -> bool:
559
+ jar = getattr(self._http, "cookies", None)
560
+ if jar is None:
561
+ return False
562
+ try:
563
+ stored = jar.get(_REFRESH_COOKIE)
564
+ except Exception:
565
+ return False
566
+ return isinstance(stored, str) and bool(stored)
567
+
568
+ def _refresh_if_expiring(self) -> None:
569
+ with self._refresh_lock:
570
+ if self._tokens.needs_refresh():
571
+ self.refresh()
572
+
573
+ def _refresh_after_unauthorized(self, failed_token: str | None) -> None:
574
+ with self._refresh_lock:
575
+ if self._tokens.access_token and self._tokens.access_token != failed_token:
576
+ return
577
+ self.refresh()
578
+
579
+ def _auth_mapping(self, data: Any, operation: str) -> Mapping[str, Any]:
580
+ if not isinstance(data, Mapping):
581
+ raise ProtocolError(f"{operation} returned a non-object response")
582
+ return data
583
+
584
+ def _accept_auth(self, auth: Mapping[str, Any], response: Any) -> User:
585
+ access_token = auth.get("access_token")
586
+ if not isinstance(access_token, str) or not access_token:
587
+ raise ProtocolError("login did not return an access token")
588
+ user_data = auth.get("user")
589
+ if not isinstance(user_data, Mapping):
590
+ raise ProtocolError("login did not return a user profile")
591
+ self._tokens = self._tokens_from_auth(auth, self._harvest_refresh_token(response))
592
+ return User(user_data)
593
+
594
+ def _tokens_from_auth(
595
+ self,
596
+ auth: Mapping[str, Any],
597
+ refresh_token: str | None,
598
+ ) -> SessionTokens:
599
+ expires_at = None
600
+ raw_expires_at = auth.get("access_expires_at")
601
+ if isinstance(raw_expires_at, (int, float)) and not isinstance(raw_expires_at, bool):
602
+ expires_at = float(raw_expires_at)
603
+ token_type = auth.get("token_type")
604
+ session = auth.get("session")
605
+ session_id = None
606
+ if isinstance(session, Mapping):
607
+ raw_session_id = session.get("sid")
608
+ session_id = raw_session_id if isinstance(raw_session_id, str) else None
609
+ return SessionTokens(
610
+ access_token=str(auth["access_token"]),
611
+ refresh_token=refresh_token,
612
+ expires_at=expires_at,
613
+ token_type=str(token_type) if token_type else "Bearer",
614
+ session_id=session_id,
615
+ )
616
+
617
+ def _resolve_quota_per_unit(self) -> float:
618
+ if self._quota_per_unit is None:
619
+ self.status()
620
+ return self._quota_per_unit if self._quota_per_unit is not None else _DEFAULT_QUOTA_PER_UNIT