diff --git a/app/services/monitoring.py b/app/services/monitoring.py index 07055a4..df053fa 100644 --- a/app/services/monitoring.py +++ b/app/services/monitoring.py @@ -25,6 +25,51 @@ def _parse_credential(raw: str) -> Dict[str, Any]: return {'api_key': raw} +def _parse_reset_at(value) -> datetime | None: + if not value: + return None + if isinstance(value, datetime): + return value + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value, tz=timezone.utc) + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + except Exception: + return None + return None + + +def _normalize_usage_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + used = payload.get('used') or payload.get('usage') or payload.get('consumed') or payload.get('total_usage') + limit = payload.get('limit') or payload.get('quota') or payload.get('hard_limit') or payload.get('total') + remaining = payload.get('remain') or payload.get('remaining') or payload.get('left') + usage_pct = payload.get('usage_pct') or payload.get('percent') or payload.get('usage_percent') + window_label = payload.get('window') or payload.get('window_label') + reset_at = payload.get('reset_at') or payload.get('reset_time') or payload.get('reset') + + if used is None and remaining is not None and limit is not None: + try: + used = float(limit) - float(remaining) + except Exception: + pass + + if usage_pct is None and used is not None and limit: + try: + usage_pct = round(float(used) / float(limit) * 100, 2) + except Exception: + pass + + return { + 'window_label': window_label, + 'used': used, + 'limit': limit, + 'usage_pct': usage_pct, + 'reset_at': _parse_reset_at(reset_at), + 'raw': payload, + } + + def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800): key = 'issue_stats_24h' now = _now() @@ -78,6 +123,8 @@ def test_provider_connection(provider: str, credential: str): r = requests.get('https://api.anthropic.com/v1/models', headers=_provider_headers(provider, key, info), timeout=12) return r.status_code == 200, f'status={r.status_code}' usage_url = info.get('usage_url') or info.get('test_url') + if provider == 'kimi' and not usage_url: + usage_url = 'https://www.kimi.com/api/user/usage' if usage_url: r = requests.get(usage_url, headers=_provider_headers(provider, key, info), timeout=12) return r.status_code < 500, f'status={r.status_code}' @@ -130,15 +177,37 @@ def _anthropic_usage(credential: str) -> Tuple[str, Dict[str, Any]]: if r.status_code != 200: return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text} payload = r.json() - # Try to normalize - return 'ok', { - 'window_label': payload.get('window') or payload.get('window_label'), - 'used': payload.get('used'), - 'limit': payload.get('limit'), - 'usage_pct': payload.get('usage_pct'), - 'reset_at': None, - 'raw': payload, - } + if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict): + payload = payload['data'] + return 'ok', _normalize_usage_payload(payload) + + +def _kimi_usage(credential: str) -> Tuple[str, Dict[str, Any]]: + info = _parse_credential(credential) + key = info.get('api_key') or credential + usage_url = info.get('usage_url') or 'https://www.kimi.com/api/user/usage' + r = requests.get(usage_url, headers=_provider_headers('kimi', key, info), timeout=12) + if r.status_code != 200: + return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text} + payload = r.json() + if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict): + payload = payload['data'] + return 'ok', _normalize_usage_payload(payload) + + +def _minimax_usage(credential: str) -> Tuple[str, Dict[str, Any]]: + info = _parse_credential(credential) + key = info.get('api_key') or credential + usage_url = info.get('usage_url') + if not usage_url: + return 'unsupported', {'error': 'minimax usage API not configured'} + r = requests.get(usage_url, headers=_provider_headers('minimax', key, info), timeout=12) + if r.status_code != 200: + return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text} + payload = r.json() + if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict): + payload = payload['data'] + return 'ok', _normalize_usage_payload(payload) def _generic_usage(provider: str, credential: str) -> Tuple[str, Dict[str, Any]]: @@ -151,14 +220,7 @@ def _generic_usage(provider: str, credential: str) -> Tuple[str, Dict[str, Any]] if r.status_code != 200: return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text} payload = r.json() - return 'ok', { - 'window_label': payload.get('window') or payload.get('window_label'), - 'used': payload.get('used'), - 'limit': payload.get('limit'), - 'usage_pct': payload.get('usage_pct'), - 'reset_at': None, - 'raw': payload, - } + return 'ok', _normalize_usage_payload(payload) def refresh_provider_usage_once(db: Session): @@ -171,7 +233,11 @@ def refresh_provider_usage_once(db: Session): status, payload = _openai_usage(a.credential) elif a.provider == 'anthropic': status, payload = _anthropic_usage(a.credential) - elif a.provider in {'minimax', 'kimi', 'qwen'}: + elif a.provider == 'kimi': + status, payload = _kimi_usage(a.credential) + elif a.provider == 'minimax': + status, payload = _minimax_usage(a.credential) + elif a.provider == 'qwen': status, payload = _generic_usage(a.provider, a.credential) else: ok, msg = test_provider_connection(a.provider, a.credential) diff --git a/docs/monitor-provider-credentials.md b/docs/monitor-provider-credentials.md index 0eb4c3e..dad5244 100644 --- a/docs/monitor-provider-credentials.md +++ b/docs/monitor-provider-credentials.md @@ -53,3 +53,11 @@ "auth_scheme": "Bearer" } ``` + +## Kimi +推荐 usage_url: https://www.kimi.com/api/user/usage +Authorization: Bearer + +## Minimax +推荐 usage_url: https://platform.minimax.io/v1/api/openplatform/coding_plan/remains?GroupId=YOUR_GROUP_ID +Authorization: Bearer