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.
Files changed (76) hide show
  1. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/PKG-INFO +3 -1
  2. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/__init__.py +2 -1
  3. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/client.py +140 -20
  4. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/key_manager.py +57 -6
  5. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/models.py +1 -0
  6. python_infrakit_dev-0.1.6/infrakit/llm/providers/__init__.py +6 -0
  7. python_infrakit_dev-0.1.6/infrakit/llm/providers/groq.py +167 -0
  8. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/ai.py +19 -6
  9. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/backend.py +27 -9
  10. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/cli_tool.py +20 -10
  11. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/generator.py +62 -2
  12. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/pipeline.py +17 -8
  13. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/pyproject.toml +2 -1
  14. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/test_llm.py +376 -1
  15. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/test_scaffolders.py +125 -0
  16. python_infrakit_dev-0.1.5/infrakit/llm/providers/__init__.py +0 -5
  17. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/.gitignore +0 -0
  18. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/.python-version +0 -0
  19. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/README.md +0 -0
  20. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/__init__.py +0 -0
  21. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/__init__.py +0 -0
  22. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/__init__.py +0 -0
  23. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/config.py +0 -0
  24. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/deps.py +0 -0
  25. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/init.py +0 -0
  26. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/llm.py +0 -0
  27. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/logger.py +0 -0
  28. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/module.py +0 -0
  29. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/commands/time.py +0 -0
  30. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/cli/main.py +0 -0
  31. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/__init__.py +0 -0
  32. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/__init__.py +0 -0
  33. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/converter.py +0 -0
  34. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/exporter.py +0 -0
  35. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/loader.py +0 -0
  36. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/config/validator.py +0 -0
  37. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/__init__.py +0 -0
  38. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/formatters.py +0 -0
  39. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/handlers.py +0 -0
  40. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/retention.py +0 -0
  41. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/core/logger/setup.py +0 -0
  42. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/__init__.py +0 -0
  43. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/clean.py +0 -0
  44. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/depfile.py +0 -0
  45. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/health.py +0 -0
  46. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/optimizer.py +0 -0
  47. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/deps/scanner.py +0 -0
  48. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/batch.py +0 -0
  49. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/llm_readme.md +0 -0
  50. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/providers/base.py +0 -0
  51. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/providers/gemini.py +0 -0
  52. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/providers/openai.py +0 -0
  53. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/llm/rate_limiter.py +0 -0
  54. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/__init__.py +0 -0
  55. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/scaffolder/registry.py +0 -0
  56. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/time/__init__.py +0 -0
  57. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/infrakit/time/profiler.py +0 -0
  58. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/__init__.py +0 -0
  59. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/__init__.py +0 -0
  60. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/conftest.py +0 -0
  61. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_config.py +0 -0
  62. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_init.py +0 -0
  63. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_logger.py +0 -0
  64. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/cli/test_module.py +0 -0
  65. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/__init__.py +0 -0
  66. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/__init__.py +0 -0
  67. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_converter.py +0 -0
  68. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_exporter.py +0 -0
  69. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_loader.py +0 -0
  70. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/config/test_validator.py +0 -0
  71. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/__init__.py +0 -0
  72. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_formatters.py +0 -0
  73. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_handler.py +0 -0
  74. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_retention.py +0 -0
  75. {python_infrakit_dev-0.1.5 → python_infrakit_dev-0.1.6}/tests/core/logger/test_setup.py +0 -0
  76. {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.5
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-1.5-flash"``.
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
- Provider.OPENAI: OpenAIProvider(model=openai_model),
160
- Provider.GEMINI: GeminiProvider(model=gemini_model),
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, and metadata recording.
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
- return self._sync_single_generate(
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
- return await self._async_single_generate(
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
- # small backoff before same-key retry
466
- await asyncio.sleep(1.0 * (attempt + 1))
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
- time.sleep(1.0 * (attempt + 1))
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.utcnow()
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.utcfromtimestamp(ms.deactivated_at)
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.utcnow()
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.utcfromtimestamp(ms.day_start_epoch)
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()
@@ -19,6 +19,7 @@ from pydantic import BaseModel
19
19
  class Provider(str, Enum):
20
20
  OPENAI = "openai"
21
21
  GEMINI = "gemini"
22
+ GROQ = "groq"
22
23
 
23
24
 
24
25
  class KeyStatus(str, Enum):
@@ -0,0 +1,6 @@
1
+ from .base import BaseProvider
2
+ from .openai import OpenAIProvider
3
+ from .gemini import GeminiProvider
4
+ from .groq import GroqProvider
5
+
6
+ __all__ = ["BaseProvider", "OpenAIProvider", "GeminiProvider", "GroqProvider"]
@@ -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
- "infrakit",
417
- "openai",
418
- "google-genai",
419
- "pydantic>=2.0",
420
- "tqdm",
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]