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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ai_providers/openrouter.py — OpenRouter provider.
|
|
3
|
+
|
|
4
|
+
OpenRouter (https://openrouter.ai) provides access to 200+ LLMs including
|
|
5
|
+
GPT-4o, DeepSeek, Qwen, Mistral, Llama, and more
|
|
6
|
+
through a single OpenAI-compatible API endpoint.
|
|
7
|
+
|
|
8
|
+
Set OPENROUTER_API_KEY in .env to activate. When the key is present the
|
|
9
|
+
SmartModelRouter inserts an OpenRouter attempt between Gemini and Groq,
|
|
10
|
+
using the task-optimal model for each request type.
|
|
11
|
+
|
|
12
|
+
Task → Model mapping is configurable via OPENROUTER_TASK_MODELS_JSON (.env):
|
|
13
|
+
'{"coding": "openai/gpt-4o", "reasoning": "deepseek/deepseek-r1"}'
|
|
14
|
+
"""
|
|
15
|
+
import asyncio
|
|
16
|
+
import json as _json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from ai_providers.base import AIProvider
|
|
24
|
+
from ai_providers.types import LLMRequest, LLMResponse
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("botnesia.openrouter")
|
|
27
|
+
|
|
28
|
+
_BASE_URL = "https://openrouter.ai/api/v1"
|
|
29
|
+
_RETRYABLE = frozenset({429, 500, 502, 503, 504})
|
|
30
|
+
|
|
31
|
+
# Peta task→model OpenRouter — sesuai arsitektur BotNesia:
|
|
32
|
+
# Claude → coding, analisis dokumen, penulisan kompleks
|
|
33
|
+
# Gemini → multimodal (vision/gambar/PDF/audio)
|
|
34
|
+
# DeepSeek→ standar/chat/speed (murah & cepat) + reasoning/planning (R1)
|
|
35
|
+
# Otak inti (chat/reasoning/planning) tetap DeepSeek LANGSUNG di jalur utama;
|
|
36
|
+
# entri di sini dipakai saat request memang lewat OpenRouter (fallback/spesialis).
|
|
37
|
+
# Semua bisa di-override via env OPENROUTER_TASK_MODELS_JSON. TIDAK ada gpt-4o
|
|
38
|
+
# (mahal) di default demi menjaga margin.
|
|
39
|
+
_CLAUDE = "anthropic/claude-3.5-sonnet" # ganti ke claude-3-haiku bila mau lebih murah
|
|
40
|
+
_GEMINI = "google/gemini-2.0-flash-001"
|
|
41
|
+
_DS_CHAT = "deepseek/deepseek-chat"
|
|
42
|
+
_DS_R1 = "deepseek/deepseek-r1"
|
|
43
|
+
|
|
44
|
+
DEFAULT_TASK_MODELS: dict = {
|
|
45
|
+
# ── Claude: coding / dokumen / penulisan kompleks ───────────────────
|
|
46
|
+
"coding": _CLAUDE,
|
|
47
|
+
"advanced_coding": _CLAUDE,
|
|
48
|
+
"document": _CLAUDE,
|
|
49
|
+
"document_analysis": _CLAUDE,
|
|
50
|
+
"writing": _CLAUDE,
|
|
51
|
+
"complex_writing": _CLAUDE,
|
|
52
|
+
"workflow": _CLAUDE,
|
|
53
|
+
"complex_workflow": _CLAUDE,
|
|
54
|
+
# ── Gemini: multimodal (vision/gambar/PDF/audio) ────────────────────
|
|
55
|
+
"vision": _GEMINI,
|
|
56
|
+
"multimodal": _GEMINI,
|
|
57
|
+
"image": _GEMINI,
|
|
58
|
+
"image_analysis": _GEMINI,
|
|
59
|
+
"pdf": _GEMINI,
|
|
60
|
+
"document_ocr": _GEMINI,
|
|
61
|
+
"audio": _GEMINI,
|
|
62
|
+
# ── DeepSeek R1: reasoning / planning ───────────────────────────────
|
|
63
|
+
"reasoning": _DS_R1,
|
|
64
|
+
"deep_reasoning": _DS_R1,
|
|
65
|
+
"planning": _DS_R1,
|
|
66
|
+
"business_planning": _DS_R1,
|
|
67
|
+
# ── DeepSeek chat: standar / chat / speed (murah & cepat) ───────────
|
|
68
|
+
"chat": _DS_CHAT,
|
|
69
|
+
"cs": _DS_CHAT,
|
|
70
|
+
"customer_service": _DS_CHAT,
|
|
71
|
+
"faq": _DS_CHAT,
|
|
72
|
+
"sales": _DS_CHAT,
|
|
73
|
+
"marketing": _DS_CHAT,
|
|
74
|
+
"hr": _DS_CHAT,
|
|
75
|
+
"knowledge": _DS_CHAT,
|
|
76
|
+
"knowledge_search": _DS_CHAT,
|
|
77
|
+
"internal": _DS_CHAT,
|
|
78
|
+
"fast": _DS_CHAT,
|
|
79
|
+
"low_latency": _DS_CHAT,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_DEFAULT_MODEL = "deepseek/deepseek-chat" # fallback murah (bukan gpt-4o-mini)
|
|
83
|
+
|
|
84
|
+
# Module-level cache — rebuilt once per process (or when env var changes)
|
|
85
|
+
_TASK_MODELS: dict | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def task_model(task_type: str) -> str:
|
|
89
|
+
"""Return the best OpenRouter model slug for a given task type."""
|
|
90
|
+
global _TASK_MODELS
|
|
91
|
+
if _TASK_MODELS is None:
|
|
92
|
+
models = dict(DEFAULT_TASK_MODELS)
|
|
93
|
+
raw = os.environ.get("OPENROUTER_TASK_MODELS_JSON", "").strip()
|
|
94
|
+
if raw:
|
|
95
|
+
try:
|
|
96
|
+
models.update(_json.loads(raw))
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
_TASK_MODELS = models
|
|
100
|
+
return _TASK_MODELS.get((task_type or "").lower(), _DEFAULT_MODEL)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _add_token_usage(model: str, pt: int, ct: int) -> None:
|
|
104
|
+
try:
|
|
105
|
+
from agent_observability import add_token_usage
|
|
106
|
+
add_token_usage(model=model, prompt_tokens=pt, completion_tokens=ct)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OpenRouterProvider(AIProvider):
|
|
112
|
+
"""
|
|
113
|
+
OpenRouter — single API key, access to GPT-4o, DeepSeek, Qwen, Mistral, Llama, and 200+ models via OpenAI-compatible API.
|
|
114
|
+
|
|
115
|
+
Implements AIProvider so it plugs directly into SmartModelRouter.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
api_key: str,
|
|
121
|
+
model: str = _DEFAULT_MODEL,
|
|
122
|
+
site_url: str = "http://localhost:8000",
|
|
123
|
+
app_name: str = "BotNesia",
|
|
124
|
+
max_retries: int = 2,
|
|
125
|
+
timeout: float = 60.0,
|
|
126
|
+
):
|
|
127
|
+
self.api_key = api_key
|
|
128
|
+
self.model = model
|
|
129
|
+
self.site_url = site_url
|
|
130
|
+
self.app_name = app_name
|
|
131
|
+
self.max_retries = max_retries
|
|
132
|
+
self.timeout = timeout
|
|
133
|
+
self._client: httpx.AsyncClient | None = None
|
|
134
|
+
|
|
135
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
136
|
+
if self._client is None or self._client.is_closed:
|
|
137
|
+
self._client = httpx.AsyncClient(timeout=self.timeout)
|
|
138
|
+
return self._client
|
|
139
|
+
|
|
140
|
+
async def aclose(self) -> None:
|
|
141
|
+
if self._client and not self._client.is_closed:
|
|
142
|
+
await self._client.aclose()
|
|
143
|
+
self._client = None
|
|
144
|
+
|
|
145
|
+
def is_available(self) -> bool:
|
|
146
|
+
return bool(self.api_key)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def provider_name(self) -> str:
|
|
150
|
+
return "openrouter"
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def default_model(self) -> str:
|
|
154
|
+
return self.model
|
|
155
|
+
|
|
156
|
+
def _headers(self) -> dict:
|
|
157
|
+
return {
|
|
158
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
"HTTP-Referer": self.site_url,
|
|
161
|
+
"X-Title": self.app_name,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async def complete(self, request: LLMRequest, *, model: str | None = None) -> LLMResponse:
|
|
165
|
+
resolved = model or self.model
|
|
166
|
+
payload: dict = {
|
|
167
|
+
"model": resolved,
|
|
168
|
+
"messages": request.messages,
|
|
169
|
+
"temperature": request.temperature,
|
|
170
|
+
"max_tokens": request.max_tokens,
|
|
171
|
+
}
|
|
172
|
+
if request.response_format:
|
|
173
|
+
payload["response_format"] = request.response_format
|
|
174
|
+
|
|
175
|
+
client = self._get_client()
|
|
176
|
+
t0 = time.monotonic()
|
|
177
|
+
last_exc: Exception | None = None
|
|
178
|
+
retries = 0
|
|
179
|
+
|
|
180
|
+
for attempt in range(self.max_retries + 1):
|
|
181
|
+
if attempt:
|
|
182
|
+
await asyncio.sleep(min(2 ** (attempt - 1), 8))
|
|
183
|
+
retries += 1
|
|
184
|
+
try:
|
|
185
|
+
resp = await client.post(
|
|
186
|
+
f"{_BASE_URL}/chat/completions",
|
|
187
|
+
json=payload,
|
|
188
|
+
headers=self._headers(),
|
|
189
|
+
)
|
|
190
|
+
if resp.status_code in _RETRYABLE and attempt < self.max_retries:
|
|
191
|
+
last_exc = httpx.HTTPStatusError(
|
|
192
|
+
f"status {resp.status_code}", request=resp.request, response=resp
|
|
193
|
+
)
|
|
194
|
+
continue
|
|
195
|
+
resp.raise_for_status()
|
|
196
|
+
data = resp.json() or {}
|
|
197
|
+
break
|
|
198
|
+
except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
|
|
199
|
+
last_exc = exc
|
|
200
|
+
if attempt >= self.max_retries:
|
|
201
|
+
return LLMResponse(
|
|
202
|
+
content="", model=resolved, provider="openrouter",
|
|
203
|
+
latency_ms=int((time.monotonic() - t0) * 1000),
|
|
204
|
+
error=str(exc), retries=retries,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
return LLMResponse(
|
|
208
|
+
content="", model=resolved, provider="openrouter",
|
|
209
|
+
latency_ms=int((time.monotonic() - t0) * 1000),
|
|
210
|
+
error=str(last_exc), retries=retries,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
214
|
+
usage = data.get("usage") or {}
|
|
215
|
+
pt = int(usage.get("prompt_tokens") or 0)
|
|
216
|
+
ct = int(usage.get("completion_tokens") or 0)
|
|
217
|
+
_add_token_usage(resolved, pt, ct)
|
|
218
|
+
choices = data.get("choices") or []
|
|
219
|
+
content = str(((choices[0] or {}).get("message") or {}).get("content") or "").strip()
|
|
220
|
+
return LLMResponse(
|
|
221
|
+
content=content, model=resolved, provider="openrouter",
|
|
222
|
+
prompt_tokens=pt, completion_tokens=ct,
|
|
223
|
+
latency_ms=latency_ms, retries=retries,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def stream(self, request: LLMRequest, *, model: str | None = None):
|
|
227
|
+
resolved = model or self.model
|
|
228
|
+
payload = {
|
|
229
|
+
"model": resolved,
|
|
230
|
+
"messages": request.messages,
|
|
231
|
+
"temperature": request.temperature,
|
|
232
|
+
"max_tokens": request.max_tokens,
|
|
233
|
+
"stream": True,
|
|
234
|
+
}
|
|
235
|
+
client = self._get_client()
|
|
236
|
+
async with client.stream(
|
|
237
|
+
"POST", f"{_BASE_URL}/chat/completions",
|
|
238
|
+
json=payload, headers=self._headers(),
|
|
239
|
+
) as resp:
|
|
240
|
+
resp.raise_for_status()
|
|
241
|
+
async for line in resp.aiter_lines():
|
|
242
|
+
if not line.startswith("data: "):
|
|
243
|
+
continue
|
|
244
|
+
raw = line[6:].strip()
|
|
245
|
+
if not raw or raw == "[DONE]":
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
chunk = _json.loads(raw)
|
|
249
|
+
delta = ((chunk.get("choices") or [{}])[0].get("delta") or {})
|
|
250
|
+
text = delta.get("content")
|
|
251
|
+
if text:
|
|
252
|
+
yield text
|
|
253
|
+
except Exception:
|
|
254
|
+
continue
|
ai_providers/router.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ai_providers/router.py — SmartModelRouter (multi-provider).
|
|
3
|
+
|
|
4
|
+
Routing priority:
|
|
5
|
+
pro / complex task → Gemini Pro → OpenRouter (task-optimal) → Gemini Flash → Groq
|
|
6
|
+
standard task → Gemini Flash → OpenRouter (task-fast) → Groq
|
|
7
|
+
|
|
8
|
+
Circuit breaker: after _FAIL_THRESHOLD consecutive failures a provider is
|
|
9
|
+
paused for _RESET_SECS before being retried. The breaker is process-level
|
|
10
|
+
(shared across all SmartModelRouter instances) so provider health is tracked
|
|
11
|
+
globally, not per-agent.
|
|
12
|
+
|
|
13
|
+
Providers are all optional; any combination works:
|
|
14
|
+
- Gemini only (no OpenRouter, no Groq) — minimal setup
|
|
15
|
+
- Gemini + Groq — original setup
|
|
16
|
+
- Gemini + OpenRouter + Groq — full multi-provider
|
|
17
|
+
- OpenRouter + Groq — no Gemini key
|
|
18
|
+
"""
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from ai_providers.base import AIProvider
|
|
23
|
+
from ai_providers.types import LLMRequest, LLMResponse, PRO_TASK_TYPES
|
|
24
|
+
|
|
25
|
+
from platform_state import get_state_store # P0-A C4: shared circuit-breaker state
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("botnesia.router")
|
|
28
|
+
|
|
29
|
+
_FAIL_THRESHOLD = 3
|
|
30
|
+
_RESET_SECS = 60
|
|
31
|
+
_SYNC_TTL = 1.0 # throttle baca lintas-worker: maks 1 baca/detik/provider
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _CircuitBreaker:
|
|
35
|
+
"""Per-provider circuit breaker HYBRID (P0-A C4).
|
|
36
|
+
|
|
37
|
+
Fast-path LOKAL (in-process) supaya `is_open` tak menambah round-trip di
|
|
38
|
+
jalur panas LLM. State 'open' juga di-mirror ke platform_state.StateStore
|
|
39
|
+
(`cb:{name}`, wall-clock `open_until`) sehingga provider yang di-open satu
|
|
40
|
+
worker terlihat worker lain dalam ~_SYNC_TTL detik (bila STATE_BACKEND=redis).
|
|
41
|
+
Default in-process: StateStore = dict lokal → perilaku identik versi lama.
|
|
42
|
+
`is_open/ok/fail` kini async (dipanggil dari _try_*/stream yang sudah async);
|
|
43
|
+
`state()` tetap sync (dipakai status(), tanpa I/O)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self._fails: dict = {}
|
|
47
|
+
self._open_until: dict = {} # name -> wall-clock epoch
|
|
48
|
+
self._last_sync: dict = {} # name -> monotonic terakhir baca store
|
|
49
|
+
|
|
50
|
+
async def is_open(self, name: str) -> bool:
|
|
51
|
+
now = time.time()
|
|
52
|
+
until = self._open_until.get(name, 0.0)
|
|
53
|
+
if until:
|
|
54
|
+
if now < until:
|
|
55
|
+
return True
|
|
56
|
+
self._fails[name] = 0 # cooldown lokal habis — reset
|
|
57
|
+
self._open_until.pop(name, None)
|
|
58
|
+
# adopsi open dari worker lain (di-throttle agar tak beri beban tiap call)
|
|
59
|
+
if time.monotonic() - self._last_sync.get(name, 0.0) >= _SYNC_TTL:
|
|
60
|
+
self._last_sync[name] = time.monotonic()
|
|
61
|
+
try:
|
|
62
|
+
raw = await get_state_store().get(f"cb:{name}")
|
|
63
|
+
except Exception:
|
|
64
|
+
raw = None
|
|
65
|
+
if raw:
|
|
66
|
+
try:
|
|
67
|
+
remote_until = float(raw)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
remote_until = 0.0
|
|
70
|
+
if remote_until > now:
|
|
71
|
+
self._open_until[name] = remote_until
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
async def ok(self, name: str) -> None:
|
|
76
|
+
self._fails[name] = 0
|
|
77
|
+
self._open_until.pop(name, None)
|
|
78
|
+
try:
|
|
79
|
+
await get_state_store().delete(f"cb:{name}")
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
async def fail(self, name: str) -> None:
|
|
84
|
+
n = self._fails.get(name, 0) + 1
|
|
85
|
+
self._fails[name] = n
|
|
86
|
+
if n >= _FAIL_THRESHOLD:
|
|
87
|
+
until = time.time() + _RESET_SECS
|
|
88
|
+
self._open_until[name] = until
|
|
89
|
+
try:
|
|
90
|
+
await get_state_store().set(f"cb:{name}", str(until), ttl_s=_RESET_SECS)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
logger.warning("circuit-breaker: %s opened for %ds", name, _RESET_SECS)
|
|
94
|
+
|
|
95
|
+
def state(self, name: str) -> dict:
|
|
96
|
+
"""Snapshot LOKAL (sync) untuk status() — tanpa I/O."""
|
|
97
|
+
until = self._open_until.get(name, 0.0)
|
|
98
|
+
return {
|
|
99
|
+
"fails": self._fails.get(name, 0),
|
|
100
|
+
"open": bool(until and time.time() < until),
|
|
101
|
+
"open_until": until or None,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Shared across all SmartModelRouter instances — process-level health view.
|
|
106
|
+
_breaker = _CircuitBreaker()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SmartModelRouter:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
gemini: AIProvider | None = None,
|
|
113
|
+
groq: AIProvider | None = None,
|
|
114
|
+
openrouter: AIProvider | None = None,
|
|
115
|
+
deepseek: AIProvider | None = None,
|
|
116
|
+
):
|
|
117
|
+
self.gemini = gemini
|
|
118
|
+
self.groq = groq
|
|
119
|
+
self.openrouter = openrouter
|
|
120
|
+
self.deepseek = deepseek
|
|
121
|
+
|
|
122
|
+
# ── Model selection ───────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def select_model(self, tier: str = "standard", task_type: str = "chat") -> str | None:
|
|
125
|
+
"""Gemini model name for this tier/task, or None if Gemini unavailable."""
|
|
126
|
+
if not self.gemini or not self.gemini.is_available():
|
|
127
|
+
return None
|
|
128
|
+
task = (task_type or "chat").lower()
|
|
129
|
+
use_pro = (tier == "pro") or (task in PRO_TASK_TYPES)
|
|
130
|
+
from ai_providers.gemini import GeminiProvider
|
|
131
|
+
if isinstance(self.gemini, GeminiProvider):
|
|
132
|
+
return self.gemini.pro_model if use_pro else self.gemini.model
|
|
133
|
+
return self.gemini.default_model
|
|
134
|
+
|
|
135
|
+
def _flash_model(self) -> str | None:
|
|
136
|
+
if not self.gemini or not self.gemini.is_available():
|
|
137
|
+
return None
|
|
138
|
+
from ai_providers.gemini import GeminiProvider
|
|
139
|
+
return self.gemini.model if isinstance(self.gemini, GeminiProvider) else None
|
|
140
|
+
|
|
141
|
+
def _or_model(self, task_type: str) -> str | None:
|
|
142
|
+
if not self.openrouter or not self.openrouter.is_available():
|
|
143
|
+
return None
|
|
144
|
+
from ai_providers.openrouter import task_model
|
|
145
|
+
return task_model(task_type)
|
|
146
|
+
|
|
147
|
+
def _ds_model(self, task_type: str) -> str | None:
|
|
148
|
+
if not self.deepseek or not self.deepseek.is_available():
|
|
149
|
+
return None
|
|
150
|
+
from ai_providers.deepseek import deepseek_model_for_task
|
|
151
|
+
return deepseek_model_for_task(task_type)
|
|
152
|
+
|
|
153
|
+
# ── Try helpers ───────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async def _try_gemini(self, req: LLMRequest, model: str) -> LLMResponse | None:
|
|
156
|
+
if not self.gemini or not self.gemini.is_available() or await _breaker.is_open("gemini"):
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
r = await self.gemini.complete(req, model=model)
|
|
160
|
+
if r.error is None:
|
|
161
|
+
await _breaker.ok("gemini")
|
|
162
|
+
return r
|
|
163
|
+
logger.warning("gemini err model=%s: %s", model, r.error)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
logger.warning("gemini exc model=%s: %s", model, exc)
|
|
166
|
+
await _breaker.fail("gemini")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
async def _try_openrouter(self, req: LLMRequest, model: str) -> LLMResponse | None:
|
|
170
|
+
if not self.openrouter or not self.openrouter.is_available() or await _breaker.is_open("openrouter"):
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
r = await self.openrouter.complete(req, model=model)
|
|
174
|
+
if r.error is None:
|
|
175
|
+
await _breaker.ok("openrouter")
|
|
176
|
+
return r
|
|
177
|
+
logger.warning("openrouter err model=%s: %s", model, r.error)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
logger.warning("openrouter exc model=%s: %s", model, exc)
|
|
180
|
+
await _breaker.fail("openrouter")
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
async def _try_deepseek(self, req: LLMRequest, model: str) -> LLMResponse | None:
|
|
184
|
+
if not self.deepseek or not self.deepseek.is_available() or await _breaker.is_open("deepseek"):
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
r = await self.deepseek.complete(req, model=model)
|
|
188
|
+
if r.error is None:
|
|
189
|
+
await _breaker.ok("deepseek")
|
|
190
|
+
return r
|
|
191
|
+
logger.warning("deepseek err model=%s: %s", model, r.error)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logger.warning("deepseek exc model=%s: %s", model, exc)
|
|
194
|
+
await _breaker.fail("deepseek")
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
async def _try_groq(self, req: LLMRequest) -> LLMResponse | None:
|
|
198
|
+
if not self.groq or not self.groq.is_available() or await _breaker.is_open("groq"):
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
r = await self.groq.complete(req)
|
|
202
|
+
if r.error is None:
|
|
203
|
+
await _breaker.ok("groq")
|
|
204
|
+
return r
|
|
205
|
+
logger.warning("groq err: %s", r.error)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
logger.warning("groq exc: %s", exc)
|
|
208
|
+
await _breaker.fail("groq")
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
async def route(
|
|
214
|
+
self,
|
|
215
|
+
request: LLMRequest,
|
|
216
|
+
*,
|
|
217
|
+
tier: str = "standard",
|
|
218
|
+
task_type: str = "chat",
|
|
219
|
+
) -> LLMResponse:
|
|
220
|
+
"""
|
|
221
|
+
Route to best available provider:
|
|
222
|
+
pro/complex → Gemini Pro → DeepSeek(task) → OpenRouter(task) → Gemini Flash → Groq
|
|
223
|
+
standard → Gemini Flash → OpenRouter(task) → Groq
|
|
224
|
+
"""
|
|
225
|
+
primary = self.select_model(tier, task_type)
|
|
226
|
+
ds_model = self._ds_model(task_type)
|
|
227
|
+
or_model = self._or_model(task_type)
|
|
228
|
+
flash = self._flash_model()
|
|
229
|
+
|
|
230
|
+
# 1. Primary Gemini (Pro or Flash depending on tier)
|
|
231
|
+
if primary:
|
|
232
|
+
r = await self._try_gemini(request, primary)
|
|
233
|
+
if r:
|
|
234
|
+
return r
|
|
235
|
+
|
|
236
|
+
# 2. DeepSeek direct API — best for coding/reasoning tasks, no markup
|
|
237
|
+
if ds_model:
|
|
238
|
+
r = await self._try_deepseek(request, ds_model)
|
|
239
|
+
if r:
|
|
240
|
+
return r
|
|
241
|
+
|
|
242
|
+
# 3. OpenRouter — task-optimal model (covers other models not in DeepSeek)
|
|
243
|
+
if or_model:
|
|
244
|
+
r = await self._try_openrouter(request, or_model)
|
|
245
|
+
if r:
|
|
246
|
+
return r
|
|
247
|
+
|
|
248
|
+
# 4. Gemini Flash retry (only if Pro was the primary and failed)
|
|
249
|
+
if flash and flash != primary:
|
|
250
|
+
r = await self._try_gemini(request, flash)
|
|
251
|
+
if r:
|
|
252
|
+
return r
|
|
253
|
+
|
|
254
|
+
# 5. Groq fallback
|
|
255
|
+
r = await self._try_groq(request)
|
|
256
|
+
if r:
|
|
257
|
+
return r
|
|
258
|
+
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
"No AI provider available — Gemini, DeepSeek, OpenRouter, and Groq all failed or unconfigured"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def stream(
|
|
264
|
+
self,
|
|
265
|
+
request: LLMRequest,
|
|
266
|
+
*,
|
|
267
|
+
tier: str = "standard",
|
|
268
|
+
task_type: str = "chat",
|
|
269
|
+
):
|
|
270
|
+
"""Streaming with same provider priority as route()."""
|
|
271
|
+
primary = self.select_model(tier, task_type)
|
|
272
|
+
ds_model = self._ds_model(task_type)
|
|
273
|
+
or_model = self._or_model(task_type)
|
|
274
|
+
flash = self._flash_model()
|
|
275
|
+
|
|
276
|
+
# 1. Gemini primary
|
|
277
|
+
if primary and self.gemini and self.gemini.is_available() and not await _breaker.is_open("gemini"):
|
|
278
|
+
try:
|
|
279
|
+
async for chunk in self.gemini.stream(request, model=primary):
|
|
280
|
+
yield chunk
|
|
281
|
+
await _breaker.ok("gemini"); return
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
logger.warning("gemini stream err model=%s: %s", primary, exc)
|
|
284
|
+
await _breaker.fail("gemini")
|
|
285
|
+
|
|
286
|
+
# 2. DeepSeek direct
|
|
287
|
+
if ds_model and self.deepseek and self.deepseek.is_available() and not await _breaker.is_open("deepseek"):
|
|
288
|
+
try:
|
|
289
|
+
async for chunk in self.deepseek.stream(request, model=ds_model):
|
|
290
|
+
yield chunk
|
|
291
|
+
await _breaker.ok("deepseek"); return
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
logger.warning("deepseek stream err model=%s: %s", ds_model, exc)
|
|
294
|
+
await _breaker.fail("deepseek")
|
|
295
|
+
|
|
296
|
+
# 3. OpenRouter
|
|
297
|
+
if or_model and self.openrouter and self.openrouter.is_available() and not await _breaker.is_open("openrouter"):
|
|
298
|
+
try:
|
|
299
|
+
async for chunk in self.openrouter.stream(request, model=or_model):
|
|
300
|
+
yield chunk
|
|
301
|
+
await _breaker.ok("openrouter"); return
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
logger.warning("openrouter stream err model=%s: %s", or_model, exc)
|
|
304
|
+
await _breaker.fail("openrouter")
|
|
305
|
+
|
|
306
|
+
# 4. Gemini Flash retry
|
|
307
|
+
if flash and flash != primary and self.gemini and self.gemini.is_available() and not await _breaker.is_open("gemini"):
|
|
308
|
+
try:
|
|
309
|
+
async for chunk in self.gemini.stream(request, model=flash):
|
|
310
|
+
yield chunk
|
|
311
|
+
await _breaker.ok("gemini"); return
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
logger.warning("gemini flash stream err: %s", exc)
|
|
314
|
+
await _breaker.fail("gemini")
|
|
315
|
+
|
|
316
|
+
# 5. Groq
|
|
317
|
+
if self.groq and self.groq.is_available() and not await _breaker.is_open("groq"):
|
|
318
|
+
try:
|
|
319
|
+
async for chunk in self.groq.stream(request):
|
|
320
|
+
yield chunk
|
|
321
|
+
await _breaker.ok("groq"); return
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
logger.warning("groq stream err: %s", exc)
|
|
324
|
+
await _breaker.fail("groq")
|
|
325
|
+
|
|
326
|
+
raise RuntimeError("No AI provider available for streaming")
|
|
327
|
+
|
|
328
|
+
# ── Cost Router (P2-A): pilih model dari KELAS pesan ────────────────────
|
|
329
|
+
async def route_for_message(self, request: LLMRequest, *, user_message: str,
|
|
330
|
+
has_image: bool = False, reasoning_mode: str = "standard") -> LLMResponse:
|
|
331
|
+
"""Klasifikasi pesan 5-arah (simple/medium/complex/coding/vision) →
|
|
332
|
+
tier+task_type otomatis → route(). Hemat biaya: task ringan tak ke model
|
|
333
|
+
mahal; coding→coding-model, complex→reasoning, vision→vision."""
|
|
334
|
+
from cost_intelligence import classify_task_class, router_params
|
|
335
|
+
p = router_params(classify_task_class(user_message, reasoning_mode=reasoning_mode,
|
|
336
|
+
has_image=has_image))
|
|
337
|
+
return await self.route(request, tier=p["tier"], task_type=p["task_type"])
|
|
338
|
+
|
|
339
|
+
def stream_for_message(self, request: LLMRequest, *, user_message: str,
|
|
340
|
+
has_image: bool = False, reasoning_mode: str = "standard"):
|
|
341
|
+
"""Versi streaming route_for_message (kembalikan async generator)."""
|
|
342
|
+
from cost_intelligence import classify_task_class, router_params
|
|
343
|
+
p = router_params(classify_task_class(user_message, reasoning_mode=reasoning_mode,
|
|
344
|
+
has_image=has_image))
|
|
345
|
+
return self.stream(request, tier=p["tier"], task_type=p["task_type"])
|
|
346
|
+
|
|
347
|
+
def status(self) -> dict:
|
|
348
|
+
"""Current availability and circuit-breaker state for all providers."""
|
|
349
|
+
return {
|
|
350
|
+
"gemini": {
|
|
351
|
+
"available": bool(self.gemini and self.gemini.is_available()),
|
|
352
|
+
"model": self.select_model("standard", "chat"),
|
|
353
|
+
"pro_model": self.select_model("pro", "reasoning"),
|
|
354
|
+
**_breaker.state("gemini"),
|
|
355
|
+
},
|
|
356
|
+
"deepseek": {
|
|
357
|
+
"available": bool(self.deepseek and self.deepseek.is_available()),
|
|
358
|
+
"model": self.deepseek.default_model if self.deepseek else None,
|
|
359
|
+
**_breaker.state("deepseek"),
|
|
360
|
+
},
|
|
361
|
+
"openrouter": {
|
|
362
|
+
"available": bool(self.openrouter and self.openrouter.is_available()),
|
|
363
|
+
"model": self._or_model("chat"),
|
|
364
|
+
**_breaker.state("openrouter"),
|
|
365
|
+
},
|
|
366
|
+
"groq": {
|
|
367
|
+
"available": bool(self.groq and self.groq.is_available()),
|
|
368
|
+
"model": self.groq.default_model if self.groq else None,
|
|
369
|
+
**_breaker.state("groq"),
|
|
370
|
+
},
|
|
371
|
+
}
|
ai_providers/types.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, AsyncGenerator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProviderType(str, Enum):
|
|
9
|
+
GEMINI = "gemini"
|
|
10
|
+
GROQ = "groq"
|
|
11
|
+
OPENROUTER = "openrouter"
|
|
12
|
+
DEEPSEEK = "deepseek"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskType(str, Enum):
|
|
16
|
+
CHAT = "chat"
|
|
17
|
+
CS = "cs"
|
|
18
|
+
FAQ = "faq"
|
|
19
|
+
SALES = "sales"
|
|
20
|
+
MARKETING = "marketing"
|
|
21
|
+
HR = "hr"
|
|
22
|
+
KNOWLEDGE = "knowledge"
|
|
23
|
+
DOCUMENT = "document"
|
|
24
|
+
REASONING = "reasoning"
|
|
25
|
+
PLANNING = "planning"
|
|
26
|
+
CODING = "coding"
|
|
27
|
+
WORKFLOW = "workflow"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Tasks that need the heavier Pro model
|
|
31
|
+
PRO_TASK_TYPES: frozenset[str] = frozenset({
|
|
32
|
+
TaskType.DOCUMENT, TaskType.REASONING, TaskType.PLANNING,
|
|
33
|
+
TaskType.CODING, TaskType.WORKFLOW,
|
|
34
|
+
"document_analysis", "deep_reasoning", "business_planning",
|
|
35
|
+
"advanced_coding", "complex_workflow",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
# Tasks that run fine on Flash
|
|
39
|
+
FLASH_TASK_TYPES: frozenset[str] = frozenset({
|
|
40
|
+
TaskType.CHAT, TaskType.CS, TaskType.FAQ, TaskType.SALES,
|
|
41
|
+
TaskType.MARKETING, TaskType.HR, TaskType.KNOWLEDGE,
|
|
42
|
+
"customer_service", "knowledge_search", "internal",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class LLMRequest:
|
|
48
|
+
messages: list[dict]
|
|
49
|
+
temperature: float = 0.3
|
|
50
|
+
max_tokens: int = 1024
|
|
51
|
+
response_format: dict | None = None
|
|
52
|
+
stream: bool = False
|
|
53
|
+
tools: list[dict] | None = None
|
|
54
|
+
images: list[bytes | str] | None = None # base64 str or raw bytes
|
|
55
|
+
pdfs: list[bytes | str] | None = None # base64 str or raw bytes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class LLMResponse:
|
|
60
|
+
content: str
|
|
61
|
+
model: str
|
|
62
|
+
provider: str
|
|
63
|
+
prompt_tokens: int = 0
|
|
64
|
+
completion_tokens: int = 0
|
|
65
|
+
latency_ms: int = 0
|
|
66
|
+
retries: int = 0
|
|
67
|
+
error: str | None = None
|
|
68
|
+
tool_calls: list[dict] = field(default_factory=list)
|
|
69
|
+
stream_gen: Any = None # AsyncGenerator[str, None] when streaming
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def total_tokens(self) -> int:
|
|
73
|
+
return self.prompt_tokens + self.completion_tokens
|