python-infrakit-dev 0.1.5__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/PKG-INFO +3 -1
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/__init__.py +2 -1
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/client.py +140 -20
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/key_manager.py +57 -6
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/models.py +1 -0
- python_infrakit_dev-0.1.6/infrakit/llm/providers/__init__.py +6 -0
- python_infrakit_dev-0.1.6/infrakit/llm/providers/groq.py +167 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/ai.py +19 -6
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/backend.py +27 -9
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/cli_tool.py +20 -10
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/generator.py +62 -2
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/pipeline.py +17 -8
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/pyproject.toml +2 -1
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/test_llm.py +376 -1
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/test_scaffolders.py +125 -0
- python_infrakit_dev-0.1.5/infrakit/llm/providers/__init__.py +0 -5
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/.gitignore +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/.python-version +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/README.md +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/config.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/deps.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/init.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/llm.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/logger.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/module.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/time.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/main.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/converter.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/exporter.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/loader.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/validator.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/formatters.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/handlers.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/retention.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/setup.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/clean.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/depfile.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/health.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/optimizer.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/scanner.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/batch.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/llm_readme.md +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/providers/base.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/providers/gemini.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/providers/openai.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/rate_limiter.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/registry.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/time/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/time/profiler.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/conftest.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_config.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_init.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_logger.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_module.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_converter.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_exporter.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_loader.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_validator.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/__init__.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_formatters.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_handler.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_retention.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_setup.py +0 -0
- {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/test_time.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-infrakit-dev
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: A comprehensive Python developer infrastructure toolkit
|
|
5
5
|
Project-URL: Homepage, https://github.com/chiragg21/infrakit
|
|
6
6
|
Project-URL: Repository, https://github.com/chiragg21/infrakit
|
|
@@ -12,10 +12,12 @@ Requires-Dist: pyyaml>=6.0.3
|
|
|
12
12
|
Requires-Dist: typer>=0.24.1
|
|
13
13
|
Provides-Extra: all
|
|
14
14
|
Requires-Dist: google-genai>=1.69.0; extra == 'all'
|
|
15
|
+
Requires-Dist: groq>=0.9.0; extra == 'all'
|
|
15
16
|
Requires-Dist: openai>=2.30.0; extra == 'all'
|
|
16
17
|
Requires-Dist: tqdm>=4.67.3; extra == 'all'
|
|
17
18
|
Provides-Extra: llm
|
|
18
19
|
Requires-Dist: google-genai>=1.69.0; extra == 'llm'
|
|
20
|
+
Requires-Dist: groq>=0.9.0; extra == 'llm'
|
|
19
21
|
Requires-Dist: openai>=2.30.0; extra == 'llm'
|
|
20
22
|
Requires-Dist: tqdm>=4.67.3; extra == 'llm'
|
|
21
23
|
Description-Content-Type: text/markdown
|
|
@@ -20,7 +20,7 @@ from .models import (
|
|
|
20
20
|
QuotaConfig,
|
|
21
21
|
RequestMeta,
|
|
22
22
|
)
|
|
23
|
-
from .providers import GeminiProvider, OpenAIProvider
|
|
23
|
+
from .providers import GeminiProvider, GroqProvider, OpenAIProvider
|
|
24
24
|
|
|
25
25
|
__all__ = [
|
|
26
26
|
"LLMClient",
|
|
@@ -32,4 +32,5 @@ __all__ = [
|
|
|
32
32
|
"Provider",
|
|
33
33
|
"OpenAIProvider",
|
|
34
34
|
"GeminiProvider",
|
|
35
|
+
"GroqProvider",
|
|
35
36
|
]
|
|
@@ -80,6 +80,7 @@ from .key_manager import KeyManager
|
|
|
80
80
|
from .models import BatchResult, LLMResponse, Prompt, Provider, QuotaConfig, RequestMeta
|
|
81
81
|
from .providers.base import BaseProvider
|
|
82
82
|
from .providers.gemini import GeminiProvider
|
|
83
|
+
from .providers.groq import GroqProvider
|
|
83
84
|
from .providers.openai import OpenAIProvider
|
|
84
85
|
from .rate_limiter import RateLimiter
|
|
85
86
|
|
|
@@ -103,6 +104,7 @@ class LLMClient:
|
|
|
103
104
|
{
|
|
104
105
|
"openai_keys": ["sk-key1", "sk-key2"],
|
|
105
106
|
"gemini_keys": ["AIza-key1"],
|
|
107
|
+
"groq_keys": ["gsk_key1"],
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
storage_dir Path to a folder where key state is persisted.
|
|
@@ -119,7 +121,17 @@ class LLMClient:
|
|
|
119
121
|
meta_window How many recent request metadata records to keep per key.
|
|
120
122
|
Default: 50.
|
|
121
123
|
openai_model Default OpenAI model. Default: ``"gpt-4o-mini"``.
|
|
122
|
-
gemini_model Default Gemini model. Default: ``"gemini-
|
|
124
|
+
gemini_model Default Gemini model. Default: ``"gemini-2.5-flash"``.
|
|
125
|
+
groq_model Default Groq model. Default: ``"llama-3.3-70b-versatile"``.
|
|
126
|
+
fallback_order Provider priority list for automatic fallback when all keys
|
|
127
|
+
for the primary provider are exhausted. Example::
|
|
128
|
+
|
|
129
|
+
fallback_order=["gemini", "groq"]
|
|
130
|
+
|
|
131
|
+
When a ``generate()`` call fails with "all keys exhausted",
|
|
132
|
+
the client retries each provider in *fallback_order* (skipping
|
|
133
|
+
the already-tried primary). Fallback only triggers on key
|
|
134
|
+
exhaustion, not on transient errors.
|
|
123
135
|
show_progress Show tqdm progress bar during batch calls. Default: True.
|
|
124
136
|
"""
|
|
125
137
|
|
|
@@ -135,9 +147,12 @@ class LLMClient:
|
|
|
135
147
|
meta_window: int = _DEFAULT_META_WINDOW,
|
|
136
148
|
openai_model: Optional[str] = None,
|
|
137
149
|
gemini_model: Optional[str] = None,
|
|
150
|
+
groq_model: Optional[str] = None,
|
|
151
|
+
fallback_order: Optional[list[str]] = None,
|
|
138
152
|
show_progress: bool = True,
|
|
139
153
|
) -> None:
|
|
140
154
|
self._mode = mode
|
|
155
|
+
self._fallback_order: list[str] = fallback_order or []
|
|
141
156
|
self._max_concurrent = max_concurrent
|
|
142
157
|
self._key_retries = key_retries
|
|
143
158
|
self._schema_retries = schema_retries
|
|
@@ -154,11 +169,17 @@ class LLMClient:
|
|
|
154
169
|
# Rate limiter (RPM/TPM gating)
|
|
155
170
|
self._rl = RateLimiter(self._km)
|
|
156
171
|
|
|
157
|
-
# Providers
|
|
158
|
-
self._providers: dict[str, BaseProvider] = {
|
|
159
|
-
|
|
160
|
-
Provider.
|
|
161
|
-
|
|
172
|
+
# Providers — only register those whose SDK is installed
|
|
173
|
+
self._providers: dict[str, BaseProvider] = {}
|
|
174
|
+
for enum_val, cls, mdl in [
|
|
175
|
+
(Provider.OPENAI, OpenAIProvider, openai_model),
|
|
176
|
+
(Provider.GEMINI, GeminiProvider, gemini_model),
|
|
177
|
+
(Provider.GROQ, GroqProvider, groq_model),
|
|
178
|
+
]:
|
|
179
|
+
try:
|
|
180
|
+
self._providers[enum_val] = cls(model=mdl)
|
|
181
|
+
except ImportError:
|
|
182
|
+
pass # SDK not installed; skip this provider
|
|
162
183
|
|
|
163
184
|
# ── public: single generate ────────────────────────────────────────────
|
|
164
185
|
|
|
@@ -178,15 +199,24 @@ class LLMClient:
|
|
|
178
199
|
If you are inside an async function use ``await async_generate()``
|
|
179
200
|
instead; that path uses the async SDK clients end-to-end.
|
|
180
201
|
|
|
181
|
-
Handles key rotation, RPM waiting, retries,
|
|
202
|
+
Handles key rotation, RPM waiting, retries, metadata recording, and
|
|
203
|
+
automatic provider fallback when ``fallback_order`` is configured.
|
|
182
204
|
Always returns an LLMResponse — check ``.error`` for failures.
|
|
183
205
|
"""
|
|
184
|
-
|
|
185
|
-
prompt=prompt,
|
|
186
|
-
response_model=response_model,
|
|
187
|
-
provider=provider,
|
|
188
|
-
**kwargs,
|
|
206
|
+
result = self._sync_single_generate(
|
|
207
|
+
prompt=prompt, response_model=response_model, provider=provider, **kwargs
|
|
189
208
|
)
|
|
209
|
+
if result.error is None or not self._fallback_order:
|
|
210
|
+
return result
|
|
211
|
+
for fb_prov in self._fallback_order:
|
|
212
|
+
if fb_prov == provider or fb_prov not in self._providers:
|
|
213
|
+
continue
|
|
214
|
+
fb = self._sync_single_generate(
|
|
215
|
+
prompt=prompt, response_model=response_model, provider=fb_prov, **kwargs
|
|
216
|
+
)
|
|
217
|
+
if fb.error is None:
|
|
218
|
+
return fb
|
|
219
|
+
return result
|
|
190
220
|
|
|
191
221
|
async def async_generate(
|
|
192
222
|
self,
|
|
@@ -196,12 +226,20 @@ class LLMClient:
|
|
|
196
226
|
**kwargs: Any,
|
|
197
227
|
) -> LLMResponse:
|
|
198
228
|
"""Async version of generate() — await this inside an async context."""
|
|
199
|
-
|
|
200
|
-
prompt=prompt,
|
|
201
|
-
response_model=response_model,
|
|
202
|
-
provider=provider,
|
|
203
|
-
**kwargs,
|
|
229
|
+
result = await self._async_single_generate(
|
|
230
|
+
prompt=prompt, response_model=response_model, provider=provider, **kwargs
|
|
204
231
|
)
|
|
232
|
+
if result.error is None or not self._fallback_order:
|
|
233
|
+
return result
|
|
234
|
+
for fb_prov in self._fallback_order:
|
|
235
|
+
if fb_prov == provider or fb_prov not in self._providers:
|
|
236
|
+
continue
|
|
237
|
+
fb = await self._async_single_generate(
|
|
238
|
+
prompt=prompt, response_model=response_model, provider=fb_prov, **kwargs
|
|
239
|
+
)
|
|
240
|
+
if fb.error is None:
|
|
241
|
+
return fb
|
|
242
|
+
return result
|
|
205
243
|
|
|
206
244
|
# ── public: batch generate ─────────────────────────────────────────────
|
|
207
245
|
|
|
@@ -384,6 +422,87 @@ class LLMClient:
|
|
|
384
422
|
)
|
|
385
423
|
print(sep)
|
|
386
424
|
|
|
425
|
+
# ── public: runtime key management ────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
def add_key(self, provider: str, key: str) -> None:
|
|
428
|
+
"""
|
|
429
|
+
Add a new API key for *provider* at runtime.
|
|
430
|
+
|
|
431
|
+
The key is immediately available for new requests. Adding a key
|
|
432
|
+
that is already registered is a no-op. Thread-safe.
|
|
433
|
+
|
|
434
|
+
Parameters
|
|
435
|
+
----------
|
|
436
|
+
provider ``"openai"``, ``"gemini"``, or ``"groq"``.
|
|
437
|
+
key Raw API key string.
|
|
438
|
+
"""
|
|
439
|
+
self._km.add_key(provider, key)
|
|
440
|
+
|
|
441
|
+
def remove_key(self, provider: str, key_id: str) -> None:
|
|
442
|
+
"""
|
|
443
|
+
Remove a key by its ``key_id`` prefix (first 8 chars).
|
|
444
|
+
No-op if not found. Thread-safe.
|
|
445
|
+
"""
|
|
446
|
+
self._km.remove_key(provider, key_id)
|
|
447
|
+
|
|
448
|
+
# ── class method: environment-variable bootstrap ───────────────────────
|
|
449
|
+
|
|
450
|
+
@classmethod
|
|
451
|
+
def from_env(
|
|
452
|
+
cls,
|
|
453
|
+
storage_dir: Optional[str | "Path"] = None,
|
|
454
|
+
quota_file: Optional[str | "Path"] = None,
|
|
455
|
+
mode: Literal["async", "threaded"] = "async",
|
|
456
|
+
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
|
|
457
|
+
key_retries: int = _DEFAULT_KEY_RETRIES,
|
|
458
|
+
schema_retries: int = _DEFAULT_SCHEMA_RETRIES,
|
|
459
|
+
meta_window: int = _DEFAULT_META_WINDOW,
|
|
460
|
+
openai_model: Optional[str] = None,
|
|
461
|
+
gemini_model: Optional[str] = None,
|
|
462
|
+
groq_model: Optional[str] = None,
|
|
463
|
+
fallback_order: Optional[list[str]] = None,
|
|
464
|
+
show_progress: bool = True,
|
|
465
|
+
) -> "LLMClient":
|
|
466
|
+
"""
|
|
467
|
+
Create an LLMClient with API keys read from environment variables.
|
|
468
|
+
|
|
469
|
+
Reads ``OPENAI_API_KEY``, ``GEMINI_API_KEY``, and ``GROQ_API_KEY``.
|
|
470
|
+
Each variable may contain a comma-separated list of keys::
|
|
471
|
+
|
|
472
|
+
OPENAI_API_KEY=sk-key1,sk-key2
|
|
473
|
+
GROQ_API_KEY=gsk-key1
|
|
474
|
+
|
|
475
|
+
Example::
|
|
476
|
+
|
|
477
|
+
client = LLMClient.from_env(fallback_order=["gemini", "groq"])
|
|
478
|
+
"""
|
|
479
|
+
import os
|
|
480
|
+
|
|
481
|
+
def _split(env_var: str) -> list[str]:
|
|
482
|
+
val = os.environ.get(env_var, "").strip()
|
|
483
|
+
return [k.strip() for k in val.split(",") if k.strip()] if val else []
|
|
484
|
+
|
|
485
|
+
keys = {
|
|
486
|
+
"openai_keys": _split("OPENAI_API_KEY"),
|
|
487
|
+
"gemini_keys": _split("GEMINI_API_KEY"),
|
|
488
|
+
"groq_keys": _split("GROQ_API_KEY"),
|
|
489
|
+
}
|
|
490
|
+
return cls(
|
|
491
|
+
keys=keys,
|
|
492
|
+
storage_dir=storage_dir,
|
|
493
|
+
quota_file=quota_file,
|
|
494
|
+
mode=mode,
|
|
495
|
+
max_concurrent=max_concurrent,
|
|
496
|
+
key_retries=key_retries,
|
|
497
|
+
schema_retries=schema_retries,
|
|
498
|
+
meta_window=meta_window,
|
|
499
|
+
openai_model=openai_model,
|
|
500
|
+
gemini_model=gemini_model,
|
|
501
|
+
groq_model=groq_model,
|
|
502
|
+
fallback_order=fallback_order,
|
|
503
|
+
show_progress=show_progress,
|
|
504
|
+
)
|
|
505
|
+
|
|
387
506
|
# ── internal: async single generate ───────────────────────────────────
|
|
388
507
|
|
|
389
508
|
async def _async_single_generate(
|
|
@@ -462,8 +581,8 @@ class LLMClient:
|
|
|
462
581
|
)
|
|
463
582
|
break # rotate to next key/model
|
|
464
583
|
if attempt < self._key_retries:
|
|
465
|
-
#
|
|
466
|
-
await asyncio.sleep(
|
|
584
|
+
# exponential backoff: 1s, 2s, 4s, …
|
|
585
|
+
await asyncio.sleep(2.0 ** attempt)
|
|
467
586
|
# else: fall through and rotate key
|
|
468
587
|
|
|
469
588
|
keys_tried.add((ks.key_hash, prov_impl.model))
|
|
@@ -541,7 +660,8 @@ class LLMClient:
|
|
|
541
660
|
)
|
|
542
661
|
break
|
|
543
662
|
if attempt < self._key_retries:
|
|
544
|
-
|
|
663
|
+
# exponential backoff: 1s, 2s, 4s, …
|
|
664
|
+
time.sleep(2.0 ** attempt)
|
|
545
665
|
|
|
546
666
|
keys_tried.add((ks.key_hash, prov_impl.model))
|
|
547
667
|
|
|
@@ -210,7 +210,7 @@ class KeyManager:
|
|
|
210
210
|
|
|
211
211
|
Parameters
|
|
212
212
|
----------
|
|
213
|
-
keys ``{"openai_keys": [...], "gemini_keys": [...]}``.
|
|
213
|
+
keys ``{"openai_keys": [...], "gemini_keys": [...], "groq_keys": [...]}``.
|
|
214
214
|
storage_dir Folder where ``key_state.json`` is written.
|
|
215
215
|
Defaults to ``~/.infrakit/llm/``.
|
|
216
216
|
quota_file Path to a JSON quota definition file.
|
|
@@ -237,7 +237,7 @@ class KeyManager:
|
|
|
237
237
|
self._storage_path = storage_path / _STATE_FILE
|
|
238
238
|
|
|
239
239
|
# quota file: explicit arg > default location > skip
|
|
240
|
-
if quota_file is not None:
|
|
240
|
+
if quota_file is not None and str(quota_file).strip():
|
|
241
241
|
self._quota_file: Optional[Path] = Path(quota_file)
|
|
242
242
|
elif (DEFAULT_LLM_DIR / _QUOTA_FILE).exists():
|
|
243
243
|
self._quota_file = DEFAULT_LLM_DIR / _QUOTA_FILE
|
|
@@ -252,20 +252,25 @@ class KeyManager:
|
|
|
252
252
|
self._states: dict[str, list[KeyState]] = {
|
|
253
253
|
Provider.OPENAI: [],
|
|
254
254
|
Provider.GEMINI: [],
|
|
255
|
+
Provider.GROQ: [],
|
|
255
256
|
}
|
|
256
257
|
self._rr_index: dict[str, dict[str, int]] = {
|
|
257
258
|
# per-provider, per-model round-robin index
|
|
258
259
|
Provider.OPENAI: {},
|
|
259
260
|
Provider.GEMINI: {},
|
|
261
|
+
Provider.GROQ: {},
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
provider_map = {
|
|
263
265
|
"openai_keys": Provider.OPENAI,
|
|
264
266
|
"gemini_keys": Provider.GEMINI,
|
|
267
|
+
"groq_keys": Provider.GROQ,
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
for key_field, provider in provider_map.items():
|
|
268
271
|
for raw_key in keys.get(key_field, []):
|
|
272
|
+
if not raw_key or not isinstance(raw_key, str):
|
|
273
|
+
continue # skip invalid entries
|
|
269
274
|
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
270
275
|
key_id = raw_key[:8]
|
|
271
276
|
|
|
@@ -428,6 +433,52 @@ class KeyManager:
|
|
|
428
433
|
ks.model_states["__all__"] = sentinel
|
|
429
434
|
self._persist()
|
|
430
435
|
|
|
436
|
+
# ── public: runtime key management ────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
def add_key(self, provider: str, raw_key: str) -> None:
|
|
439
|
+
"""
|
|
440
|
+
Add a new API key at runtime without re-initialising the client.
|
|
441
|
+
|
|
442
|
+
The key becomes immediately available for new requests.
|
|
443
|
+
Adding a key that is already registered is a no-op (idempotent).
|
|
444
|
+
Thread-safe.
|
|
445
|
+
"""
|
|
446
|
+
import hashlib
|
|
447
|
+
|
|
448
|
+
if not raw_key or not isinstance(raw_key, str):
|
|
449
|
+
raise ValueError(
|
|
450
|
+
f"Invalid API key for {provider!r}: must be a non-empty string."
|
|
451
|
+
)
|
|
452
|
+
if provider not in self._states:
|
|
453
|
+
raise ValueError(
|
|
454
|
+
f"Unknown provider {provider!r}. "
|
|
455
|
+
f"Valid options: {list(self._states)}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
with self._lock:
|
|
459
|
+
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
460
|
+
if any(ks.key_hash == key_hash for ks in self._states[provider]):
|
|
461
|
+
return # already registered
|
|
462
|
+
key_id = raw_key[:8]
|
|
463
|
+
ks = KeyState(provider=provider, key_id=key_id, key_hash=key_hash)
|
|
464
|
+
ks._raw_key = raw_key # type: ignore[attr-defined]
|
|
465
|
+
self._states[provider].append(ks)
|
|
466
|
+
self._persist()
|
|
467
|
+
|
|
468
|
+
def remove_key(self, provider: str, key_id: str) -> None:
|
|
469
|
+
"""
|
|
470
|
+
Remove a key by its ``key_id`` prefix (first 8 chars).
|
|
471
|
+
No-op if the key is not found. Thread-safe.
|
|
472
|
+
"""
|
|
473
|
+
with self._lock:
|
|
474
|
+
before = len(self._states.get(provider, []))
|
|
475
|
+
self._states[provider] = [
|
|
476
|
+
ks for ks in self._states.get(provider, [])
|
|
477
|
+
if ks.key_id != key_id
|
|
478
|
+
]
|
|
479
|
+
if len(self._states[provider]) < before:
|
|
480
|
+
self._persist()
|
|
481
|
+
|
|
431
482
|
# ── public: quota config ───────────────────────────────────────────────
|
|
432
483
|
|
|
433
484
|
def set_quota(
|
|
@@ -553,11 +604,11 @@ class KeyManager:
|
|
|
553
604
|
"""Auto-reactivate a model if its daily reset hour has passed."""
|
|
554
605
|
if ms.status != ModelStatus.INACTIVE or ms.deactivated_at is None:
|
|
555
606
|
return
|
|
556
|
-
now_utc = datetime.datetime.
|
|
607
|
+
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
|
557
608
|
reset_today = now_utc.replace(
|
|
558
609
|
hour=ms.reset_hour_utc, minute=0, second=0, microsecond=0
|
|
559
610
|
)
|
|
560
|
-
deactivated_dt = datetime.datetime.
|
|
611
|
+
deactivated_dt = datetime.datetime.fromtimestamp(ms.deactivated_at, datetime.timezone.utc)
|
|
561
612
|
if deactivated_dt < reset_today <= now_utc:
|
|
562
613
|
ms.status = ModelStatus.ACTIVE
|
|
563
614
|
ms.deactivated_at = None
|
|
@@ -571,11 +622,11 @@ class KeyManager:
|
|
|
571
622
|
|
|
572
623
|
def _maybe_reset_day(self, ms: ModelState) -> None:
|
|
573
624
|
"""Reset daily token counter if the reset hour has passed today."""
|
|
574
|
-
now_utc = datetime.datetime.
|
|
625
|
+
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
|
575
626
|
reset_today = now_utc.replace(
|
|
576
627
|
hour=ms.reset_hour_utc, minute=0, second=0, microsecond=0
|
|
577
628
|
)
|
|
578
|
-
day_start_dt = datetime.datetime.
|
|
629
|
+
day_start_dt = datetime.datetime.fromtimestamp(ms.day_start_epoch, datetime.timezone.utc)
|
|
579
630
|
if day_start_dt < reset_today <= now_utc:
|
|
580
631
|
ms.day_token_total = 0
|
|
581
632
|
ms.day_start_epoch = time.time()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
infrakit.llm.providers.groq
|
|
3
|
+
-----------------------------
|
|
4
|
+
Groq provider — wraps the groq Python SDK.
|
|
5
|
+
|
|
6
|
+
Install dependency::
|
|
7
|
+
|
|
8
|
+
pip install groq
|
|
9
|
+
|
|
10
|
+
Supported models (default): llama-3.3-70b-versatile
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Optional, Type
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from ..models import LLMResponse, Prompt
|
|
21
|
+
from .base import BaseProvider
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GroqProvider(BaseProvider):
|
|
25
|
+
"""
|
|
26
|
+
Provider for Groq's Chat Completions API.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
model Model string to use. Defaults to ``llama-3.3-70b-versatile``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
|
34
|
+
PROVIDER_NAME = "groq"
|
|
35
|
+
|
|
36
|
+
def __init__(self, model: Optional[str] = None) -> None:
|
|
37
|
+
super().__init__(model)
|
|
38
|
+
self._check_sdk()
|
|
39
|
+
|
|
40
|
+
# ── public interface ───────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
async def async_generate(
|
|
43
|
+
self,
|
|
44
|
+
prompt: Prompt,
|
|
45
|
+
api_key: str,
|
|
46
|
+
response_model: Optional[Type[BaseModel]] = None,
|
|
47
|
+
schema_retries: int = 2,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> LLMResponse:
|
|
50
|
+
"""Async generate using groq.AsyncGroq."""
|
|
51
|
+
from groq import AsyncGroq
|
|
52
|
+
|
|
53
|
+
client = AsyncGroq(api_key=api_key)
|
|
54
|
+
messages = self._build_messages(prompt)
|
|
55
|
+
t0 = time.perf_counter()
|
|
56
|
+
|
|
57
|
+
response = await client.chat.completions.create(
|
|
58
|
+
model=self.model,
|
|
59
|
+
messages=messages,
|
|
60
|
+
**kwargs,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
latency_ms = (time.perf_counter() - t0) * 1000
|
|
64
|
+
return self._build_response(response, latency_ms, response_model, schema_retries, api_key)
|
|
65
|
+
|
|
66
|
+
def sync_generate(
|
|
67
|
+
self,
|
|
68
|
+
prompt: Prompt,
|
|
69
|
+
api_key: str,
|
|
70
|
+
response_model: Optional[Type[BaseModel]] = None,
|
|
71
|
+
schema_retries: int = 2,
|
|
72
|
+
**kwargs: Any,
|
|
73
|
+
) -> LLMResponse:
|
|
74
|
+
"""Sync generate using groq.Groq (the blocking SDK client)."""
|
|
75
|
+
from groq import Groq
|
|
76
|
+
|
|
77
|
+
client = Groq(api_key=api_key)
|
|
78
|
+
messages = self._build_messages(prompt)
|
|
79
|
+
t0 = time.perf_counter()
|
|
80
|
+
|
|
81
|
+
response = client.chat.completions.create(
|
|
82
|
+
model=self.model,
|
|
83
|
+
messages=messages,
|
|
84
|
+
**kwargs,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
latency_ms = (time.perf_counter() - t0) * 1000
|
|
88
|
+
return self._build_response(response, latency_ms, response_model, schema_retries, api_key)
|
|
89
|
+
|
|
90
|
+
# ── internal helpers ───────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _build_messages(prompt: Prompt) -> list[dict]:
|
|
94
|
+
messages = []
|
|
95
|
+
if prompt.system:
|
|
96
|
+
messages.append({"role": "system", "content": prompt.system})
|
|
97
|
+
messages.append({"role": "user", "content": prompt.user})
|
|
98
|
+
return messages
|
|
99
|
+
|
|
100
|
+
def _build_response(
|
|
101
|
+
self,
|
|
102
|
+
response: Any,
|
|
103
|
+
latency_ms: float,
|
|
104
|
+
response_model: Optional[Type[BaseModel]],
|
|
105
|
+
schema_retries: int,
|
|
106
|
+
api_key: str,
|
|
107
|
+
) -> LLMResponse:
|
|
108
|
+
choice = response.choices[0]
|
|
109
|
+
content = choice.message.content or ""
|
|
110
|
+
|
|
111
|
+
usage = response.usage
|
|
112
|
+
input_tokens = usage.prompt_tokens if usage else 0
|
|
113
|
+
output_tokens = usage.completion_tokens if usage else 0
|
|
114
|
+
total_tokens = usage.total_tokens if usage else 0
|
|
115
|
+
|
|
116
|
+
parsed = None
|
|
117
|
+
schema_matched = False
|
|
118
|
+
|
|
119
|
+
if response_model is not None:
|
|
120
|
+
parsed, schema_matched = self._validate_schema(
|
|
121
|
+
content, response_model, schema_retries
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return LLMResponse(
|
|
125
|
+
content=content,
|
|
126
|
+
parsed=parsed,
|
|
127
|
+
schema_matched=schema_matched,
|
|
128
|
+
provider=self.PROVIDER_NAME,
|
|
129
|
+
model=self.model,
|
|
130
|
+
key_id=api_key[:8],
|
|
131
|
+
input_tokens=input_tokens,
|
|
132
|
+
output_tokens=output_tokens,
|
|
133
|
+
total_tokens=total_tokens,
|
|
134
|
+
latency_ms=latency_ms,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _check_sdk() -> None:
|
|
139
|
+
try:
|
|
140
|
+
import groq # noqa: F401
|
|
141
|
+
except ImportError as exc:
|
|
142
|
+
raise ImportError(
|
|
143
|
+
"groq package is required for GroqProvider. "
|
|
144
|
+
"Install it with: pip install groq"
|
|
145
|
+
) from exc
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _is_quota_error(exc: Exception) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Groq-specific quota/auth HTTP status codes.
|
|
151
|
+
|
|
152
|
+
429 with "rate_limit_exceeded" is *transient* (burst limit) and should
|
|
153
|
+
be retried with backoff — NOT treated as a permanent quota exhaustion.
|
|
154
|
+
Only 401/402 and 429 with "quota" or "billing" in the message warrant
|
|
155
|
+
immediate model deactivation.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
from groq import APIStatusError
|
|
159
|
+
if isinstance(exc, APIStatusError):
|
|
160
|
+
if exc.status_code in (401, 402):
|
|
161
|
+
return True
|
|
162
|
+
if exc.status_code == 429:
|
|
163
|
+
msg = str(exc).lower()
|
|
164
|
+
return "quota" in msg or "billing" in msg
|
|
165
|
+
except ImportError:
|
|
166
|
+
pass
|
|
167
|
+
return BaseProvider._is_quota_error(exc)
|
|
@@ -40,6 +40,8 @@ from infrakit.scaffolder.generator import (
|
|
|
40
40
|
_write,
|
|
41
41
|
_config_content,
|
|
42
42
|
_gitignore,
|
|
43
|
+
_infrakit_dep,
|
|
44
|
+
_pkg_dep,
|
|
43
45
|
_logger_util,
|
|
44
46
|
_src_init,
|
|
45
47
|
_tests_init,
|
|
@@ -124,9 +126,11 @@ def _load_keys() -> dict:
|
|
|
124
126
|
# fall back to keys declared in the config file
|
|
125
127
|
openai_key = _cfg.get("OPENAI_API_KEY", "")
|
|
126
128
|
gemini_key = _cfg.get("GEMINI_API_KEY", "")
|
|
129
|
+
groq_key = _cfg.get("GROQ_API_KEY", "")
|
|
127
130
|
return {{
|
|
128
131
|
"openai_keys": [openai_key] if openai_key else [],
|
|
129
132
|
"gemini_keys": [gemini_key] if gemini_key else [],
|
|
133
|
+
"groq_keys": [groq_key] if groq_key else [],
|
|
130
134
|
}}
|
|
131
135
|
|
|
132
136
|
|
|
@@ -142,6 +146,7 @@ llm: LLMClient = LLMClient(
|
|
|
142
146
|
max_concurrent=int(_cfg.get("LLM_CONCURRENCY", 3)),
|
|
143
147
|
openai_model=_cfg.get("OPENAI_MODEL") or None,
|
|
144
148
|
gemini_model=_cfg.get("GEMINI_MODEL") or None,
|
|
149
|
+
groq_model=_cfg.get("GROQ_MODEL") or None,
|
|
145
150
|
)
|
|
146
151
|
|
|
147
152
|
__all__ = ["llm", "Prompt"]
|
|
@@ -316,7 +321,8 @@ def _keys_json_template() -> str:
|
|
|
316
321
|
{
|
|
317
322
|
"_comment": "Fill in your API keys. Never commit this file — it is in .gitignore.",
|
|
318
323
|
"openai_keys": [],
|
|
319
|
-
"gemini_keys":
|
|
324
|
+
"gemini_keys": [],
|
|
325
|
+
"groq_keys": []
|
|
320
326
|
}
|
|
321
327
|
"""
|
|
322
328
|
|
|
@@ -401,6 +407,12 @@ pytest
|
|
|
401
407
|
|
|
402
408
|
def _ai_pyproject(project_name: str, version: str, description: str, author: str) -> str:
|
|
403
409
|
author_line = f' "{author}",' if author else ' # "Your Name <you@example.com>",'
|
|
410
|
+
infrakit_dep = _infrakit_dep()
|
|
411
|
+
openai_dep = _pkg_dep("openai")
|
|
412
|
+
genai_dep = _pkg_dep("google-genai")
|
|
413
|
+
groq_dep = _pkg_dep("groq")
|
|
414
|
+
pydantic_dep = _pkg_dep("pydantic")
|
|
415
|
+
tqdm_dep = _pkg_dep("tqdm")
|
|
404
416
|
return f"""\
|
|
405
417
|
[project]
|
|
406
418
|
name = "{project_name}"
|
|
@@ -413,11 +425,12 @@ authors = [
|
|
|
413
425
|
]
|
|
414
426
|
|
|
415
427
|
dependencies = [
|
|
416
|
-
|
|
417
|
-
"
|
|
418
|
-
"
|
|
419
|
-
"
|
|
420
|
-
"
|
|
428
|
+
{infrakit_dep},
|
|
429
|
+
"{openai_dep}",
|
|
430
|
+
"{genai_dep}",
|
|
431
|
+
"{groq_dep}",
|
|
432
|
+
"{pydantic_dep}",
|
|
433
|
+
"{tqdm_dep}",
|
|
421
434
|
]
|
|
422
435
|
|
|
423
436
|
[project.optional-dependencies]
|