hanuscode 1.0.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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- hanuscode-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# connectors/gemini_connector.py
|
|
2
|
+
"""
|
|
3
|
+
Conector Google Gemini. NATIVE_TOOLS = False.
|
|
4
|
+
Autocontenido — usa la API REST de Gemini directamente via urllib.
|
|
5
|
+
No depende de ia_integrations ni del SDK de Google.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import urllib.request
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from typing import List, Dict, Optional, Any, Callable
|
|
12
|
+
|
|
13
|
+
from .base import BaseConnector, ModelResponse
|
|
14
|
+
from .registry import ConnectorRegistry
|
|
15
|
+
|
|
16
|
+
PRICING = {
|
|
17
|
+
"gemini-1.5-pro": {"in": 3.5, "out": 10.5},
|
|
18
|
+
"gemini-1.5-flash": {"in": 0.075, "out": 0.30},
|
|
19
|
+
"gemini-1.5-flash-8b": {"in": 0.0375,"out": 0.15},
|
|
20
|
+
"gemini-2.0-flash": {"in": 0.10, "out": 0.40},
|
|
21
|
+
"gemini-2.0-flash-lite": {"in": 0.075, "out": 0.30},
|
|
22
|
+
"gemini-pro": {"in": 0.5, "out": 1.5},
|
|
23
|
+
}
|
|
24
|
+
MODELS = list(PRICING.keys())
|
|
25
|
+
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@ConnectorRegistry.register("gemini")
|
|
29
|
+
class GeminiConnector(BaseConnector):
|
|
30
|
+
|
|
31
|
+
NATIVE_TOOLS = False # Usa XML fallback en el engine
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: Dict[str, Any]):
|
|
34
|
+
super().__init__(config)
|
|
35
|
+
self.api_key = config.get("api_key", "")
|
|
36
|
+
self.model_id = config.get("model_id", "gemini-1.5-flash")
|
|
37
|
+
self.max_tokens = config.get("max_tokens", 4096)
|
|
38
|
+
self.temperature = float(config.get("temperature", 0.3))
|
|
39
|
+
|
|
40
|
+
def validate(self) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
url = f"{API_BASE}/{self.model_id}?key={self.api_key}"
|
|
43
|
+
with urllib.request.urlopen(url, timeout=5) as r:
|
|
44
|
+
return r.status == 200
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def chat(
|
|
49
|
+
self,
|
|
50
|
+
messages: List[Dict],
|
|
51
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
52
|
+
) -> ModelResponse:
|
|
53
|
+
resp = ModelResponse(model_id=self.model_id)
|
|
54
|
+
|
|
55
|
+
# Convertir historial al formato de Gemini
|
|
56
|
+
system_parts = []
|
|
57
|
+
gemini_contents = []
|
|
58
|
+
|
|
59
|
+
for m in messages:
|
|
60
|
+
role = m["role"]
|
|
61
|
+
content = str(m["content"])
|
|
62
|
+
if role == "system":
|
|
63
|
+
system_parts.append({"text": content})
|
|
64
|
+
elif role == "user":
|
|
65
|
+
gemini_contents.append({"role": "user", "parts": [{"text": content}]})
|
|
66
|
+
elif role == "assistant":
|
|
67
|
+
gemini_contents.append({"role": "model", "parts": [{"text": content}]})
|
|
68
|
+
|
|
69
|
+
# Asegurar que empiece con user
|
|
70
|
+
if not gemini_contents:
|
|
71
|
+
resp.text = "[Error Gemini] Sin mensajes de usuario"
|
|
72
|
+
resp.stop_reason = "error"
|
|
73
|
+
return resp
|
|
74
|
+
|
|
75
|
+
# Si el último mensaje es del model, añadir dummy user para continuar
|
|
76
|
+
if gemini_contents[-1]["role"] == "model":
|
|
77
|
+
gemini_contents.append({"role": "user", "parts": [{"text": "Continúa."}]})
|
|
78
|
+
|
|
79
|
+
body: Dict[str, Any] = {
|
|
80
|
+
"contents": gemini_contents,
|
|
81
|
+
"generationConfig": {
|
|
82
|
+
"temperature": self.temperature,
|
|
83
|
+
"maxOutputTokens": self.max_tokens,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
if system_parts:
|
|
87
|
+
body["systemInstruction"] = {"parts": system_parts}
|
|
88
|
+
|
|
89
|
+
url = f"{API_BASE}/{self.model_id}:generateContent?key={self.api_key}"
|
|
90
|
+
payload = json.dumps(body).encode("utf-8")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
req = urllib.request.Request(
|
|
94
|
+
url,
|
|
95
|
+
data=payload,
|
|
96
|
+
headers={"Content-Type": "application/json"},
|
|
97
|
+
method="POST",
|
|
98
|
+
)
|
|
99
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
100
|
+
data = json.loads(r.read())
|
|
101
|
+
|
|
102
|
+
# Extraer texto
|
|
103
|
+
candidates = data.get("candidates", [])
|
|
104
|
+
if not candidates:
|
|
105
|
+
resp.text = f"[Gemini] Sin candidatos en respuesta: {data}"
|
|
106
|
+
resp.stop_reason = "error"
|
|
107
|
+
return resp
|
|
108
|
+
|
|
109
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
110
|
+
text = "".join(p.get("text", "") for p in parts)
|
|
111
|
+
resp.text = text
|
|
112
|
+
|
|
113
|
+
if stream_callback and text:
|
|
114
|
+
stream_callback(text)
|
|
115
|
+
|
|
116
|
+
# Tokens
|
|
117
|
+
usage = data.get("usageMetadata", {})
|
|
118
|
+
resp.input_tokens = usage.get("promptTokenCount", len(str(body)) // 4)
|
|
119
|
+
resp.output_tokens = usage.get("candidatesTokenCount", len(text) // 4)
|
|
120
|
+
resp.stop_reason = "end_turn"
|
|
121
|
+
|
|
122
|
+
except urllib.error.HTTPError as e:
|
|
123
|
+
body_err = e.read().decode("utf-8", errors="replace")
|
|
124
|
+
resp.text = f"[Error Gemini HTTP {e.code}] {body_err[:300]}"
|
|
125
|
+
resp.stop_reason = "error"
|
|
126
|
+
except Exception as e:
|
|
127
|
+
resp.text = f"[Error Gemini] {type(e).__name__}: {e}"
|
|
128
|
+
resp.stop_reason = "error"
|
|
129
|
+
|
|
130
|
+
p = PRICING.get(self.model_id, {"in": 0.5, "out": 1.5})
|
|
131
|
+
resp.calc_cost(p["in"], p["out"])
|
|
132
|
+
self.record(resp)
|
|
133
|
+
return resp
|
|
134
|
+
|
|
135
|
+
def count_tokens(self, text: str) -> int:
|
|
136
|
+
return len(text) // 4
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def provider_name(self) -> str: return "Google Gemini"
|
|
140
|
+
@property
|
|
141
|
+
def available_models(self) -> List[str]: return MODELS
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# connectors/glm_connector.py
|
|
2
|
+
"""
|
|
3
|
+
Conector GLM Cloud (ZhipuAI). NATIVE_TOOLS = False.
|
|
4
|
+
Soporta modelos GLM-4, GLM-4-Flash, GLM-5, etc.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
import urllib.error
|
|
11
|
+
from typing import List, Dict, Optional, Any, Callable
|
|
12
|
+
|
|
13
|
+
from .base import BaseConnector, ModelResponse
|
|
14
|
+
from .registry import ConnectorRegistry
|
|
15
|
+
|
|
16
|
+
# Modelos disponibles
|
|
17
|
+
GLM_MODELS = {
|
|
18
|
+
"glm-4": "glm-4",
|
|
19
|
+
"glm-4-flash": "glm-4-flash",
|
|
20
|
+
"glm-4-plus": "glm-4-plus",
|
|
21
|
+
"glm-5": "glm-5",
|
|
22
|
+
"glm-5:cloud": "glm-5", # Alias
|
|
23
|
+
"glm-5-cloud": "glm-5",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
API_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@ConnectorRegistry.register("glm")
|
|
30
|
+
class GLMConnector(BaseConnector):
|
|
31
|
+
|
|
32
|
+
NATIVE_TOOLS = False # Usa XML fallback en el engine
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: Dict[str, Any]):
|
|
35
|
+
super().__init__(config)
|
|
36
|
+
self.api_key = config.get("glm_api_key", "") or config.get("api_key", "")
|
|
37
|
+
self.model_id = GLM_MODELS.get(config.get("model_id", "glm-4"), "glm-4")
|
|
38
|
+
self.max_tokens = config.get("max_tokens", 4096)
|
|
39
|
+
self.temperature = float(config.get("temperature", 0.7))
|
|
40
|
+
|
|
41
|
+
def validate(self) -> bool:
|
|
42
|
+
if not self.api_key:
|
|
43
|
+
return False
|
|
44
|
+
try:
|
|
45
|
+
resp = self.chat([{"role": "user", "content": "hi"}])
|
|
46
|
+
return resp.stop_reason != "error"
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def chat(
|
|
51
|
+
self,
|
|
52
|
+
messages: List[Dict],
|
|
53
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
54
|
+
) -> ModelResponse:
|
|
55
|
+
resp = ModelResponse(model_id=f"glm/{self.model_id}")
|
|
56
|
+
|
|
57
|
+
if not self.api_key:
|
|
58
|
+
resp.text = "[Error GLM] No hay API key configurada.\nConfigura glm_api_key en ~/.hanus/config.yaml"
|
|
59
|
+
resp.stop_reason = "error"
|
|
60
|
+
return resp
|
|
61
|
+
|
|
62
|
+
# Construir mensajes para la API
|
|
63
|
+
api_messages = []
|
|
64
|
+
for m in messages:
|
|
65
|
+
role = m["role"]
|
|
66
|
+
if role not in ("system", "user", "assistant"):
|
|
67
|
+
continue
|
|
68
|
+
api_messages.append({"role": role, "content": str(m["content"])})
|
|
69
|
+
|
|
70
|
+
payload = json.dumps({
|
|
71
|
+
"model": self.model_id,
|
|
72
|
+
"messages": api_messages,
|
|
73
|
+
"stream": False,
|
|
74
|
+
"max_tokens": self.max_tokens,
|
|
75
|
+
"temperature": self.temperature,
|
|
76
|
+
}).encode("utf-8")
|
|
77
|
+
|
|
78
|
+
MAX_RETRIES = 3
|
|
79
|
+
for attempt in range(MAX_RETRIES):
|
|
80
|
+
try:
|
|
81
|
+
req = urllib.request.Request(
|
|
82
|
+
API_URL,
|
|
83
|
+
data=payload,
|
|
84
|
+
headers={
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
87
|
+
},
|
|
88
|
+
method="POST",
|
|
89
|
+
)
|
|
90
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
91
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
92
|
+
|
|
93
|
+
# Extraer respuesta
|
|
94
|
+
choices = data.get("choices", [])
|
|
95
|
+
if choices:
|
|
96
|
+
content = choices[0].get("message", {}).get("content", "")
|
|
97
|
+
else:
|
|
98
|
+
content = data.get("content", "") or json.dumps(data, ensure_ascii=False)
|
|
99
|
+
|
|
100
|
+
if not content.strip() and attempt < MAX_RETRIES - 1:
|
|
101
|
+
time.sleep(2)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
resp.text = content
|
|
105
|
+
resp.stop_reason = "end_turn"
|
|
106
|
+
|
|
107
|
+
# Tokens
|
|
108
|
+
usage = data.get("usage", {})
|
|
109
|
+
resp.input_tokens = usage.get("prompt_tokens", len(str(api_messages)) // 4)
|
|
110
|
+
resp.output_tokens = usage.get("completion_tokens", len(content) // 4)
|
|
111
|
+
resp.cost_usd = 0.0
|
|
112
|
+
|
|
113
|
+
if stream_callback and content:
|
|
114
|
+
stream_callback(content)
|
|
115
|
+
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
except urllib.error.HTTPError as e:
|
|
119
|
+
error_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
|
120
|
+
if e.code == 401:
|
|
121
|
+
resp.text = "[Error GLM] API key inválida. Verifica tu glm_api_key"
|
|
122
|
+
elif e.code == 429:
|
|
123
|
+
resp.text = f"[Error GLM] Rate limit alcanzado. Espera un momento."
|
|
124
|
+
else:
|
|
125
|
+
resp.text = f"[Error GLM HTTP {e.code}] {error_body[:300]}"
|
|
126
|
+
resp.stop_reason = "error"
|
|
127
|
+
if attempt < MAX_RETRIES - 1:
|
|
128
|
+
time.sleep(3)
|
|
129
|
+
continue
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
except urllib.error.URLError as e:
|
|
133
|
+
if attempt < MAX_RETRIES - 1:
|
|
134
|
+
time.sleep(3)
|
|
135
|
+
continue
|
|
136
|
+
resp.text = f"[Error GLM] No se pudo conectar a {API_URL}\nError: {e}"
|
|
137
|
+
resp.stop_reason = "error"
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
if attempt < MAX_RETRIES - 1:
|
|
142
|
+
time.sleep(3)
|
|
143
|
+
continue
|
|
144
|
+
resp.text = f"[Error GLM] {type(e).__name__}: {e}"
|
|
145
|
+
resp.stop_reason = "error"
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
self.record(resp)
|
|
149
|
+
return resp
|
|
150
|
+
|
|
151
|
+
def count_tokens(self, text: str) -> int:
|
|
152
|
+
return len(text) // 4
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def provider_name(self) -> str:
|
|
156
|
+
return f"GLM Cloud ({self.model_id})"
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def available_models(self) -> List[str]:
|
|
160
|
+
return list(GLM_MODELS.keys())
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# connectors/ollama_connector.py
|
|
2
|
+
"""
|
|
3
|
+
Conector Ollama para modelos locales. NATIVE_TOOLS = False.
|
|
4
|
+
Completamente autocontenido — sin dependencias de ia_integrations.
|
|
5
|
+
Usa la API REST de Ollama directamente via urllib.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import urllib.request
|
|
11
|
+
import urllib.error
|
|
12
|
+
from typing import List, Dict, Optional, Any, Callable
|
|
13
|
+
|
|
14
|
+
from .base import BaseConnector, ModelResponse
|
|
15
|
+
from .registry import ConnectorRegistry
|
|
16
|
+
|
|
17
|
+
COMMON_MODELS = [
|
|
18
|
+
"llama3", "llama3:8b", "llama3:70b",
|
|
19
|
+
"llama3.1", "llama3.2",
|
|
20
|
+
"mistral", "mistral:7b", "mixtral",
|
|
21
|
+
"codellama", "codellama:7b", "codellama:34b",
|
|
22
|
+
"phi3", "phi3:mini", "phi3:medium",
|
|
23
|
+
"qwen2.5:3b-instruct", "qwen2.5:7b", "qwen2.5-coder",
|
|
24
|
+
"deepseek-coder", "deepseek-coder-v2",
|
|
25
|
+
"gemma2", "gemma2:9b",
|
|
26
|
+
"neural-chat", "dolphin-mistral",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@ConnectorRegistry.register("ollama")
|
|
31
|
+
class OllamaConnector(BaseConnector):
|
|
32
|
+
|
|
33
|
+
NATIVE_TOOLS = False # Usa XML fallback en el engine
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: Dict[str, Any]):
|
|
36
|
+
super().__init__(config)
|
|
37
|
+
self.base_url = config.get("ollama_url", "http://localhost:11434").rstrip("/")
|
|
38
|
+
self.model_id = config.get("model_id", "llama3")
|
|
39
|
+
self.max_tokens = config.get("max_tokens", 4096)
|
|
40
|
+
self.temperature = float(config.get("temperature", 0.3))
|
|
41
|
+
|
|
42
|
+
def validate(self) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
req = urllib.request.Request(f"{self.base_url}/api/tags")
|
|
45
|
+
with urllib.request.urlopen(req, timeout=4) as r:
|
|
46
|
+
return r.status == 200
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def chat(
|
|
51
|
+
self,
|
|
52
|
+
messages: List[Dict],
|
|
53
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
54
|
+
) -> ModelResponse:
|
|
55
|
+
resp = ModelResponse(model_id=self.model_id)
|
|
56
|
+
|
|
57
|
+
# Extraer system y construir lista de mensajes para /api/chat
|
|
58
|
+
api_messages = []
|
|
59
|
+
for m in messages:
|
|
60
|
+
role = m["role"]
|
|
61
|
+
if role not in ("system", "user", "assistant"):
|
|
62
|
+
continue
|
|
63
|
+
api_messages.append({"role": role, "content": str(m["content"])})
|
|
64
|
+
|
|
65
|
+
payload = json.dumps({
|
|
66
|
+
"model": self.model_id,
|
|
67
|
+
"messages": api_messages,
|
|
68
|
+
"stream": True,
|
|
69
|
+
"options": {
|
|
70
|
+
"temperature": self.temperature,
|
|
71
|
+
"num_predict": self.max_tokens,
|
|
72
|
+
},
|
|
73
|
+
}).encode("utf-8")
|
|
74
|
+
|
|
75
|
+
MAX_RETRIES = 3
|
|
76
|
+
for attempt in range(MAX_RETRIES):
|
|
77
|
+
try:
|
|
78
|
+
req = urllib.request.Request(
|
|
79
|
+
f"{self.base_url}/api/chat",
|
|
80
|
+
data=payload,
|
|
81
|
+
headers={"Content-Type": "application/json"},
|
|
82
|
+
method="POST",
|
|
83
|
+
)
|
|
84
|
+
with urllib.request.urlopen(req, timeout=300) as r:
|
|
85
|
+
full_text = ""
|
|
86
|
+
for raw_line in r:
|
|
87
|
+
line = raw_line.decode("utf-8").strip()
|
|
88
|
+
if not line:
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
chunk = json.loads(line)
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Check for error in response
|
|
96
|
+
if "error" in chunk:
|
|
97
|
+
resp.text = f"[Error Ollama] {chunk['error']}"
|
|
98
|
+
resp.stop_reason = "error"
|
|
99
|
+
return resp
|
|
100
|
+
|
|
101
|
+
token = chunk.get("message", {}).get("content", "")
|
|
102
|
+
if token:
|
|
103
|
+
full_text += token
|
|
104
|
+
if stream_callback:
|
|
105
|
+
stream_callback(token)
|
|
106
|
+
|
|
107
|
+
if chunk.get("done"):
|
|
108
|
+
resp.input_tokens = chunk.get("prompt_eval_count", len(full_text) // 8)
|
|
109
|
+
resp.output_tokens = chunk.get("eval_count", len(full_text) // 4)
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
# Respuesta vacía → reintentar
|
|
113
|
+
if not full_text.strip() and attempt < MAX_RETRIES - 1:
|
|
114
|
+
time.sleep(2)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
resp.text = full_text
|
|
118
|
+
resp.stop_reason = "end_turn"
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
except urllib.error.HTTPError as e:
|
|
122
|
+
error_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
|
123
|
+
if attempt < MAX_RETRIES - 1:
|
|
124
|
+
time.sleep(3)
|
|
125
|
+
continue
|
|
126
|
+
resp.text = (
|
|
127
|
+
f"[Error Ollama HTTP {e.code}] {error_body[:500]}\n"
|
|
128
|
+
f"URL: {self.base_url}/api/chat\n"
|
|
129
|
+
f"Modelo: {self.model_id}"
|
|
130
|
+
)
|
|
131
|
+
resp.stop_reason = "error"
|
|
132
|
+
break
|
|
133
|
+
except urllib.error.URLError as e:
|
|
134
|
+
if attempt < MAX_RETRIES - 1:
|
|
135
|
+
time.sleep(3)
|
|
136
|
+
continue
|
|
137
|
+
resp.text = (
|
|
138
|
+
f"[Error Ollama] No se pudo conectar a {self.base_url}.\n"
|
|
139
|
+
f"Inicia Ollama con: ollama serve\n"
|
|
140
|
+
f"Error: {e.reason}"
|
|
141
|
+
)
|
|
142
|
+
resp.stop_reason = "error"
|
|
143
|
+
break
|
|
144
|
+
except Exception as e:
|
|
145
|
+
if attempt < MAX_RETRIES - 1:
|
|
146
|
+
time.sleep(3)
|
|
147
|
+
continue
|
|
148
|
+
resp.text = f"[Error Ollama] {type(e).__name__}: {e}"
|
|
149
|
+
resp.stop_reason = "error"
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
resp.cost_usd = 0.0 # Sin costo monetario
|
|
153
|
+
self.record(resp)
|
|
154
|
+
return resp
|
|
155
|
+
|
|
156
|
+
def list_local_models(self) -> List[str]:
|
|
157
|
+
"""Retorna modelos instalados localmente."""
|
|
158
|
+
try:
|
|
159
|
+
req = urllib.request.Request(f"{self.base_url}/api/tags")
|
|
160
|
+
with urllib.request.urlopen(req, timeout=4) as r:
|
|
161
|
+
data = json.loads(r.read())
|
|
162
|
+
return [m["name"] for m in data.get("models", [])]
|
|
163
|
+
except Exception:
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
def count_tokens(self, text: str) -> int:
|
|
167
|
+
return len(text) // 4
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def provider_name(self) -> str: return "Ollama (Local)"
|
|
171
|
+
@property
|
|
172
|
+
def available_models(self) -> List[str]:
|
|
173
|
+
local = self.list_local_models()
|
|
174
|
+
return local if local else COMMON_MODELS
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# connectors/openai_connector.py
|
|
2
|
+
"""Conector OpenAI. NATIVE_TOOLS = True. Soporta function calling y streaming."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
from typing import List, Dict, Optional, Any, Callable
|
|
6
|
+
|
|
7
|
+
from .base import BaseConnector, ModelResponse, ToolCall
|
|
8
|
+
from .registry import ConnectorRegistry
|
|
9
|
+
|
|
10
|
+
PRICING = {
|
|
11
|
+
"gpt-4o": {"in": 5.0, "out": 15.0},
|
|
12
|
+
"gpt-4o-mini": {"in": 0.15, "out": 0.60},
|
|
13
|
+
"gpt-4-turbo": {"in": 10.0, "out": 30.0},
|
|
14
|
+
"gpt-3.5-turbo": {"in": 0.5, "out": 1.5},
|
|
15
|
+
"o1": {"in": 15.0, "out": 60.0},
|
|
16
|
+
"o1-mini": {"in": 3.0, "out": 12.0},
|
|
17
|
+
"o3-mini": {"in": 1.1, "out": 4.4},
|
|
18
|
+
}
|
|
19
|
+
MODELS = list(PRICING.keys())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@ConnectorRegistry.register("openai")
|
|
23
|
+
class OpenAIConnector(BaseConnector):
|
|
24
|
+
|
|
25
|
+
NATIVE_TOOLS = True
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: Dict[str, Any]):
|
|
28
|
+
super().__init__(config)
|
|
29
|
+
self.api_key = config.get("api_key", "")
|
|
30
|
+
self.model_id = config.get("model_id", "gpt-4o")
|
|
31
|
+
self.max_tokens = config.get("max_tokens", 4096)
|
|
32
|
+
self._client = None
|
|
33
|
+
|
|
34
|
+
def _get_client(self):
|
|
35
|
+
if self._client is None:
|
|
36
|
+
try:
|
|
37
|
+
from openai import OpenAI
|
|
38
|
+
self._client = OpenAI(api_key=self.api_key)
|
|
39
|
+
except ImportError:
|
|
40
|
+
raise RuntimeError("Instala: pip install openai")
|
|
41
|
+
return self._client
|
|
42
|
+
|
|
43
|
+
def validate(self) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
self._get_client().models.list()
|
|
46
|
+
return True
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def chat(
|
|
51
|
+
self,
|
|
52
|
+
messages: List[Dict],
|
|
53
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
54
|
+
) -> ModelResponse:
|
|
55
|
+
return self._do_chat(messages, tools=None, stream_callback=stream_callback)
|
|
56
|
+
|
|
57
|
+
def chat_with_tools(
|
|
58
|
+
self,
|
|
59
|
+
messages: List[Dict],
|
|
60
|
+
tools: List[Dict],
|
|
61
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
62
|
+
) -> ModelResponse:
|
|
63
|
+
return self._do_chat(messages, tools=tools, stream_callback=stream_callback)
|
|
64
|
+
|
|
65
|
+
def _do_chat(self, messages, tools, stream_callback) -> ModelResponse:
|
|
66
|
+
client = self._get_client()
|
|
67
|
+
resp = ModelResponse(model_id=self.model_id)
|
|
68
|
+
|
|
69
|
+
oai_tools = [{"type": "function", "function": t} for t in tools] if tools else None
|
|
70
|
+
kwargs: Dict[str, Any] = {
|
|
71
|
+
"model": self.model_id,
|
|
72
|
+
"messages": messages,
|
|
73
|
+
"max_tokens": self.max_tokens,
|
|
74
|
+
}
|
|
75
|
+
if oai_tools:
|
|
76
|
+
kwargs["tools"] = oai_tools
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
if stream_callback:
|
|
80
|
+
stream = self._get_client().chat.completions.create(**kwargs, stream=True)
|
|
81
|
+
for chunk in stream:
|
|
82
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
83
|
+
if delta and delta.content:
|
|
84
|
+
resp.text += delta.content
|
|
85
|
+
stream_callback(delta.content)
|
|
86
|
+
resp.stop_reason = "end_turn"
|
|
87
|
+
else:
|
|
88
|
+
msg = client.chat.completions.create(**kwargs)
|
|
89
|
+
choice = msg.choices[0]
|
|
90
|
+
resp.text = choice.message.content or ""
|
|
91
|
+
resp.input_tokens = msg.usage.prompt_tokens
|
|
92
|
+
resp.output_tokens = msg.usage.completion_tokens
|
|
93
|
+
resp.stop_reason = "end_turn"
|
|
94
|
+
if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
|
|
95
|
+
resp.stop_reason = "tool_use"
|
|
96
|
+
for tc in choice.message.tool_calls:
|
|
97
|
+
try: args = json.loads(tc.function.arguments)
|
|
98
|
+
except: args = {}
|
|
99
|
+
resp.tool_calls.append(ToolCall(
|
|
100
|
+
id=tc.id, name=tc.function.name,
|
|
101
|
+
arguments=args, raw=tc,
|
|
102
|
+
))
|
|
103
|
+
except Exception as e:
|
|
104
|
+
resp.text = f"[Error OpenAI] {type(e).__name__}: {e}"
|
|
105
|
+
resp.stop_reason = "error"
|
|
106
|
+
|
|
107
|
+
p = PRICING.get(self.model_id, {"in": 5.0, "out": 15.0})
|
|
108
|
+
resp.calc_cost(p["in"], p["out"])
|
|
109
|
+
self.record(resp)
|
|
110
|
+
return resp
|
|
111
|
+
|
|
112
|
+
def count_tokens(self, text: str) -> int:
|
|
113
|
+
try:
|
|
114
|
+
import tiktoken
|
|
115
|
+
return len(tiktoken.encoding_for_model(self.model_id).encode(text))
|
|
116
|
+
except Exception:
|
|
117
|
+
return len(text) // 4
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def provider_name(self) -> str: return "OpenAI"
|
|
121
|
+
@property
|
|
122
|
+
def available_models(self) -> List[str]: return MODELS
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# connectors/registry.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Dict, Type, Any
|
|
4
|
+
from .base import BaseConnector
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConnectorRegistry:
|
|
8
|
+
_r: Dict[str, Type[BaseConnector]] = {}
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def register(cls, provider: str):
|
|
12
|
+
def decorator(klass: Type[BaseConnector]):
|
|
13
|
+
cls._r[provider.lower()] = klass
|
|
14
|
+
return klass
|
|
15
|
+
return decorator
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def get(cls, provider: str, config: Dict[str, Any]) -> BaseConnector:
|
|
19
|
+
key = provider.lower()
|
|
20
|
+
if key not in cls._r:
|
|
21
|
+
raise ValueError(f"Proveedor '{provider}' no disponible. Disponibles: {cls.available()}")
|
|
22
|
+
return cls._r[key](config)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def available(cls) -> list:
|
|
26
|
+
return sorted(cls._r.keys())
|