File size: 5,286 Bytes
9693b03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""
gemini_key_manager.py
----------------------
Round-robin API key rotation untuk Gemini via LiteLLM, dengan fallback
otomatis dan cooldown khusus untuk limit RPD (Requests Per Day).

CARA PAKAI DI HUGGING FACE SPACES:
- Buka Settings > Variables and secrets pada Space Anda.
- Tambahkan secret dengan Name & Value:
    GEMINI_API_KEY_1  = AIza...key1
    GEMINI_API_KEY_2  = AIza...key2
    GEMINI_API_KEY_3  = AIza...key3
    GEMINI_API_KEY_4  = AIza...key4
    GEMINI_API_KEY_5  = AIza...key5
  (boleh 1 sampai berapa pun key, penomoran harus berurutan mulai dari 1,
   tidak boleh ada nomor yang loncat/kosong di tengah).
- Modul ini otomatis mendeteksi semua GEMINI_API_KEY_<n> yang ada.
"""

import os
import re
import threading
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)

# Jam reset kuota harian Gemini (RPD) menurut Google: 08:05 UTC
RPD_RESET_HOUR_UTC = 8
RPD_RESET_MINUTE_UTC = 5


class GeminiKeyManager:
    def __init__(self, prefix: str = "GEMINI_API_KEY_"):
        keys: List[str] = []
        i = 1
        while True:
            val = os.environ.get(f"{prefix}{i}")
            if not val:
                break
            keys.append(val.strip())
            i += 1

        # fallback: kalau tidak ada GEMINI_API_KEY_1, coba GEMINI_API_KEY tunggal
        if not keys:
            single = os.environ.get("GEMINI_API_KEY")
            if single:
                keys = [single.strip()]

        if not keys:
            raise RuntimeError(
                "Tidak ada Gemini API key ditemukan. "
                "Set GEMINI_API_KEY_1, GEMINI_API_KEY_2, ... di Variables and secrets."
            )

        self.keys = keys
        self.n = len(keys)
        self._lock = threading.Lock()
        self._idx = 0
        # key -> datetime (UTC) sampai kapan key ini "istirahat" karena RPD habis
        self._cooldown_until: Dict[str, Optional[datetime]] = {k: None for k in keys}

        logger.info(f"GeminiKeyManager aktif dengan {self.n} API key.")

    # ------------------------------------------------------------------
    # Helper waktu reset
    # ------------------------------------------------------------------
    def _next_rpd_reset(self) -> datetime:
        now = datetime.now(timezone.utc)
        reset = now.replace(
            hour=RPD_RESET_HOUR_UTC, minute=RPD_RESET_MINUTE_UTC,
            second=0, microsecond=0,
        )
        if now >= reset:
            reset += timedelta(days=1)
        return reset

    def _is_available(self, key: str) -> bool:
        until = self._cooldown_until.get(key)
        if until is None:
            return True
        if datetime.now(timezone.utc) >= until:
            self._cooldown_until[key] = None  # cooldown habis, key aktif lagi
            return True
        return False

    # ------------------------------------------------------------------
    # API utama
    # ------------------------------------------------------------------
    def get_rotation_order(self) -> List[str]:
        """
        Kembalikan daftar key sesuai urutan giliran, dimulai dari key
        berikutnya dalam rotasi. Pointer selalu maju setiap kali method
        ini dipanggil (baik request berhasil maupun gagal), sehingga
        beban merata di semua user/panggilan.
        """
        with self._lock:
            order = [self.keys[(self._idx + i) % self.n] for i in range(self.n)]
            self._idx = (self._idx + 1) % self.n
        return order

    def mark_rpd_exhausted(self, key: str) -> None:
        """Tandai key kena limit harian (RPD) -> istirahat sampai reset berikutnya."""
        with self._lock:
            reset_at = self._next_rpd_reset()
            self._cooldown_until[key] = reset_at
        logger.warning(f"Key ...{key[-6:]} kena limit RPD, istirahat sampai {reset_at.isoformat()}")

    def status(self) -> Dict[str, str]:
        now = datetime.now(timezone.utc)
        out = {}
        for k in self.keys:
            until = self._cooldown_until.get(k)
            if until and now < until:
                out[f"...{k[-6:]}"] = f"cooldown until {until.isoformat()}"
            else:
                out[f"...{k[-6:]}"] = "available"
        return out


# ------------------------------------------------------------------
# Deteksi jenis error dari Gemini / LiteLLM
# ------------------------------------------------------------------
# Catatan: format pesan error Google untuk RPD biasanya mengandung kata
# "PerDay" pada quotaId, mis. "GenerateRequestsPerDayPerProjectPerModel-FreeTier".
# Untuk RPM/TPM mengandung "PerMinute". Kalau tidak yakin (ambigu), kita
# anggap BUKAN rpd (lebih aman -> key tidak dikunci seharian secara keliru).
RPD_PATTERNS = [r"PerDay", r"per[_ ]?day", r"daily limit", r"requests per day"]
RATE_LIMIT_PATTERNS = [r"RESOURCE_EXHAUSTED", r"429", r"rate limit", r"quota"]


def classify_gemini_error(exc: Exception) -> str:
    """Return 'rpd', 'rpm', atau 'other'."""
    msg = str(exc)
    is_rate_limit = any(re.search(p, msg, re.IGNORECASE) for p in RATE_LIMIT_PATTERNS)
    if not is_rate_limit:
        return "other"
    if any(re.search(p, msg, re.IGNORECASE) for p in RPD_PATTERNS):
        return "rpd"
    return "rpm"