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
@@ -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