File size: 8,689 Bytes
f70ac6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
"""
Groq API Key Pool Manager
Manages multiple Groq API keys with round-robin and rate limiting
"""

import asyncio
import time
import logging
from typing import List, Dict
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)


@dataclass
class KeyUsage:
    """Track usage for a single API key"""
    api_key: str
    calls_this_minute: int = 0
    last_reset_time: float = field(default_factory=time.time)
    total_calls: int = 0
    total_failures: int = 0
    is_blocked: bool = False
    blocked_until: float = 0


class GroqAPIPool:
    """
    Round-robin API key pool with rate limiting
    Free tier: 30 requests/minute per key
    """

    RATE_LIMIT_PER_MINUTE = 30
    RATE_WINDOW_SECONDS = 60

    def __init__(self, api_keys: List[str]):
        """
        Initialize pool with API keys

        Args:
            api_keys: List of Groq API keys
        """
        if not api_keys:
            raise ValueError("At least one Groq API key is required")

        self.api_keys = api_keys
        self.key_usage = {key: KeyUsage(api_key=key) for key in api_keys}
        self.current_index = 0
        self._lock = asyncio.Lock()

        logger.info(f"GroqAPIPool initialized with {len(api_keys)} keys")
        logger.info(f"Total capacity: {len(api_keys) * self.RATE_LIMIT_PER_MINUTE} requests/minute")

    async def get_available_key(self) -> str:
        """
        Get next available API key (rate-limit aware)
        Uses round-robin across keys, skipping rate-limited ones
        """
        async with self._lock:
            now = time.time()
            attempts = 0
            max_attempts = len(self.api_keys) * 2

            while attempts < max_attempts:
                # Get next key in round-robin order
                key = self.api_keys[self.current_index % len(self.api_keys)]
                self.current_index += 1
                usage = self.key_usage[key]

                # Reset counter if minute window has passed
                if now - usage.last_reset_time > self.RATE_WINDOW_SECONDS:
                    usage.calls_this_minute = 0
                    usage.last_reset_time = now

                # Unblock if block period passed
                if usage.is_blocked and now > usage.blocked_until:
                    usage.is_blocked = False
                    logger.info(f"Key {key[:10]}... unblocked")

                # Check if usable
                if (
                    not usage.is_blocked
                    and usage.calls_this_minute < self.RATE_LIMIT_PER_MINUTE
                ):
                    usage.calls_this_minute += 1
                    usage.total_calls += 1
                    return key

                attempts += 1

            # All keys exhausted, wait and retry
            wait_time = self._calculate_wait_time()
            logger.warning(
                f"All Groq keys rate-limited. Waiting {wait_time:.1f}s for capacity..."
            )
            await asyncio.sleep(wait_time)
            return await self.get_available_key()

    def _calculate_wait_time(self) -> float:
        """Calculate minimum wait time until any key becomes available"""
        now = time.time()
        min_wait = float("inf")

        for usage in self.key_usage.values():
            if usage.is_blocked:
                wait = max(0, usage.blocked_until - now)
            else:
                wait = max(0, self.RATE_WINDOW_SECONDS - (now - usage.last_reset_time))
            min_wait = min(min_wait, wait)

        return max(1.0, min(min_wait, 60.0))

    async def invoke(
        self,
        prompt: str,
        model: str = "llama-3.1-8b-instant",
        temperature: float = 0.2,
        max_tokens: int = 1000,
        system_prompt: str = None,
    ) -> str:
        """
        Invoke Groq API with automatic key rotation

        Args:
            prompt: User prompt
            model: Groq model name (llama-3.1-8b-instant or llama-3.1-70b-versatile)
            temperature: Sampling temperature
            max_tokens: Max output tokens
            system_prompt: Optional system prompt

        Returns:
            Response text from Groq
        """
        # Get available key
        api_key = await self.get_available_key()

        try:
            # Import here to avoid hard dependency
            from groq import AsyncGroq

            client = AsyncGroq(api_key=api_key)

            # Build messages
            messages = []
            if system_prompt:
                messages.append({"role": "system", "content": system_prompt})
            messages.append({"role": "user", "content": prompt})

            # Make API call
            response = await client.chat.completions.create(
                model=model,
                messages=messages,
                temperature=temperature,
                max_tokens=max_tokens,
            )

            # Extract response text
            return response.choices[0].message.content

        except Exception as e:
            # Mark key as failed
            usage = self.key_usage[api_key]
            usage.total_failures += 1

            error_str = str(e).lower()

            # Detect rate limit errors
            if "rate" in error_str or "limit" in error_str or "429" in error_str:
                usage.is_blocked = True
                usage.blocked_until = time.time() + 60
                logger.warning(f"Key {api_key[:10]}... blocked for 60s (rate limit)")

            # Retry with different key
            if usage.total_failures < 3:
                logger.warning(f"Retrying with different key: {str(e)[:100]}")
                return await self.invoke(prompt, model, temperature, max_tokens, system_prompt)

            raise Exception(f"All Groq keys failed: {str(e)}")

    async def call_async(
        self,
        model: str,
        messages: List[Dict],
        temperature: float = 0.2,
        max_tokens: int = 1000,
    ) -> str:
        """
        Call Groq API with pre-built messages (for polishers, agents, etc.)

        Args:
            model: Groq model name
            messages: List of message dicts with 'role' and 'content'
            temperature: Sampling temperature
            max_tokens: Max output tokens

        Returns:
            Response text from Groq
        """
        # Get available key
        api_key = await self.get_available_key()

        try:
            from groq import AsyncGroq

            client = AsyncGroq(api_key=api_key)

            # Make API call with provided messages
            response = await client.chat.completions.create(
                model=model,
                messages=messages,
                temperature=temperature,
                max_tokens=max_tokens,
            )

            # Extract response text
            return response.choices[0].message.content

        except Exception as e:
            # Mark key as failed
            usage = self.key_usage[api_key]
            usage.total_failures += 1

            error_str = str(e).lower()

            # Detect rate limit errors
            if "rate" in error_str or "limit" in error_str or "429" in error_str:
                usage.is_blocked = True
                usage.blocked_until = time.time() + 60
                logger.warning(f"Key {api_key[:10]}... blocked for 60s (rate limit)")

            # Retry with different key
            if usage.total_failures < 3:
                logger.warning(f"Retrying with different key: {str(e)[:100]}")
                return await self.call_async(model, messages, temperature, max_tokens)

            raise Exception(f"All Groq keys failed: {str(e)}")

    def get_status(self) -> Dict:
        """Get current pool status"""
        now = time.time()
        return {
            "total_keys": len(self.api_keys),
            "active_keys": sum(1 for k in self.key_usage.values() if not k.is_blocked),
            "total_calls": sum(k.total_calls for k in self.key_usage.values()),
            "total_failures": sum(k.total_failures for k in self.key_usage.values()),
            "current_capacity": sum(
                self.RATE_LIMIT_PER_MINUTE - k.calls_this_minute
                for k in self.key_usage.values()
                if not k.is_blocked
            ),
            "keys": [
                {
                    "key": k.api_key[:10] + "...",
                    "calls_this_minute": k.calls_this_minute,
                    "total_calls": k.total_calls,
                    "total_failures": k.total_failures,
                    "is_blocked": k.is_blocked,
                }
                for k in self.key_usage.values()
            ],
        }