botnesia-core 0.1.0__py3-none-any.whl

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 (70) hide show
  1. agent_observability.py +402 -0
  2. agent_os.py +180 -0
  3. agent_registry.py +170 -0
  4. ai_providers/__init__.py +20 -0
  5. ai_providers/base.py +30 -0
  6. ai_providers/deepseek.py +206 -0
  7. ai_providers/gemini.py +441 -0
  8. ai_providers/groq_provider.py +176 -0
  9. ai_providers/openrouter.py +254 -0
  10. ai_providers/router.py +371 -0
  11. ai_providers/types.py +73 -0
  12. anti_hallucination_engine.py +119 -0
  13. base.py +631 -0
  14. botnesia_core-0.1.0.dist-info/METADATA +253 -0
  15. botnesia_core-0.1.0.dist-info/RECORD +70 -0
  16. botnesia_core-0.1.0.dist-info/WHEEL +5 -0
  17. botnesia_core-0.1.0.dist-info/licenses/LICENSE +202 -0
  18. botnesia_core-0.1.0.dist-info/licenses/NOTICE +5 -0
  19. botnesia_core-0.1.0.dist-info/top_level.txt +34 -0
  20. cognitive_loop/__init__.py +16 -0
  21. cognitive_loop/loop.py +151 -0
  22. cognitive_loop/worker.py +32 -0
  23. cost_intelligence.py +196 -0
  24. devil_advocate_agent.py +116 -0
  25. evaluation/__init__.py +15 -0
  26. evaluation/evaluator.py +115 -0
  27. evaluation/schema.py +30 -0
  28. event_bus/__init__.py +46 -0
  29. event_bus/bus.py +101 -0
  30. event_bus/events.py +26 -0
  31. feature_flags/__init__.py +27 -0
  32. feature_flags/flags.py +101 -0
  33. first_principle_agent.py +139 -0
  34. groq_knowledge.py +345 -0
  35. intent_classifier.py +84 -0
  36. kb_embeddings.py +98 -0
  37. knowledge_access_engine.py +209 -0
  38. long_term_memory/__init__.py +19 -0
  39. long_term_memory/schema.py +57 -0
  40. long_term_memory/store.py +126 -0
  41. mcp_client.py +147 -0
  42. mcp_registry.py +148 -0
  43. multi_agent_orchestrator.py +437 -0
  44. perf_cache.py +76 -0
  45. planner_agent.py +81 -0
  46. platform_state/__init__.py +22 -0
  47. platform_state/base.py +73 -0
  48. platform_state/factory.py +26 -0
  49. platform_state/inprocess.py +140 -0
  50. platform_state/redis_store.py +124 -0
  51. policy_engine/__init__.py +21 -0
  52. policy_engine/engine.py +98 -0
  53. policy_engine/loader.py +76 -0
  54. prompt_registry/__init__.py +22 -0
  55. prompt_registry/factory.py +19 -0
  56. prompt_registry/registry.py +160 -0
  57. prompt_registry/schema.py +37 -0
  58. socratic_reasoning.py +119 -0
  59. task_engine.py +143 -0
  60. task_runtime/__init__.py +22 -0
  61. task_runtime/monitor.py +142 -0
  62. task_runtime/repository.py +230 -0
  63. task_runtime/runner.py +344 -0
  64. task_runtime/schema.py +64 -0
  65. task_runtime/worker.py +65 -0
  66. tool_executor.py +836 -0
  67. tool_registry.py +308 -0
  68. uncertainty_engine.py +195 -0
  69. vendor_bootstrap.py +28 -0
  70. workflow_engine.py +646 -0
ai_providers/gemini.py ADDED
@@ -0,0 +1,441 @@
1
+ """
2
+ ai_providers/gemini.py — Full Gemini Content API client.
3
+
4
+ Features: retries, timeout, streaming, JSON mode, image/PDF input,
5
+ function/tool calling, safety handling, token counting, usage logging.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ import json
12
+ import logging
13
+ import time
14
+ from typing import Any, AsyncGenerator
15
+
16
+ import httpx
17
+
18
+ from ai_providers.base import AIProvider
19
+ from ai_providers.types import LLMRequest, LLMResponse
20
+
21
+ logger = logging.getLogger("botnesia.gemini")
22
+
23
+ _BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
24
+
25
+ # Gemini finish reasons that indicate a safety block (treat as empty, not error)
26
+ _SAFETY_FINISH_REASONS = frozenset({"SAFETY", "RECITATION", "PROHIBITED_CONTENT"})
27
+
28
+ # HTTP status codes that should be retried
29
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
30
+
31
+ # Pricing per 1M tokens (USD) — update via AI_MODEL_PRICING_JSON env var
32
+ GEMINI_PRICING: dict[str, dict[str, float]] = {
33
+ "gemini-2.5-flash": {"input": 0.075, "output": 0.30},
34
+ "gemini-2.5-flash-preview-05-20": {"input": 0.075, "output": 0.30},
35
+ "gemini-2.5-pro": {"input": 1.25, "output": 10.00},
36
+ "gemini-2.5-pro-preview-06-05": {"input": 1.25, "output": 10.00},
37
+ "gemini-1.5-flash": {"input": 0.075, "output": 0.30},
38
+ "gemini-1.5-pro": {"input": 1.25, "output": 5.00},
39
+ }
40
+
41
+
42
+ def _add_token_usage(model: str, prompt_tokens: int, completion_tokens: int) -> None:
43
+ try:
44
+ from agent_observability import add_token_usage
45
+ add_token_usage(model=model, prompt_tokens=prompt_tokens,
46
+ completion_tokens=completion_tokens)
47
+ except Exception:
48
+ pass
49
+
50
+
51
+ class GeminiProvider(AIProvider):
52
+ """
53
+ Full-featured async Gemini client.
54
+
55
+ Connection pooling: one httpx.AsyncClient is created per provider instance
56
+ and reused across requests (call `await provider.aclose()` on shutdown).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ api_key: str,
62
+ model: str = "gemini-2.5-flash",
63
+ pro_model: str = "gemini-2.5-pro",
64
+ timeout: float = 30.0,
65
+ max_retries: int = 3,
66
+ ):
67
+ self.api_key = api_key
68
+ self.model = model
69
+ self.pro_model = pro_model
70
+ self.timeout = timeout
71
+ self.max_retries = max_retries
72
+ self._client: httpx.AsyncClient | None = None
73
+
74
+ # ── lifecycle ───────────────────────────────────────────────────────────
75
+
76
+ def _get_client(self) -> httpx.AsyncClient:
77
+ if self._client is None or self._client.is_closed:
78
+ self._client = httpx.AsyncClient(timeout=self.timeout)
79
+ return self._client
80
+
81
+ async def aclose(self) -> None:
82
+ if self._client and not self._client.is_closed:
83
+ await self._client.aclose()
84
+ self._client = None
85
+
86
+ # ── AIProvider interface ─────────────────────────────────────────────────
87
+
88
+ def is_available(self) -> bool:
89
+ return bool(self.api_key)
90
+
91
+ @property
92
+ def provider_name(self) -> str:
93
+ return "gemini"
94
+
95
+ @property
96
+ def default_model(self) -> str:
97
+ return self.model
98
+
99
+ # ── payload builders ─────────────────────────────────────────────────────
100
+
101
+ def _build_contents(
102
+ self,
103
+ messages: list[dict],
104
+ images: list[bytes | str] | None = None,
105
+ pdfs: list[bytes | str] | None = None,
106
+ ) -> tuple[list[dict], list[dict]]:
107
+ """Returns (system_parts, contents)."""
108
+ system_parts: list[dict] = []
109
+ contents: list[dict] = []
110
+
111
+ for i, msg in enumerate(messages):
112
+ role = str(msg.get("role") or "user")
113
+ content = msg.get("content") or ""
114
+
115
+ if role == "system":
116
+ if isinstance(content, str):
117
+ system_parts.append({"text": content})
118
+ continue
119
+
120
+ gemini_role = "model" if role == "assistant" else "user"
121
+
122
+ # Build parts list for this message
123
+ parts: list[dict] = []
124
+ if isinstance(content, str) and content:
125
+ parts.append({"text": content})
126
+ elif isinstance(content, list):
127
+ for block in content:
128
+ if isinstance(block, dict):
129
+ if block.get("type") == "text":
130
+ parts.append({"text": block.get("text", "")})
131
+ elif block.get("type") == "image_url":
132
+ url = (block.get("image_url") or {}).get("url", "")
133
+ if url.startswith("data:"):
134
+ mime, b64 = _parse_data_url(url)
135
+ parts.append({"inline_data": {"mime_type": mime, "data": b64}})
136
+
137
+ # Attach images/PDFs to the last user message
138
+ is_last_user = (gemini_role == "user" and i == len(messages) - 1)
139
+ if is_last_user:
140
+ for img in (images or []):
141
+ parts.append(_encode_media(img, "image/jpeg"))
142
+ for pdf in (pdfs or []):
143
+ parts.append(_encode_media(pdf, "application/pdf"))
144
+
145
+ if not parts:
146
+ parts = [{"text": ""}]
147
+
148
+ contents.append({"role": gemini_role, "parts": parts})
149
+
150
+ if not contents:
151
+ contents.append({"role": "user", "parts": [{"text": ""}]})
152
+
153
+ return system_parts, contents
154
+
155
+ def _build_payload(self, request: LLMRequest, model: str) -> dict:
156
+ system_parts, contents = self._build_contents(
157
+ request.messages, request.images, request.pdfs
158
+ )
159
+ generation_config: dict[str, Any] = {
160
+ "temperature": request.temperature,
161
+ "maxOutputTokens": request.max_tokens,
162
+ }
163
+ if request.response_format and request.response_format.get("type") == "json_object":
164
+ generation_config["responseMimeType"] = "application/json"
165
+
166
+ payload: dict[str, Any] = {
167
+ "contents": contents,
168
+ "generationConfig": generation_config,
169
+ }
170
+ if system_parts:
171
+ payload["systemInstruction"] = {"parts": system_parts}
172
+ if request.tools:
173
+ payload["tools"] = [{"function_declarations": _openai_tools_to_gemini(request.tools)}]
174
+
175
+ return payload
176
+
177
+ # ── core HTTP ────────────────────────────────────────────────────────────
178
+
179
+ async def _post_with_retry(
180
+ self, url: str, payload: dict
181
+ ) -> tuple[dict, int]:
182
+ """POST with exponential backoff. Returns (response_json, retry_count)."""
183
+ client = self._get_client()
184
+ params = {"key": self.api_key}
185
+ last_exc: Exception | None = None
186
+ retries = 0
187
+
188
+ for attempt in range(self.max_retries + 1):
189
+ if attempt > 0:
190
+ delay = min(2 ** (attempt - 1), 16)
191
+ await asyncio.sleep(delay)
192
+ retries += 1
193
+
194
+ try:
195
+ resp = await client.post(url, params=params, json=payload)
196
+
197
+ if resp.status_code in _RETRYABLE_STATUS and attempt < self.max_retries:
198
+ logger.warning("gemini %s status=%d attempt=%d", url, resp.status_code, attempt)
199
+ last_exc = httpx.HTTPStatusError(
200
+ f"status {resp.status_code}", request=resp.request, response=resp
201
+ )
202
+ continue
203
+
204
+ resp.raise_for_status()
205
+ return resp.json() or {}, retries
206
+
207
+ except httpx.TimeoutException as exc:
208
+ logger.warning("gemini timeout attempt=%d", attempt)
209
+ last_exc = exc
210
+ if attempt >= self.max_retries:
211
+ raise
212
+
213
+ except httpx.HTTPStatusError as exc:
214
+ status = exc.response.status_code if exc.response is not None else 0
215
+ if status not in _RETRYABLE_STATUS or attempt >= self.max_retries:
216
+ raise
217
+ last_exc = exc
218
+
219
+ raise last_exc or RuntimeError("Gemini request failed after retries")
220
+
221
+ # ── public API ───────────────────────────────────────────────────────────
222
+
223
+ async def complete(self, request: LLMRequest, *, model: str | None = None) -> LLMResponse:
224
+ """Non-streaming completion."""
225
+ resolved = model or self.model
226
+ url = f"{_BASE_URL}/{resolved}:generateContent"
227
+ payload = self._build_payload(request, resolved)
228
+
229
+ t0 = time.monotonic()
230
+ try:
231
+ data, retries = await self._post_with_retry(url, payload)
232
+ except Exception as exc:
233
+ return LLMResponse(
234
+ content="", model=resolved, provider="gemini",
235
+ latency_ms=int((time.monotonic() - t0) * 1000),
236
+ error=str(exc),
237
+ )
238
+
239
+ latency_ms = int((time.monotonic() - t0) * 1000)
240
+ text, usage = _parse_response(data, resolved)
241
+ _add_token_usage(f"gemini:{resolved}", usage["prompt"], usage["completion"])
242
+
243
+ return LLMResponse(
244
+ content=text,
245
+ model=resolved,
246
+ provider="gemini",
247
+ prompt_tokens=usage["prompt"],
248
+ completion_tokens=usage["completion"],
249
+ latency_ms=latency_ms,
250
+ retries=retries,
251
+ )
252
+
253
+ async def complete_with_tools(
254
+ self,
255
+ request: LLMRequest,
256
+ *,
257
+ model: str | None = None,
258
+ tool_executor: Any = None,
259
+ tool_ctx: dict | None = None,
260
+ max_rounds: int = 4,
261
+ ) -> LLMResponse:
262
+ """Gemini-native tool calling loop."""
263
+ resolved = model or self.model
264
+ url = f"{_BASE_URL}/{resolved}:generateContent"
265
+ messages = list(request.messages)
266
+ executed_calls: list[dict] = []
267
+ total_prompt = 0
268
+ total_completion = 0
269
+ retries_total = 0
270
+
271
+ t0 = time.monotonic()
272
+ for round_no in range(max_rounds):
273
+ loop_req = LLMRequest(
274
+ messages=messages,
275
+ temperature=request.temperature,
276
+ max_tokens=request.max_tokens,
277
+ tools=request.tools,
278
+ )
279
+ payload = self._build_payload(loop_req, resolved)
280
+ try:
281
+ data, retries = await self._post_with_retry(url, payload)
282
+ retries_total += retries
283
+ except Exception as exc:
284
+ return LLMResponse(
285
+ content="", model=resolved, provider="gemini",
286
+ latency_ms=int((time.monotonic() - t0) * 1000),
287
+ error=str(exc), tool_calls=executed_calls,
288
+ )
289
+
290
+ usage = data.get("usageMetadata") or {}
291
+ total_prompt += usage.get("promptTokenCount", 0)
292
+ total_completion += usage.get("candidatesTokenCount", 0)
293
+
294
+ candidates = data.get("candidates") or []
295
+ if not candidates:
296
+ break
297
+ candidate = candidates[0] or {}
298
+ finish = candidate.get("finishReason", "")
299
+ if finish in _SAFETY_FINISH_REASONS:
300
+ break
301
+
302
+ parts = ((candidate.get("content") or {}).get("parts") or [])
303
+
304
+ # Check for function calls
305
+ fn_calls = [p["functionCall"] for p in parts if "functionCall" in p]
306
+ if not fn_calls:
307
+ text = "".join(str(p.get("text") or "") for p in parts).strip()
308
+ _add_token_usage(f"gemini:{resolved}", total_prompt, total_completion)
309
+ return LLMResponse(
310
+ content=text, model=resolved, provider="gemini",
311
+ prompt_tokens=total_prompt, completion_tokens=total_completion,
312
+ latency_ms=int((time.monotonic() - t0) * 1000),
313
+ retries=retries_total, tool_calls=executed_calls,
314
+ )
315
+
316
+ # Execute tools and append results
317
+ messages.append({
318
+ "role": "model",
319
+ "parts": parts,
320
+ })
321
+ tool_result_parts: list[dict] = []
322
+ for fn_call in fn_calls:
323
+ name = fn_call.get("name", "")
324
+ args = fn_call.get("args") or {}
325
+ result: Any = {}
326
+ if tool_executor:
327
+ try:
328
+ result = await tool_executor(name, args, ctx=tool_ctx or {})
329
+ except Exception as exc:
330
+ result = {"error": str(exc)}
331
+ executed_calls.append({"name": name, "args": args, "result": result})
332
+ tool_result_parts.append({
333
+ "functionResponse": {"name": name, "response": result}
334
+ })
335
+ messages.append({"role": "user", "parts": tool_result_parts})
336
+
337
+ _add_token_usage(f"gemini:{resolved}", total_prompt, total_completion)
338
+ return LLMResponse(
339
+ content="", model=resolved, provider="gemini",
340
+ prompt_tokens=total_prompt, completion_tokens=total_completion,
341
+ latency_ms=int((time.monotonic() - t0) * 1000),
342
+ retries=retries_total, tool_calls=executed_calls,
343
+ )
344
+
345
+ async def stream(self, request: LLMRequest, *, model: str | None = None):
346
+ """Async generator yielding str text chunks."""
347
+ resolved = model or self.model
348
+ url = f"{_BASE_URL}/{resolved}:streamGenerateContent"
349
+ payload = self._build_payload(request, resolved)
350
+ params = {"key": self.api_key, "alt": "sse"}
351
+
352
+ client = self._get_client()
353
+ async with client.stream("POST", url, params=params, json=payload) as resp:
354
+ resp.raise_for_status()
355
+ async for line in resp.aiter_lines():
356
+ if not line.startswith("data: "):
357
+ continue
358
+ raw = line[6:].strip()
359
+ if not raw or raw == "[DONE]":
360
+ continue
361
+ try:
362
+ chunk = json.loads(raw)
363
+ except json.JSONDecodeError:
364
+ continue
365
+ candidates = chunk.get("candidates") or []
366
+ if not candidates:
367
+ continue
368
+ parts = ((candidates[0] or {}).get("content") or {}).get("parts") or []
369
+ for part in parts:
370
+ text = part.get("text")
371
+ if text:
372
+ yield text
373
+
374
+ async def count_tokens(self, request: LLMRequest, *, model: str | None = None) -> int:
375
+ """Return estimated token count for a request without generating output."""
376
+ resolved = model or self.model
377
+ url = f"{_BASE_URL}/{resolved}:countTokens"
378
+ _, contents = self._build_contents(request.messages)
379
+ payload: dict[str, Any] = {"contents": contents}
380
+ try:
381
+ data, _ = await self._post_with_retry(url, payload)
382
+ return int(data.get("totalTokens") or 0)
383
+ except Exception:
384
+ return 0
385
+
386
+
387
+ # ── helpers ──────────────────────────────────────────────────────────────────
388
+
389
+ def _parse_response(data: dict, model: str) -> tuple[str, dict]:
390
+ """Extract text + token usage from a Gemini generateContent response."""
391
+ usage_meta = data.get("usageMetadata") or {}
392
+ usage = {
393
+ "prompt": int(usage_meta.get("promptTokenCount") or 0),
394
+ "completion": int(usage_meta.get("candidatesTokenCount") or 0),
395
+ }
396
+ candidates = data.get("candidates") or []
397
+ if not candidates:
398
+ return "", usage
399
+
400
+ candidate = candidates[0] or {}
401
+ finish = candidate.get("finishReason", "")
402
+ if finish in _SAFETY_FINISH_REASONS:
403
+ logger.warning("gemini safety block: finishReason=%s model=%s", finish, model)
404
+ return "", usage
405
+
406
+ parts = ((candidate.get("content") or {}).get("parts") or [])
407
+ text = "".join(str(p.get("text") or "") for p in parts).strip()
408
+ return text, usage
409
+
410
+
411
+ def _encode_media(data: bytes | str, default_mime: str) -> dict:
412
+ """Encode bytes or base64 string into a Gemini inline_data part."""
413
+ if isinstance(data, bytes):
414
+ b64 = base64.b64encode(data).decode()
415
+ else:
416
+ b64 = data # assume already base64
417
+ return {"inline_data": {"mime_type": default_mime, "data": b64}}
418
+
419
+
420
+ def _parse_data_url(url: str) -> tuple[str, str]:
421
+ """Parse a data: URL into (mime_type, base64_data)."""
422
+ rest = url[5:]
423
+ if "," in rest:
424
+ header, data = rest.split(",", 1)
425
+ mime = header.split(";")[0] if ";" in header else header
426
+ return mime, data
427
+ return "image/jpeg", rest
428
+
429
+
430
+ def _openai_tools_to_gemini(tools: list[dict]) -> list[dict]:
431
+ """Convert OpenAI function-calling schema to Gemini function_declarations."""
432
+ declarations = []
433
+ for tool in tools:
434
+ fn = tool.get("function") or tool
435
+ decl: dict[str, Any] = {"name": fn.get("name", "")}
436
+ if fn.get("description"):
437
+ decl["description"] = fn["description"]
438
+ if fn.get("parameters"):
439
+ decl["parameters"] = fn["parameters"]
440
+ declarations.append(decl)
441
+ return declarations
@@ -0,0 +1,176 @@
1
+ """
2
+ ai_providers/groq_provider.py — Groq provider wrapping the OpenAI-compatible API.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ import time
9
+
10
+ import httpx
11
+
12
+ from ai_providers.base import AIProvider
13
+ from ai_providers.types import LLMRequest, LLMResponse
14
+
15
+ logger = logging.getLogger("botnesia.groq")
16
+
17
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
18
+
19
+
20
+ def _add_token_usage(model: str, prompt_tokens: int, completion_tokens: int) -> None:
21
+ try:
22
+ from agent_observability import add_token_usage
23
+ add_token_usage(model=model, prompt_tokens=prompt_tokens,
24
+ completion_tokens=completion_tokens)
25
+ except Exception:
26
+ pass
27
+
28
+
29
+ class GroqProvider(AIProvider):
30
+ def __init__(
31
+ self,
32
+ api_key: str,
33
+ model: str = "meta-llama/llama-4-scout-17b-16e-instruct",
34
+ base_url: str = "https://api.groq.com/openai/v1",
35
+ max_retries: int = 3,
36
+ timeout: float = 60.0,
37
+ ):
38
+ self.api_key = api_key
39
+ self.model = model
40
+ self.base_url = base_url.rstrip("/")
41
+ self.max_retries = max_retries
42
+ self.timeout = timeout
43
+ self._client: httpx.AsyncClient | None = None
44
+
45
+ def _get_client(self) -> httpx.AsyncClient:
46
+ if self._client is None or self._client.is_closed:
47
+ self._client = httpx.AsyncClient(timeout=self.timeout)
48
+ return self._client
49
+
50
+ async def aclose(self) -> None:
51
+ if self._client and not self._client.is_closed:
52
+ await self._client.aclose()
53
+ self._client = None
54
+
55
+ def is_available(self) -> bool:
56
+ return bool(self.api_key)
57
+
58
+ @property
59
+ def provider_name(self) -> str:
60
+ return "groq"
61
+
62
+ @property
63
+ def default_model(self) -> str:
64
+ return self.model
65
+
66
+ async def complete(self, request: LLMRequest, *, model: str | None = None) -> LLMResponse:
67
+ resolved = model or self.model
68
+ headers = {
69
+ "Authorization": f"Bearer {self.api_key}",
70
+ "Content-Type": "application/json",
71
+ }
72
+ payload: dict = {
73
+ "model": resolved,
74
+ "messages": request.messages,
75
+ "temperature": request.temperature,
76
+ "max_tokens": request.max_tokens,
77
+ }
78
+ if request.response_format:
79
+ payload["response_format"] = request.response_format
80
+
81
+ client = self._get_client()
82
+ t0 = time.monotonic()
83
+ last_exc: Exception | None = None
84
+ retries = 0
85
+
86
+ for attempt in range(self.max_retries + 1):
87
+ if attempt > 0:
88
+ await asyncio.sleep(min(2 ** (attempt - 1), 16))
89
+ retries += 1
90
+ try:
91
+ resp = await client.post(
92
+ f"{self.base_url}/chat/completions",
93
+ json=payload, headers=headers,
94
+ )
95
+ if resp.status_code in _RETRYABLE_STATUS and attempt < self.max_retries:
96
+ last_exc = httpx.HTTPStatusError(
97
+ f"status {resp.status_code}", request=resp.request, response=resp
98
+ )
99
+ continue
100
+ resp.raise_for_status()
101
+ data = resp.json() or {}
102
+ break
103
+ except httpx.TimeoutException as exc:
104
+ last_exc = exc
105
+ if attempt >= self.max_retries:
106
+ return LLMResponse(
107
+ content="", model=resolved, provider="groq",
108
+ latency_ms=int((time.monotonic() - t0) * 1000),
109
+ error=str(exc), retries=retries,
110
+ )
111
+ except httpx.HTTPStatusError as exc:
112
+ status = exc.response.status_code if exc.response is not None else 0
113
+ if status not in _RETRYABLE_STATUS or attempt >= self.max_retries:
114
+ return LLMResponse(
115
+ content="", model=resolved, provider="groq",
116
+ latency_ms=int((time.monotonic() - t0) * 1000),
117
+ error=str(exc), retries=retries,
118
+ )
119
+ last_exc = exc
120
+ else:
121
+ return LLMResponse(
122
+ content="", model=resolved, provider="groq",
123
+ latency_ms=int((time.monotonic() - t0) * 1000),
124
+ error=str(last_exc), retries=retries,
125
+ )
126
+
127
+ latency_ms = int((time.monotonic() - t0) * 1000)
128
+ usage = data.get("usage") or {}
129
+ prompt_t = int(usage.get("prompt_tokens") or 0)
130
+ completion_t = int(usage.get("completion_tokens") or 0)
131
+ _add_token_usage(resolved, prompt_t, completion_t)
132
+
133
+ choices = data.get("choices") or []
134
+ content = str(((choices[0] or {}).get("message") or {}).get("content") or "").strip()
135
+
136
+ return LLMResponse(
137
+ content=content, model=resolved, provider="groq",
138
+ prompt_tokens=prompt_t, completion_tokens=completion_t,
139
+ latency_ms=latency_ms, retries=retries,
140
+ )
141
+
142
+ async def stream(self, request: LLMRequest, *, model: str | None = None):
143
+ """Async generator for Groq streaming (SSE)."""
144
+ resolved = model or self.model
145
+ headers = {
146
+ "Authorization": f"Bearer {self.api_key}",
147
+ "Content-Type": "application/json",
148
+ }
149
+ payload = {
150
+ "model": resolved,
151
+ "messages": request.messages,
152
+ "temperature": request.temperature,
153
+ "max_tokens": request.max_tokens,
154
+ "stream": True,
155
+ }
156
+ client = self._get_client()
157
+ async with client.stream(
158
+ "POST", f"{self.base_url}/chat/completions",
159
+ json=payload, headers=headers
160
+ ) as resp:
161
+ resp.raise_for_status()
162
+ async for line in resp.aiter_lines():
163
+ if not line.startswith("data: "):
164
+ continue
165
+ raw = line[6:].strip()
166
+ if not raw or raw == "[DONE]":
167
+ continue
168
+ try:
169
+ import json as _json
170
+ chunk = _json.loads(raw)
171
+ delta = ((chunk.get("choices") or [{}])[0].get("delta") or {})
172
+ text = delta.get("content")
173
+ if text:
174
+ yield text
175
+ except Exception:
176
+ continue