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
agent_registry.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent_registry.py — Agent Directory + Admin Agent (AI Agent Platform Phase 5).
|
|
3
|
+
|
|
4
|
+
Katalog deklaratif setiap "digital employee" agent di platform ini (BUKAN
|
|
5
|
+
agent mesin-internal reasoning pipeline seperti Devil's Advocate/Verification/
|
|
6
|
+
Planner/Intent Classifier -- itu komponen reasoning, bukan "karyawan").
|
|
7
|
+
`list_agents()` membaca atribut `skills`/`tools`/`goals` yang SUDAH ADA di
|
|
8
|
+
tiap class lewat import dinamis -- tidak hardcode duplikat supaya tidak
|
|
9
|
+
drift kalau atribut itu berubah. Katalog agent (data proprietary) dan
|
|
10
|
+
`AdminAgent` (fitur agregasi Cloud) berada di modul terpisah dan di-import
|
|
11
|
+
secara guarded, supaya mekanisme registry ini reusable di `botnesia-core`.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from base import BaseAgent
|
|
19
|
+
|
|
20
|
+
CHAT_PIPELINE = "chat_pipeline"
|
|
21
|
+
AUTHENTICATED_API = "authenticated_api"
|
|
22
|
+
|
|
23
|
+
# ── Proprietary catalog seam (Open-Core extraction) ─────────────────────────
|
|
24
|
+
# The agent *catalog* (which digital-employee agents exist, their RBAC per
|
|
25
|
+
# category, and the heuristic routing keywords) is BotNesia's proprietary AI
|
|
26
|
+
# Workforce definition. It lives in `agent_directory_data` (Cloud/Enterprise
|
|
27
|
+
# only) so this registry *mechanism* can be reused inside the open
|
|
28
|
+
# `botnesia-core` package without exposing the catalog.
|
|
29
|
+
#
|
|
30
|
+
# In the Cloud/Enterprise deployment `agent_directory_data` is always present,
|
|
31
|
+
# so these names bind to the full catalog and behaviour is byte-identical.
|
|
32
|
+
# In botnesia-core (where that module is absent) they fall back to empty
|
|
33
|
+
# structures: the registry machinery still works, users register their own
|
|
34
|
+
# agents, and no proprietary names are shipped.
|
|
35
|
+
try:
|
|
36
|
+
from agent_directory_data import (
|
|
37
|
+
AGENT_DIRECTORY,
|
|
38
|
+
ORCHESTRATION_EXTRA,
|
|
39
|
+
AGENT_PERMISSION_BY_CATEGORY,
|
|
40
|
+
AGENT_CAPABILITY_KEYWORDS,
|
|
41
|
+
)
|
|
42
|
+
except ImportError: # pragma: no cover - core-only path (catalog not installed)
|
|
43
|
+
AGENT_DIRECTORY: list[tuple[str, str, str, str]] = []
|
|
44
|
+
ORCHESTRATION_EXTRA: list[tuple[str, str, str, str]] = []
|
|
45
|
+
AGENT_PERMISSION_BY_CATEGORY: dict[str, str | None] = {}
|
|
46
|
+
AGENT_CAPABILITY_KEYWORDS: dict[str, list[str]] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class OrchestrationAgentSpec:
|
|
51
|
+
"""Deskriptor satu agent yang bisa dipanggil orkestrator internal."""
|
|
52
|
+
name: str
|
|
53
|
+
class_name: str
|
|
54
|
+
category: str
|
|
55
|
+
module_path: str
|
|
56
|
+
permission: str | None
|
|
57
|
+
capabilities: list[str]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _overrides_run(cls: type) -> bool:
|
|
61
|
+
"""True bila class benar-benar mengimplementasikan run() sendiri.
|
|
62
|
+
|
|
63
|
+
Dasar penemuan DINAMIS: hanya agent dengan run(context) sendiri yang bisa
|
|
64
|
+
di-orkestrasi seragam via safe_run(). Agent berbasis fungsi-modul (mis.
|
|
65
|
+
operations/security tanpa run()) otomatis TIDAK terdaftar sampai punya run()
|
|
66
|
+
— tak perlu daftar hardcode terpisah.
|
|
67
|
+
"""
|
|
68
|
+
return getattr(cls, "run", None) is not getattr(BaseAgent, "run", None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_agent(module_path: str, class_name: str, **kwargs) -> BaseAgent:
|
|
72
|
+
"""Instansiasi agent secara dinamis dari (module_path, class_name).
|
|
73
|
+
|
|
74
|
+
Kwarg yang tidak diterima __init__ agent DIBUANG otomatis (kecuali agent
|
|
75
|
+
memakai **kwargs) supaya config LLM bersama aman dipakai lintas agent yang
|
|
76
|
+
signature-nya beda-beda — tanpa TypeError.
|
|
77
|
+
"""
|
|
78
|
+
import inspect
|
|
79
|
+
module = importlib.import_module(module_path)
|
|
80
|
+
cls = getattr(module, class_name)
|
|
81
|
+
try:
|
|
82
|
+
sig = inspect.signature(cls.__init__)
|
|
83
|
+
has_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
|
84
|
+
if not has_var_kw:
|
|
85
|
+
allowed = set(sig.parameters) - {"self"}
|
|
86
|
+
kwargs = {k: v for k, v in kwargs.items() if k in allowed}
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
pass
|
|
89
|
+
return cls(**kwargs)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def orchestration_agents(
|
|
93
|
+
*, allowed_permissions: set[str] | None = None
|
|
94
|
+
) -> list[OrchestrationAgentSpec]:
|
|
95
|
+
"""Daftar agent yang boleh dipanggil orkestrator, difilter RBAC.
|
|
96
|
+
|
|
97
|
+
Penemuan dinamis: gabung AGENT_DIRECTORY + ORCHESTRATION_EXTRA, sisakan yang
|
|
98
|
+
override run(), lalu filter berdasarkan permission efektif user. Bila
|
|
99
|
+
allowed_permissions None → tanpa filter (mis. konteks super-admin/test).
|
|
100
|
+
"""
|
|
101
|
+
specs: list[OrchestrationAgentSpec] = []
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
for module_path, class_name, category, _channel in (*AGENT_DIRECTORY, *ORCHESTRATION_EXTRA):
|
|
104
|
+
if class_name in seen:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
module = importlib.import_module(module_path)
|
|
108
|
+
cls = getattr(module, class_name)
|
|
109
|
+
except Exception:
|
|
110
|
+
continue
|
|
111
|
+
if not _overrides_run(cls):
|
|
112
|
+
continue
|
|
113
|
+
perm = AGENT_PERMISSION_BY_CATEGORY.get(category)
|
|
114
|
+
if (
|
|
115
|
+
perm is not None
|
|
116
|
+
and allowed_permissions is not None
|
|
117
|
+
and perm not in allowed_permissions
|
|
118
|
+
and "*" not in allowed_permissions
|
|
119
|
+
):
|
|
120
|
+
continue
|
|
121
|
+
seen.add(class_name)
|
|
122
|
+
specs.append(OrchestrationAgentSpec(
|
|
123
|
+
name=getattr(cls, "name", class_name),
|
|
124
|
+
class_name=class_name,
|
|
125
|
+
category=category,
|
|
126
|
+
module_path=module_path,
|
|
127
|
+
permission=perm,
|
|
128
|
+
capabilities=AGENT_CAPABILITY_KEYWORDS.get(category, []),
|
|
129
|
+
))
|
|
130
|
+
return specs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def list_agents() -> list[dict]:
|
|
134
|
+
agents: list[dict] = []
|
|
135
|
+
for module_path, class_name, category, channel in AGENT_DIRECTORY:
|
|
136
|
+
try:
|
|
137
|
+
module = importlib.import_module(module_path)
|
|
138
|
+
cls = getattr(module, class_name)
|
|
139
|
+
except Exception:
|
|
140
|
+
continue
|
|
141
|
+
agents.append({
|
|
142
|
+
"name": getattr(cls, "name", class_name),
|
|
143
|
+
"class_name": class_name,
|
|
144
|
+
"category": category,
|
|
145
|
+
"channel": channel,
|
|
146
|
+
"skills": list(getattr(cls, "skills", [])),
|
|
147
|
+
"tools": list(getattr(cls, "tools", [])),
|
|
148
|
+
"goals": list(getattr(cls, "goals", [])),
|
|
149
|
+
})
|
|
150
|
+
return agents
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_agent(name: str) -> dict | None:
|
|
154
|
+
for agent in list_agents():
|
|
155
|
+
if agent["name"] == name:
|
|
156
|
+
return agent
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── AdminAgent seam (Open-Core extraction) ──────────────────────────────────
|
|
161
|
+
# AdminAgent.platform_overview() aggregates Cloud subsystems (execution log,
|
|
162
|
+
# workforce orchestrator, computer agent, local-agent commands), so the class
|
|
163
|
+
# is a Cloud/Enterprise feature and lives in `agent_admin` (not open-sourced).
|
|
164
|
+
# Re-exported here so `from agent_registry import AdminAgent` keeps working in
|
|
165
|
+
# the Cloud deployment; in botnesia-core the module is absent and AdminAgent is
|
|
166
|
+
# None (the open registry ships without it and without those subsystem names).
|
|
167
|
+
try:
|
|
168
|
+
from agent_admin import AdminAgent # noqa: F401
|
|
169
|
+
except ImportError: # pragma: no cover - core-only path (AdminAgent not installed)
|
|
170
|
+
AdminAgent = None # type: ignore[assignment,misc]
|
ai_providers/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from ai_providers.types import LLMRequest, LLMResponse, ProviderType, TaskType
|
|
2
|
+
from ai_providers.base import AIProvider
|
|
3
|
+
from ai_providers.gemini import GeminiProvider
|
|
4
|
+
from ai_providers.groq_provider import GroqProvider
|
|
5
|
+
from ai_providers.openrouter import OpenRouterProvider
|
|
6
|
+
from ai_providers.deepseek import DeepSeekProvider
|
|
7
|
+
from ai_providers.router import SmartModelRouter
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LLMRequest",
|
|
11
|
+
"LLMResponse",
|
|
12
|
+
"ProviderType",
|
|
13
|
+
"TaskType",
|
|
14
|
+
"AIProvider",
|
|
15
|
+
"GeminiProvider",
|
|
16
|
+
"GroqProvider",
|
|
17
|
+
"OpenRouterProvider",
|
|
18
|
+
"DeepSeekProvider",
|
|
19
|
+
"SmartModelRouter",
|
|
20
|
+
]
|
ai_providers/base.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from ai_providers.types import LLMRequest, LLMResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AIProvider(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
async def complete(self, request: LLMRequest, *, model: str | None = None) -> LLMResponse:
|
|
11
|
+
"""Non-streaming completion. `model` overrides the provider default."""
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def stream(self, request: LLMRequest, *, model: str | None = None):
|
|
16
|
+
"""Async generator yielding str chunks."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def is_available(self) -> bool:
|
|
21
|
+
"""True if provider has a usable API key."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def provider_name(self) -> str: ...
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def default_model(self) -> str: ...
|
ai_providers/deepseek.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ai_providers/deepseek.py — DeepSeek provider.
|
|
3
|
+
|
|
4
|
+
DeepSeek exposes an OpenAI-compatible API at https://api.deepseek.com.
|
|
5
|
+
Models available:
|
|
6
|
+
deepseek-chat — DeepSeek-V3, general purpose (fast, cheap, excellent for coding)
|
|
7
|
+
deepseek-reasoner — DeepSeek-R1, chain-of-thought reasoning (slower, deeper analysis)
|
|
8
|
+
|
|
9
|
+
Set DEEPSEEK_API_KEY in .env to activate. When set, the SmartModelRouter
|
|
10
|
+
uses DeepSeek directly for coding and reasoning tasks — without the OpenRouter
|
|
11
|
+
markup — before falling back to OpenRouter or Groq.
|
|
12
|
+
"""
|
|
13
|
+
import asyncio
|
|
14
|
+
import json as _json
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ai_providers.base import AIProvider
|
|
21
|
+
from ai_providers.types import LLMRequest, LLMResponse
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("botnesia.deepseek")
|
|
24
|
+
|
|
25
|
+
_BASE_URL = "https://api.deepseek.com"
|
|
26
|
+
_RETRYABLE = frozenset({429, 500, 502, 503, 504})
|
|
27
|
+
|
|
28
|
+
# Default model per task type when routing through DeepSeek directly.
|
|
29
|
+
# CATATAN: task-routing internal ini SENGAJA memakai model DeepSeek yang sudah
|
|
30
|
+
# terbukti (deepseek-chat/deepseek-reasoner) agar pipeline lama tetap stabil.
|
|
31
|
+
# Nama model per-tier "3 otak" yang env-driven ditangani terpisah di
|
|
32
|
+
# deepseek_brain.py (lihat docs/DEEPSEEK_BOTNESIA_BRAIN.md).
|
|
33
|
+
DEEPSEEK_TASK_MODELS: dict = {
|
|
34
|
+
"coding": "deepseek-chat",
|
|
35
|
+
"advanced_coding": "deepseek-chat",
|
|
36
|
+
"reasoning": "deepseek-reasoner",
|
|
37
|
+
"deep_reasoning": "deepseek-reasoner",
|
|
38
|
+
"planning": "deepseek-chat",
|
|
39
|
+
"document": "deepseek-chat",
|
|
40
|
+
"document_analysis": "deepseek-chat",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Tasks where DeepSeek should NOT be the primary (let Gemini handle these)
|
|
44
|
+
_SKIP_TASKS = frozenset({
|
|
45
|
+
"chat", "cs", "customer_service", "faq", "sales",
|
|
46
|
+
"marketing", "hr", "knowledge", "knowledge_search", "internal",
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def deepseek_model_for_task(task_type: str) -> str | None:
|
|
51
|
+
"""Return DeepSeek model for task, or None if task is better handled elsewhere."""
|
|
52
|
+
task = (task_type or "chat").lower()
|
|
53
|
+
if task in _SKIP_TASKS:
|
|
54
|
+
return None
|
|
55
|
+
return DEEPSEEK_TASK_MODELS.get(task, "deepseek-chat")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _add_token_usage(model: str, pt: int, ct: int) -> None:
|
|
59
|
+
try:
|
|
60
|
+
from agent_observability import add_token_usage
|
|
61
|
+
add_token_usage(model=model, prompt_tokens=pt, completion_tokens=ct)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DeepSeekProvider(AIProvider):
|
|
67
|
+
"""
|
|
68
|
+
DeepSeek direct API — best for coding (deepseek-chat/V3) and
|
|
69
|
+
chain-of-thought reasoning (deepseek-reasoner/R1).
|
|
70
|
+
|
|
71
|
+
Cheaper than routing through OpenRouter because there is no intermediary markup.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
api_key: str,
|
|
77
|
+
model: str = "deepseek-chat",
|
|
78
|
+
base_url: str = _BASE_URL,
|
|
79
|
+
max_retries: int = 2,
|
|
80
|
+
timeout: float = 120.0,
|
|
81
|
+
):
|
|
82
|
+
self.api_key = api_key
|
|
83
|
+
self.model = model
|
|
84
|
+
self.base_url = base_url.rstrip("/")
|
|
85
|
+
self.max_retries = max_retries
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
self._client: httpx.AsyncClient | None = None
|
|
88
|
+
|
|
89
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
90
|
+
if self._client is None or self._client.is_closed:
|
|
91
|
+
self._client = httpx.AsyncClient(timeout=self.timeout)
|
|
92
|
+
return self._client
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
if self._client and not self._client.is_closed:
|
|
96
|
+
await self._client.aclose()
|
|
97
|
+
self._client = None
|
|
98
|
+
|
|
99
|
+
def is_available(self) -> bool:
|
|
100
|
+
return bool(self.api_key)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def provider_name(self) -> str:
|
|
104
|
+
return "deepseek"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def default_model(self) -> str:
|
|
108
|
+
return self.model
|
|
109
|
+
|
|
110
|
+
def _headers(self) -> dict:
|
|
111
|
+
return {
|
|
112
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async def complete(self, request: LLMRequest, *, model: str | None = None) -> LLMResponse:
|
|
117
|
+
resolved = model or self.model
|
|
118
|
+
payload: dict = {
|
|
119
|
+
"model": resolved,
|
|
120
|
+
"messages": request.messages,
|
|
121
|
+
"temperature": request.temperature,
|
|
122
|
+
"max_tokens": request.max_tokens,
|
|
123
|
+
}
|
|
124
|
+
if request.response_format:
|
|
125
|
+
payload["response_format"] = request.response_format
|
|
126
|
+
|
|
127
|
+
client = self._get_client()
|
|
128
|
+
t0 = time.monotonic()
|
|
129
|
+
last_exc: Exception | None = None
|
|
130
|
+
retries = 0
|
|
131
|
+
|
|
132
|
+
for attempt in range(self.max_retries + 1):
|
|
133
|
+
if attempt:
|
|
134
|
+
await asyncio.sleep(min(2 ** (attempt - 1), 8))
|
|
135
|
+
retries += 1
|
|
136
|
+
try:
|
|
137
|
+
resp = await client.post(
|
|
138
|
+
f"{self.base_url}/chat/completions",
|
|
139
|
+
json=payload,
|
|
140
|
+
headers=self._headers(),
|
|
141
|
+
)
|
|
142
|
+
if resp.status_code in _RETRYABLE and attempt < self.max_retries:
|
|
143
|
+
last_exc = httpx.HTTPStatusError(
|
|
144
|
+
f"status {resp.status_code}", request=resp.request, response=resp
|
|
145
|
+
)
|
|
146
|
+
continue
|
|
147
|
+
resp.raise_for_status()
|
|
148
|
+
data = resp.json() or {}
|
|
149
|
+
break
|
|
150
|
+
except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
|
|
151
|
+
last_exc = exc
|
|
152
|
+
if attempt >= self.max_retries:
|
|
153
|
+
return LLMResponse(
|
|
154
|
+
content="", model=resolved, provider="deepseek",
|
|
155
|
+
latency_ms=int((time.monotonic() - t0) * 1000),
|
|
156
|
+
error=str(exc), retries=retries,
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
return LLMResponse(
|
|
160
|
+
content="", model=resolved, provider="deepseek",
|
|
161
|
+
latency_ms=int((time.monotonic() - t0) * 1000),
|
|
162
|
+
error=str(last_exc), retries=retries,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
166
|
+
usage = data.get("usage") or {}
|
|
167
|
+
pt = int(usage.get("prompt_tokens") or 0)
|
|
168
|
+
ct = int(usage.get("completion_tokens") or 0)
|
|
169
|
+
_add_token_usage(resolved, pt, ct)
|
|
170
|
+
choices = data.get("choices") or []
|
|
171
|
+
content = str(((choices[0] or {}).get("message") or {}).get("content") or "").strip()
|
|
172
|
+
return LLMResponse(
|
|
173
|
+
content=content, model=resolved, provider="deepseek",
|
|
174
|
+
prompt_tokens=pt, completion_tokens=ct,
|
|
175
|
+
latency_ms=latency_ms, retries=retries,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def stream(self, request: LLMRequest, *, model: str | None = None):
|
|
179
|
+
resolved = model or self.model
|
|
180
|
+
payload = {
|
|
181
|
+
"model": resolved,
|
|
182
|
+
"messages": request.messages,
|
|
183
|
+
"temperature": request.temperature,
|
|
184
|
+
"max_tokens": request.max_tokens,
|
|
185
|
+
"stream": True,
|
|
186
|
+
}
|
|
187
|
+
client = self._get_client()
|
|
188
|
+
async with client.stream(
|
|
189
|
+
"POST", f"{self.base_url}/chat/completions",
|
|
190
|
+
json=payload, headers=self._headers(),
|
|
191
|
+
) as resp:
|
|
192
|
+
resp.raise_for_status()
|
|
193
|
+
async for line in resp.aiter_lines():
|
|
194
|
+
if not line.startswith("data: "):
|
|
195
|
+
continue
|
|
196
|
+
raw = line[6:].strip()
|
|
197
|
+
if not raw or raw == "[DONE]":
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
chunk = _json.loads(raw)
|
|
201
|
+
delta = ((chunk.get("choices") or [{}])[0].get("delta") or {})
|
|
202
|
+
text = delta.get("content")
|
|
203
|
+
if text:
|
|
204
|
+
yield text
|
|
205
|
+
except Exception:
|
|
206
|
+
continue
|