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.
Files changed (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. 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())
@@ -0,0 +1,7 @@
1
+ # hanus/context/__init__.py
2
+ """
3
+ Sistema de gestión de contexto para conversaciones largas.
4
+ """
5
+ from hanus.context.manager import ContextManager
6
+
7
+ __all__ = ["ContextManager"]