"""Upstream HTTP client — ported from monitor_ai98pro_group_rates.py.""" from __future__ import annotations import json from decimal import Decimal, InvalidOperation from typing import Any, Optional from urllib.parse import urljoin import httpx class UpstreamError(RuntimeError): pass def _find_token(value: Any) -> str: if isinstance(value, str) and value.count(".") >= 2: return value if isinstance(value, dict): for key in ("token", "access_token", "accessToken", "jwt", "auth_token", "authToken"): candidate = value.get(key) if isinstance(candidate, str) and candidate: return candidate for key in ("data", "result", "user", "session"): tok = _find_token(value.get(key)) if tok: return tok return "" def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]: if isinstance(value, list): return [i for i in value if isinstance(i, dict)] if isinstance(value, dict): for key in ("data", "items", "groups", "available_groups", "availableGroups"): nested = value.get(key) if isinstance(nested, list): return [i for i in nested if isinstance(i, dict)] return None def _decimal_str(value: Any) -> str: if value is None or value == "": return "" try: d = Decimal(str(value)) except (InvalidOperation, ValueError): return str(value) n = d.normalize() if n == n.to_integral(): return str(n.quantize(Decimal("1"))) return format(n, "f") def _group_id(group: dict[str, Any]) -> str: for key in ("id", "group_id", "groupId"): v = group.get(key) if v is not None: return str(v) name = str(group.get("name") or group.get("group_name") or "") platform = str(group.get("platform") or "") return f"{platform}:{name}" def _rate_from_group(group: dict[str, Any]) -> str: for key in ( "user_rate_multiplier", "userRateMultiplier", "effective_rate_multiplier", "effectiveRateMultiplier", "rate_multiplier", "rateMultiplier", ): r = _decimal_str(group.get(key)) if r: return r return "" def _extract_rates_map(raw: Any) -> dict[str, str]: if raw is None: return {} if isinstance(raw, dict): candidates = raw for key in ("data", "rates", "group_rates", "groupRates"): nested = raw.get(key) if isinstance(nested, dict): candidates = nested break result: dict[str, str] = {} for k, v in candidates.items(): if isinstance(v, dict): r = _decimal_str( v.get("rate_multiplier") or v.get("rateMultiplier") or v.get("user_rate_multiplier") or v.get("userRateMultiplier") ) else: r = _decimal_str(v) if r: result[str(k)] = r return result if isinstance(raw, list): result = {} for item in raw: if not isinstance(item, dict): continue gid = _group_id(item) rate = _rate_from_group(item) if gid and rate: result[gid] = rate return result return {} def build_snapshot(upstream_id: int, base_url: str, api_prefix: str, groups: list[dict[str, Any]], raw_rates: Any) -> dict[str, Any]: from datetime import datetime, timezone override_rates = _extract_rates_map(raw_rates) entries: dict[str, dict[str, Any]] = {} for g in groups: gid = _group_id(g) default_rate = _rate_from_group(g) effective_rate = override_rates.get(gid, default_rate) entries[gid] = { "group_id": gid, "group_name": g.get("name") or g.get("group_name") or "", "platform": g.get("platform") or "", "rate": effective_rate, "default_rate": default_rate, "override_rate": override_rates.get(gid, ""), } return { "upstream_id": upstream_id, "base_url": base_url.rstrip("/"), "api_prefix": api_prefix, "captured_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), "groups": entries, } class UpstreamClient: """Sync HTTP client that handles all auth types.""" def __init__( self, base_url: str, api_prefix: str, auth_type: str, auth_config: dict[str, Any], timeout: float = 30.0, ) -> None: self.base_url = base_url.rstrip("/") self.api_prefix = api_prefix.strip("/") self.auth_type = auth_type self.auth_config = auth_config self.timeout = timeout self._token: str = "" def _url(self, path: str) -> str: prefix = f"/{self.api_prefix}" if self.api_prefix else "" return f"{self.base_url}{prefix}/{path.lstrip('/')}" def _headers(self, auth: bool = True) -> dict[str, str]: headers: dict[str, str] = { "Accept": "application/json", "User-Agent": "SmartUp/1.0", } if not auth: return headers if self.auth_type == "bearer": token = self.auth_config.get("token", "") if token: headers["Authorization"] = f"Bearer {token}" elif self.auth_type == "api_key": key = self.auth_config.get("key", "") header = self.auth_config.get("header", "Authorization") if key: headers[header] = key elif self.auth_type == "login_password" and self._token: headers["Authorization"] = f"Bearer {self._token}" return headers def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any: url = self._url(path) with httpx.Client(timeout=self.timeout) as client: if body is not None: resp = client.request(method, url, json=body, headers=self._headers(auth)) else: resp = client.request(method, url, headers=self._headers(auth)) resp.raise_for_status() ct = resp.headers.get("content-type", "") if not resp.content: return None text = resp.text if "application/json" not in ct and text.lstrip().startswith("<"): raise UpstreamError(f"{method} {path} returned HTML, not JSON") return resp.json() def login(self) -> None: if self.auth_type != "login_password": return email = self.auth_config.get("email", "") password = self.auth_config.get("password", "") login_path = self.auth_config.get("login_path", "/auth/login") if not email or not password: raise UpstreamError("login_password auth requires email and password in auth_config") resp = self._request("POST", login_path, {"email": email, "password": password}, auth=False) token = _find_token(resp) if not token: raise UpstreamError("login succeeded but no token found in response") self._token = token def get_available_groups(self, endpoint: str) -> list[dict[str, Any]]: resp = self._request("GET", endpoint) groups = _unwrap_list(resp) if groups is None: raise UpstreamError(f"{endpoint} did not return a list") return groups def get_group_rates(self, endpoint: str) -> Any: return self._request("GET", endpoint)