sub2api_client.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """Sub2API admin HTTP client."""
  2. from __future__ import annotations
  3. import json
  4. from typing import Any
  5. from urllib.error import HTTPError, URLError
  6. from urllib.parse import urlencode
  7. from urllib.request import Request, urlopen
  8. class Sub2ApiClient:
  9. def __init__(self, base_url, admin_email, admin_password, api_key="", timeout=20):
  10. self.base_url = str(base_url or "http://127.0.0.1:8080").strip().rstrip("/") or "http://127.0.0.1:8080"
  11. self.admin_email = str(admin_email or "").strip()
  12. self.admin_password = str(admin_password or "").strip()
  13. self.api_key = str(api_key or "").strip()
  14. self.timeout = max(1, int(timeout))
  15. self._jwt = ""
  16. def _ensure_jwt(self) -> str:
  17. if self.api_key:
  18. return ""
  19. if self._jwt:
  20. return self._jwt
  21. body = json.dumps({"email": self.admin_email, "password": self.admin_password}, ensure_ascii=False).encode("utf-8")
  22. payload = self._request_raw("POST", "/api/v1/auth/login", body=body, with_auth=False)
  23. data = payload.get("data", payload) if isinstance(payload, dict) else {}
  24. token = str((data or {}).get("access_token") or "").strip()
  25. if not token:
  26. raise RuntimeError("sub2api login returned empty access_token")
  27. self._jwt = token
  28. return token
  29. def _headers(self) -> dict[str, str]:
  30. headers = {"Accept": "application/json"}
  31. if self.api_key:
  32. headers["x-api-key"] = self.api_key
  33. else:
  34. headers["Authorization"] = f"Bearer {self._ensure_jwt()}"
  35. return headers
  36. def _request_raw(self, method: str, path: str, body: bytes | None = None, *, with_auth: bool = True) -> dict[str, Any]:
  37. url = f"{self.base_url}/{str(path or '').lstrip('/')}"
  38. headers = {"Accept": "application/json"}
  39. if body is not None:
  40. headers["Content-Type"] = "application/json"
  41. if with_auth:
  42. headers.update(self._headers())
  43. request = Request(url, data=body, headers=headers, method=str(method or "GET").upper())
  44. try:
  45. with urlopen(request, timeout=self.timeout) as response:
  46. raw = response.read().decode("utf-8")
  47. except HTTPError as exc:
  48. raw = exc.read().decode("utf-8", errors="replace")
  49. try:
  50. payload = json.loads(raw) if raw else {}
  51. except json.JSONDecodeError:
  52. payload = {"message": raw or str(exc)}
  53. payload.setdefault("code", exc.code)
  54. raise RuntimeError(json.dumps(payload, ensure_ascii=False)) from exc
  55. except (URLError, TimeoutError, OSError) as exc:
  56. raise RuntimeError(f"sub2api request failed: {exc}") from exc
  57. try:
  58. payload = json.loads(raw) if raw else {}
  59. except json.JSONDecodeError as exc:
  60. raise RuntimeError(f"sub2api returned invalid json: {exc}") from exc
  61. if not isinstance(payload, dict):
  62. raise RuntimeError("sub2api returned non-object payload")
  63. return payload
  64. def _request(self, method, path, body=None) -> dict:
  65. encoded_body = None
  66. if body is not None:
  67. encoded_body = json.dumps(body, ensure_ascii=False).encode("utf-8")
  68. for attempt in range(2):
  69. try:
  70. payload = self._request_raw(method, path, body=encoded_body)
  71. data = payload.get("data", payload)
  72. return data if isinstance(data, dict) else {"items": data} if isinstance(data, list) else {}
  73. except RuntimeError as exc:
  74. message = str(exc)
  75. if '"code": 401' in message and not self.api_key and attempt == 0:
  76. self._jwt = ""
  77. self._ensure_jwt()
  78. continue
  79. raise
  80. return {}
  81. def health_check(self) -> bool:
  82. request = Request(f"{self.base_url}/health", headers={"Accept": "application/json"}, method="GET")
  83. try:
  84. with urlopen(request, timeout=self.timeout) as response:
  85. payload = json.loads(response.read().decode("utf-8") or "{}")
  86. except Exception:
  87. return False
  88. return isinstance(payload, dict) and payload.get("status") == "ok"
  89. def list_accounts(self, platform="openai", page=1, page_size=100) -> dict:
  90. query = urlencode({"platform": platform, "page": page, "page_size": page_size})
  91. return self._request("GET", f"/api/v1/admin/accounts?{query}")
  92. def create_account(self, name, credentials, platform="openai", type="oauth", **kwargs) -> dict:
  93. payload = {
  94. "name": name,
  95. "platform": platform,
  96. "type": type,
  97. "credentials": credentials,
  98. }
  99. payload.update({key: value for key, value in kwargs.items() if value is not None})
  100. return self._request("POST", "/api/v1/admin/accounts", payload)
  101. def batch_create_accounts(self, accounts: list[dict]) -> dict:
  102. return self._request("POST", "/api/v1/admin/accounts/batch", {"accounts": accounts})
  103. def get_account(self, account_id: int) -> dict | None:
  104. try:
  105. return self._request("GET", f"/api/v1/admin/accounts/{int(account_id)}")
  106. except RuntimeError as exc:
  107. if '"code": 404' in str(exc):
  108. return None
  109. raise
  110. def delete_account(self, account_id: int) -> bool:
  111. self._request("DELETE", f"/api/v1/admin/accounts/{int(account_id)}")
  112. return True
  113. def update_account(self, account_id: int, updates: dict) -> dict:
  114. return self._request("PUT", f"/api/v1/admin/accounts/{int(account_id)}", updates)
  115. def refresh_account(self, account_id: int) -> dict:
  116. return self._request("POST", f"/api/v1/admin/accounts/{int(account_id)}/refresh", {})
  117. def batch_refresh(self, account_ids: list[int] | None = None) -> dict:
  118. payload = {}
  119. if account_ids is not None:
  120. payload["account_ids"] = account_ids
  121. return self._request("POST", "/api/v1/admin/accounts/batch-refresh", payload)
  122. def test_account(self, account_id: int) -> dict:
  123. return self._request("POST", f"/api/v1/admin/accounts/{int(account_id)}/test", {})
  124. def set_schedulable(self, account_id: int, schedulable: bool) -> dict:
  125. return self._request(
  126. "POST",
  127. f"/api/v1/admin/accounts/{int(account_id)}/schedulable",
  128. {"schedulable": bool(schedulable)},
  129. )
  130. def clear_error(self, account_id: int) -> dict:
  131. return self._request("POST", f"/api/v1/admin/accounts/{int(account_id)}/clear-error", {})
  132. def restart(self) -> dict:
  133. return self._request("POST", "/api/v1/admin/system/restart", {})