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.
- agent_observability.py +402 -0
- agent_os.py +180 -0
- agent_registry.py +170 -0
- ai_providers/__init__.py +20 -0
- ai_providers/base.py +30 -0
- ai_providers/deepseek.py +206 -0
- ai_providers/gemini.py +441 -0
- ai_providers/groq_provider.py +176 -0
- ai_providers/openrouter.py +254 -0
- ai_providers/router.py +371 -0
- ai_providers/types.py +73 -0
- anti_hallucination_engine.py +119 -0
- base.py +631 -0
- botnesia_core-0.1.0.dist-info/METADATA +253 -0
- botnesia_core-0.1.0.dist-info/RECORD +70 -0
- botnesia_core-0.1.0.dist-info/WHEEL +5 -0
- botnesia_core-0.1.0.dist-info/licenses/LICENSE +202 -0
- botnesia_core-0.1.0.dist-info/licenses/NOTICE +5 -0
- botnesia_core-0.1.0.dist-info/top_level.txt +34 -0
- cognitive_loop/__init__.py +16 -0
- cognitive_loop/loop.py +151 -0
- cognitive_loop/worker.py +32 -0
- cost_intelligence.py +196 -0
- devil_advocate_agent.py +116 -0
- evaluation/__init__.py +15 -0
- evaluation/evaluator.py +115 -0
- evaluation/schema.py +30 -0
- event_bus/__init__.py +46 -0
- event_bus/bus.py +101 -0
- event_bus/events.py +26 -0
- feature_flags/__init__.py +27 -0
- feature_flags/flags.py +101 -0
- first_principle_agent.py +139 -0
- groq_knowledge.py +345 -0
- intent_classifier.py +84 -0
- kb_embeddings.py +98 -0
- knowledge_access_engine.py +209 -0
- long_term_memory/__init__.py +19 -0
- long_term_memory/schema.py +57 -0
- long_term_memory/store.py +126 -0
- mcp_client.py +147 -0
- mcp_registry.py +148 -0
- multi_agent_orchestrator.py +437 -0
- perf_cache.py +76 -0
- planner_agent.py +81 -0
- platform_state/__init__.py +22 -0
- platform_state/base.py +73 -0
- platform_state/factory.py +26 -0
- platform_state/inprocess.py +140 -0
- platform_state/redis_store.py +124 -0
- policy_engine/__init__.py +21 -0
- policy_engine/engine.py +98 -0
- policy_engine/loader.py +76 -0
- prompt_registry/__init__.py +22 -0
- prompt_registry/factory.py +19 -0
- prompt_registry/registry.py +160 -0
- prompt_registry/schema.py +37 -0
- socratic_reasoning.py +119 -0
- task_engine.py +143 -0
- task_runtime/__init__.py +22 -0
- task_runtime/monitor.py +142 -0
- task_runtime/repository.py +230 -0
- task_runtime/runner.py +344 -0
- task_runtime/schema.py +64 -0
- task_runtime/worker.py +65 -0
- tool_executor.py +836 -0
- tool_registry.py +308 -0
- uncertainty_engine.py +195 -0
- vendor_bootstrap.py +28 -0
- 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
|