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
hanus/config.py ADDED
@@ -0,0 +1,401 @@
1
+ # config.py
2
+ """
3
+ Configuración de Hanus. Carga desde:
4
+ 1. ~/.hanus/config.yaml (global)
5
+ 2. .hanus.yaml (local, en la raíz del proyecto)
6
+ 3. Variables de entorno (prioridad máxima)
7
+
8
+ También puede usar hanus.yaml en el directorio del paquete como plantilla.
9
+ """
10
+ from __future__ import annotations
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Dict, Any, Optional
15
+
16
+ CONFIG_DIR = Path.home() / ".hanus"
17
+ GLOBAL_CONFIG = CONFIG_DIR / "config.yaml"
18
+ PACKAGE_CONFIG = Path(__file__).parent.parent / "hanus.yaml" # hanus.yaml in package root
19
+
20
+
21
+ def _yaml(path: Path) -> Dict:
22
+ if not path.exists():
23
+ return {}
24
+ try:
25
+ import yaml
26
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
27
+ except ImportError:
28
+ # Parse mínimo sin PyYAML
29
+ out = {}
30
+ for line in path.read_text(encoding="utf-8").splitlines():
31
+ line = line.strip()
32
+ if line and not line.startswith("#") and ":" in line:
33
+ k, _, v = line.partition(":")
34
+ out[k.strip()] = v.strip().strip('"\'')
35
+ return out
36
+ except Exception:
37
+ return {}
38
+
39
+
40
+ @dataclass
41
+ class HanusConfig:
42
+ # ── Modelo ────────────────────────────────────────────────────────────────
43
+ provider: str = "ollama" # claude|openai|gemini|ollama|glm
44
+ model_id: str = "llama3" # Modelo por defecto (GLM via Ollama)
45
+ max_tokens: int = 8096
46
+
47
+ # ── API Keys ──────────────────────────────────────────────────────────────
48
+ anthropic_api_key: str = ""
49
+ openai_api_key: str = ""
50
+ gemini_api_key: str = ""
51
+ glm_api_key: str = "" # API key para GLM Cloud (ZhipuAI)
52
+ ollama_url: str = "http://localhost:11434"
53
+
54
+ # ── Permisos ──────────────────────────────────────────────────────────────
55
+ permission_mode: str = "bypass" # default | plan | bypass
56
+ max_tool_calls: int = 2000
57
+
58
+ # ── Proyecto ──────────────────────────────────────────────────────────────
59
+ root_dir: Path = field(default_factory=Path.cwd)
60
+ plugins_dir: Path = field(default_factory=lambda: Path.cwd() / "plugins")
61
+ prompt_file: Path = field(default_factory=lambda: CONFIG_DIR / "system_prompt.txt")
62
+
63
+ # ── Contexto ──────────────────────────────────────────────────────────────
64
+ context_max_files: int = 50
65
+ context_include_content: bool = True
66
+ context_preview_chars: int = 3000
67
+ max_file_read_chars: int = 100_000
68
+
69
+ # ── Ejecución ─────────────────────────────────────────────────────────────
70
+ shell_timeout: int = 60
71
+ python_exec_timeout: int = 120
72
+
73
+ # ── Presupuesto ───────────────────────────────────────────────────────────
74
+ budget_usd: float = 0.0 # 0 = sin límite
75
+ budget_warn_pct: float = 0.8
76
+
77
+ # ── Sesiones ──────────────────────────────────────────────────────────────
78
+ auto_save_session: bool = True
79
+
80
+ # ── Contexto ────────────────────────────────────────────────────────────────
81
+ context_window: int = 200000 # Ventana de contexto del modelo (tokens)
82
+ context_compress_threshold: float = 0.75 # Comprimir al 75% del límite
83
+
84
+ # ── UI ──────────────────────────────────────────────────────────────────────
85
+ show_cost: bool = True
86
+ verbose: bool = False
87
+
88
+ @classmethod
89
+ def load(cls, project_dir: Optional[Path] = None) -> "HanusConfig":
90
+ cfg = cls()
91
+ cfg.root_dir = project_dir or Path.cwd()
92
+ cfg.plugins_dir = cfg.root_dir / "plugins"
93
+
94
+ # Load order: package default -> global -> local -> env
95
+ cfg._apply(_yaml(PACKAGE_CONFIG)) # Package defaults (hanus.yaml)
96
+ cfg._apply(_yaml(GLOBAL_CONFIG)) # User global config
97
+ cfg._apply(_yaml(cfg.root_dir / ".hanus.yaml")) # Project local config
98
+ cfg._apply_env() # Environment variables (highest priority)
99
+ return cfg
100
+
101
+ def _apply(self, data: Dict):
102
+ for k, v in data.items():
103
+ k = k.lower().replace("-", "_")
104
+ if not hasattr(self, k):
105
+ continue
106
+ cur = getattr(self, k)
107
+ if isinstance(cur, Path):
108
+ setattr(self, k, Path(str(v)).expanduser())
109
+ elif isinstance(cur, bool):
110
+ setattr(self, k, str(v).lower() in ("true", "1", "yes"))
111
+ elif isinstance(cur, int):
112
+ setattr(self, k, int(v))
113
+ elif isinstance(cur, float):
114
+ setattr(self, k, float(v))
115
+ else:
116
+ setattr(self, k, str(v))
117
+
118
+ def _apply_env(self):
119
+ ENV = {
120
+ "ANTHROPIC_API_KEY": "anthropic_api_key",
121
+ "OPENAI_API_KEY": "openai_api_key",
122
+ "GEMINI_API_KEY": "gemini_api_key",
123
+ "OLLAMA_URL": "ollama_url",
124
+ "HANUS_PROVIDER": "provider",
125
+ "HANUS_MODEL": "model_id",
126
+ "HANUS_MODE": "permission_mode",
127
+ "HANUS_BUDGET": "budget_usd",
128
+ "HANUS_VERBOSE": "verbose",
129
+ }
130
+ for env_key, attr in ENV.items():
131
+ v = os.environ.get(env_key)
132
+ if v:
133
+ self._apply({attr: v})
134
+
135
+ def get_connector_config(self) -> Dict[str, Any]:
136
+ base: Dict[str, Any] = {"model_id": self.model_id, "max_tokens": self.max_tokens}
137
+ p = self.provider.lower()
138
+ if p == "claude":
139
+ base["api_key"] = self.anthropic_api_key
140
+ elif p == "openai":
141
+ base["api_key"] = self.openai_api_key
142
+ elif p == "gemini":
143
+ base["api_key"] = self.gemini_api_key
144
+ elif p == "ollama":
145
+ base["ollama_url"] = self.ollama_url
146
+ elif p == "glm":
147
+ base["api_key"] = self.glm_api_key
148
+ return base
149
+
150
+ def load_system_prompt(self, plugin_docs: str = "") -> str:
151
+ base = _DEFAULT_PROMPT
152
+ if self.prompt_file.exists():
153
+ try:
154
+ base = self.prompt_file.read_text(encoding="utf-8")
155
+ except Exception:
156
+ pass
157
+ if plugin_docs:
158
+ base += f"\n\n## Available Plugins\n\n{plugin_docs}"
159
+ return base
160
+
161
+ def save_global(self):
162
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
163
+ GLOBAL_CONFIG.write_text(
164
+ f"provider: {self.provider}\n"
165
+ f"model_id: {self.model_id}\n"
166
+ f"permission_mode: {self.permission_mode}\n"
167
+ f"budget_usd: {self.budget_usd}\n"
168
+ f"show_cost: {str(self.show_cost).lower()}\n",
169
+ encoding="utf-8",
170
+ )
171
+
172
+ def export_full(self, path: Optional[Path] = None) -> Path:
173
+ """
174
+ Export full configuration to a YAML file.
175
+
176
+ Args:
177
+ path: Target path (default: GLOBAL_CONFIG)
178
+
179
+ Returns:
180
+ Path to the exported config file
181
+ """
182
+ from datetime import datetime
183
+
184
+ target = path or GLOBAL_CONFIG
185
+ target.parent.mkdir(parents=True, exist_ok=True)
186
+
187
+ content = f"""# ═══════════════════════════════════════════════════════════════════════════════
188
+ # HANUSCODE CONFIGURATION
189
+ # Generated: {datetime.now().isoformat()}
190
+ # ═══════════════════════════════════════════════════════════════════════════════
191
+
192
+ # ─── AI MODEL ──────────────────────────────────────────────────────────────────
193
+ provider: {self.provider}
194
+ model_id: {self.model_id}
195
+ max_tokens: {self.max_tokens}
196
+ context_window: {self.context_window}
197
+
198
+ # ─── API KEYS ──────────────────────────────────────────────────────────────────
199
+ # Set via environment variables for security: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
200
+ # anthropic_api_key: ""
201
+ # openai_api_key: ""
202
+ # gemini_api_key: ""
203
+ # glm_api_key: ""
204
+ ollama_url: "{self.ollama_url}"
205
+
206
+ # ─── PERMISSIONS ───────────────────────────────────────────────────────────────
207
+ permission_mode: {self.permission_mode}
208
+ max_tool_calls: {self.max_tool_calls}
209
+
210
+ # ─── CONTEXT ───────────────────────────────────────────────────────────────────
211
+ context_max_files: {self.context_max_files}
212
+ context_include_content: {str(self.context_include_content).lower()}
213
+ context_preview_chars: {self.context_preview_chars}
214
+ max_file_read_chars: {self.max_file_read_chars}
215
+ context_compress_threshold: {self.context_compress_threshold}
216
+
217
+ # ─── EXECUTION ─────────────────────────────────────────────────────────────────
218
+ shell_timeout: {self.shell_timeout}
219
+ python_exec_timeout: {self.python_exec_timeout}
220
+
221
+ # ─── BUDGET ────────────────────────────────────────────────────────────────────
222
+ budget_usd: {self.budget_usd}
223
+ budget_warn_pct: {self.budget_warn_pct}
224
+
225
+ # ─── SESSIONS ──────────────────────────────────────────────────────────────────
226
+ auto_save_session: {str(self.auto_save_session).lower()}
227
+
228
+ # ─── UI ────────────────────────────────────────────────────────────────────────
229
+ show_cost: {str(self.show_cost).lower()}
230
+ verbose: {str(self.verbose).lower()}
231
+ """
232
+ target.write_text(content, encoding="utf-8")
233
+ return target
234
+
235
+ @staticmethod
236
+ def generate_default_config() -> str:
237
+ """Generate default configuration file content."""
238
+ return (Path(__file__).parent.parent / "hanus.yaml").read_text(encoding="utf-8")
239
+
240
+
241
+ _DEFAULT_PROMPT = """You are Hanus Code, an autonomous senior software engineer. You complete tasks from start to finish without stopping.
242
+
243
+ ## CORE IDENTITY
244
+
245
+ You are a professional software engineer who:
246
+ - Writes clean, maintainable, production-ready code
247
+ - Follows best practices and established patterns
248
+ - Verifies work before completion
249
+ - Never stops mid-task
250
+
251
+ ## AVAILABLE TOOLS
252
+
253
+ ### File Operations
254
+ - <read_file path="file.py"/> — Read file contents
255
+ - <write_file path="file.py">content</write_file> — Create/overwrite file
256
+ - <create_file path="file.py">content</create_file> — Create new file
257
+ - <edit_file path="file.py" old="text" new="text"/> — Edit file by replacing text
258
+ - <append_to_file path="file.py">content</append_to_file> — Append to file
259
+ - <glob_search pattern="**/*.py"/> — Find files matching pattern
260
+ - <grep_search pattern="def func" dir="src" regex="true"/> — Search text in files
261
+ - <list_files dir="."/> — List directory contents
262
+
263
+ ### Execution
264
+ - <exec_cmd cmd="python script.py"/> — Run shell command (simple)
265
+ - <exec_cmd>multiline command with pipes | and quotes 'value'</exec_cmd> — Block format
266
+ - <exec_file path="script.py"/> — Execute Python file
267
+
268
+ ### Git Operations
269
+ - <git_status/> — Show working tree status
270
+ - <git_diff/> — Show uncommitted changes
271
+ - <git_log limit="10"/> — Show commit history
272
+ - <git_commit message="message"/> — Create commit
273
+ - <git_push branch="main"/> — Push to remote
274
+ - <git_reset mode="hard" target="HEAD~1"/> — Reset commits
275
+ - <git_branch name="feature"/> — Create/list branches
276
+
277
+ ### Web & Research
278
+ - <web_fetch url="https://..." prompt="extract info"/> — Fetch and parse web page
279
+ - <web_search query="python async best practices"/> — Search the web
280
+
281
+ ### Task Management
282
+ - <task_create subject="Task name" description="Details"/> — Create task
283
+ - <task_update taskId="1" status="in_progress"/> — Update task status
284
+ - <task_list/> — List all tasks
285
+ - <task_get taskId="1"/> — Get task details
286
+
287
+ ### Memory System (Persistent Context)
288
+ - <memory_save name="project-context">Important info to remember</memory_save> — Save memory
289
+ - <memory_search query="authentication"/> — Search memories
290
+ - <memory_list/> — List all memories
291
+
292
+ ### Planning Mode
293
+ - <plan_create name="plan-name" description="What to accomplish"/> — Create plan
294
+ - <plan_add_step planId="1" description="Step details"/> — Add step to plan
295
+ - <plan_update_step planId="1" stepId="1" status="completed"/> — Update step
296
+ - <plan_list/> — List plans
297
+ - <plan_get planId="1"/> — Get plan details
298
+ - <plan_approve planId="1"/> — Approve plan
299
+ - <plan_reject planId="1"/> — Reject plan
300
+
301
+ ### Subagents (Parallel Tasks)
302
+ - <subagent type="Explore" prompt="Find all API endpoints"/> — Spawn specialized agent
303
+ Types: Explore, general-purpose, Plan
304
+ - Use for parallel/async work
305
+
306
+ ### Notebook Editing
307
+ - <notebook_edit path="notebook.ipynb" cell_id="0" new_source="code"/> — Edit Jupyter cell
308
+
309
+ ### User Interaction
310
+ - <ask_user question="Which approach?" header="Choice" options='["A","B"]'/> — Ask user
311
+
312
+ ### Binary Analysis
313
+ - <binsmasher path="binary" mode="strings"/> — Analyze binary files
314
+
315
+ ## PLUGINS
316
+
317
+ Plugins extend your capabilities. Available plugins are listed in the system prompt.
318
+
319
+ Invoke plugins with: <run_plugin name="plugin_name" args="arguments"/>
320
+
321
+ Example plugins:
322
+ - <run_plugin name="notes" args="new \"My Note\" \"Content #tag\""/> — Create knowledge note
323
+ - <run_plugin name="git_ops" args="status"/> — Git operations
324
+ - <run_plugin name="search_code" args="pattern"/> — Advanced code search
325
+
326
+ Check the "Plugins" section in your system prompt for available plugins and their usage.
327
+
328
+ ## CRITICAL RULES
329
+
330
+ **1. NEVER WRITE CODE AS TEXT**
331
+ NEVER write code in ``` blocks or raw text. ALWAYS use write_file or edit_file.
332
+ WRONG: int main() { return 0; }
333
+ RIGHT: <write_file path="main.c">int main() { return 0; }</write_file>
334
+
335
+ **2. READ BEFORE MODIFY**
336
+ Use read_file and grep_search to understand code before changing it.
337
+ <read_file path="src/auth.py"/>
338
+ <grep_search pattern="function" dir="src"/>
339
+
340
+ **3. ONE FILE AT A TIME**
341
+ Start and COMPLETE one file before starting another.
342
+ NEVER interrupt a write_file to start another.
343
+ ALWAYS close </write_file> before opening another.
344
+
345
+ **4. VERIFY ALWAYS**
346
+ After writing code, run it to verify it works:
347
+ - C: <exec_cmd>gcc file.c -o file && ./file</exec_cmd>
348
+ - Python: <exec_cmd cmd="python file.py"/>
349
+ - Node: <exec_cmd cmd="node file.js"/>
350
+ - Tests: <exec_cmd cmd="pytest tests/"/>
351
+
352
+ **5. NEVER STOP UNTIL COMPLETE**
353
+ DO NOT ask for permission. DO NOT wait for confirmation. KEEP WORKING until done.
354
+ Use <done/> ONLY when ALL tasks are complete and verified.
355
+
356
+ **6. COMPLETE FILES**
357
+ In write_file, write the COMPLETE file. NEVER use "# ... rest unchanged" or similar.
358
+
359
+ **7. FIX ERRORS COMPLETELY**
360
+ When something fails:
361
+ 1. Read the error message carefully
362
+ 2. Identify the root cause
363
+ 3. Fix the issue
364
+ 4. Verify the fix works
365
+ 5. Continue with remaining tasks
366
+
367
+ **8. USE TASK MANAGEMENT**
368
+ For complex tasks, create and track tasks:
369
+ <task_create subject="Implement X" description="Details"/>
370
+ <task_update taskId="1" status="in_progress"/>
371
+ ... work ...
372
+ <task_update taskId="1" status="completed"/>
373
+
374
+ ## EXEC_CMD FORMATS
375
+
376
+ Simple command:
377
+ <exec_cmd cmd="python script.py"/>
378
+
379
+ Block format (for complex commands):
380
+ <exec_cmd>
381
+ find . -name "*.py" -exec grep -l "pattern" {} \\;
382
+ </exec_cmd>
383
+
384
+ ## COMPLETION SIGNAL
385
+
386
+ When ALL work is done:
387
+ 1. Write a summary of what was done
388
+ 2. List modified/created files
389
+ 3. Show verification results
390
+ 4. End with: <done/>
391
+
392
+ Example:
393
+ ## Summary
394
+ - Created src/api.py with REST endpoints
395
+ - Modified config.py to add new settings
396
+ - All tests passed
397
+
398
+ <done/>
399
+
400
+ DO NOT use <done/> until everything works.
401
+ """
@@ -0,0 +1,19 @@
1
+ # connectors/__init__.py
2
+ """Auto-registra todos los conectores disponibles al importar el paquete."""
3
+ from .base import BaseConnector, ModelResponse, ToolCall
4
+ from .registry import ConnectorRegistry
5
+
6
+ def _try(mod):
7
+ try:
8
+ import importlib
9
+ importlib.import_module(f".{mod}", package=__name__)
10
+ except Exception:
11
+ pass
12
+
13
+ _try("claude_connector")
14
+ _try("openai_connector")
15
+ _try("gemini_connector")
16
+ _try("ollama_connector")
17
+ _try("glm_connector")
18
+
19
+ __all__ = ["BaseConnector", "ModelResponse", "ToolCall", "ConnectorRegistry"]
@@ -0,0 +1,114 @@
1
+ # connectors/base.py
2
+ """
3
+ Interfaz unificada para todos los conectores.
4
+
5
+ IMPORTANTE — dos modos de operación:
6
+ • NATIVE_TOOLS = True → el modelo soporta tool_calls nativos (Claude, OpenAI).
7
+ El engine le pasa TOOL_DEFINITIONS y parsea ToolCall objects.
8
+ • NATIVE_TOOLS = False → el modelo solo soporta chat de texto (Gemini, Ollama).
9
+ El engine le inyecta las herramientas como instrucciones XML
10
+ en el system prompt y parsea la respuesta de texto buscando bloques XML.
11
+ """
12
+ from __future__ import annotations
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field
15
+ from typing import List, Dict, Optional, Any, Callable
16
+
17
+
18
+ @dataclass
19
+ class ToolCall:
20
+ """Solicitud de herramienta emitida por el modelo."""
21
+ id: str
22
+ name: str
23
+ arguments: Dict[str, Any]
24
+ raw: Optional[Any] = None
25
+
26
+
27
+ @dataclass
28
+ class ModelResponse:
29
+ """Respuesta unificada de cualquier proveedor."""
30
+ text: str = ""
31
+ tool_calls: List[ToolCall] = field(default_factory=list)
32
+ stop_reason: str = "end_turn" # end_turn | tool_use | max_tokens | error
33
+ input_tokens: int = 0
34
+ output_tokens: int = 0
35
+ model_id: str = ""
36
+ cost_usd: float = 0.0
37
+
38
+ @property
39
+ def total_tokens(self) -> int:
40
+ return self.input_tokens + self.output_tokens
41
+
42
+ def calc_cost(self, price_in_per_1m: float, price_out_per_1m: float):
43
+ self.cost_usd = (
44
+ self.input_tokens * price_in_per_1m / 1_000_000 +
45
+ self.output_tokens * price_out_per_1m / 1_000_000
46
+ )
47
+
48
+
49
+ class BaseConnector(ABC):
50
+ """Contrato base para todos los conectores de proveedores de IA."""
51
+
52
+ # Sobrescribir en conectores sin soporte nativo de herramientas
53
+ NATIVE_TOOLS: bool = False
54
+
55
+ def __init__(self, config: Dict[str, Any]):
56
+ self.config = config
57
+ self.model_id = config.get("model_id", "")
58
+ self._session_cost: float = 0.0
59
+ self._session_tokens: int = 0
60
+
61
+ @abstractmethod
62
+ def validate(self) -> bool:
63
+ """Verifica que las credenciales y el servicio estén disponibles."""
64
+ ...
65
+
66
+ @abstractmethod
67
+ def chat(
68
+ self,
69
+ messages: List[Dict],
70
+ stream_callback: Optional[Callable[[str], None]] = None,
71
+ ) -> ModelResponse:
72
+ """
73
+ Envía historial y retorna respuesta.
74
+ Los conectores con NATIVE_TOOLS=True reciben tools por separado
75
+ vía chat_with_tools(). Este método siempre debe funcionar sin tools.
76
+ """
77
+ ...
78
+
79
+ def chat_with_tools(
80
+ self,
81
+ messages: List[Dict],
82
+ tools: List[Dict],
83
+ stream_callback: Optional[Callable[[str], None]] = None,
84
+ ) -> ModelResponse:
85
+ """
86
+ Solo para conectores con NATIVE_TOOLS=True.
87
+ Los demás no deben sobrescribir esto — el engine usará XML fallback.
88
+ """
89
+ raise NotImplementedError("Este conector no soporta tool_calls nativos.")
90
+
91
+ @abstractmethod
92
+ def count_tokens(self, text: str) -> int:
93
+ """Estima número de tokens de un texto."""
94
+ ...
95
+
96
+ @property
97
+ @abstractmethod
98
+ def provider_name(self) -> str: ...
99
+
100
+ @property
101
+ @abstractmethod
102
+ def available_models(self) -> List[str]: ...
103
+
104
+ # ── Métricas de sesión ────────────────────────────────────────────────────
105
+ def record(self, response: ModelResponse):
106
+ self._session_cost += response.cost_usd
107
+ self._session_tokens += response.total_tokens
108
+
109
+ @property
110
+ def session_cost(self) -> float: return self._session_cost
111
+ @property
112
+ def session_tokens(self) -> int: return self._session_tokens
113
+ def reset_session(self):
114
+ self._session_cost = 0.0; self._session_tokens = 0
@@ -0,0 +1,146 @@
1
+ # connectors/claude_connector.py
2
+ """
3
+ Conector Anthropic Claude. NATIVE_TOOLS = True.
4
+ Soporta streaming real, tool_use nativo y prompt cache.
5
+ """
6
+ from __future__ import annotations
7
+ from typing import List, Dict, Optional, Any, Callable
8
+
9
+ from .base import BaseConnector, ModelResponse, ToolCall
10
+ from .registry import ConnectorRegistry
11
+
12
+ PRICING = {
13
+ "claude-opus-4-5": {"in": 15.0, "out": 75.0},
14
+ "claude-sonnet-4-5": {"in": 3.0, "out": 15.0},
15
+ "claude-haiku-4-5": {"in": 0.25, "out": 1.25},
16
+ "claude-3-5-sonnet-20241022": {"in": 3.0, "out": 15.0},
17
+ "claude-3-haiku-20240307": {"in": 0.25, "out": 1.25},
18
+ "claude-3-opus-20240229": {"in": 15.0, "out": 75.0},
19
+ }
20
+ MODELS = list(PRICING.keys())
21
+
22
+
23
+ @ConnectorRegistry.register("claude")
24
+ class ClaudeConnector(BaseConnector):
25
+
26
+ NATIVE_TOOLS = True
27
+
28
+ def __init__(self, config: Dict[str, Any]):
29
+ super().__init__(config)
30
+ self.api_key = config.get("api_key", "")
31
+ self.model_id = config.get("model_id", "claude-sonnet-4-5")
32
+ self.max_tokens = config.get("max_tokens", 8096)
33
+ self._client = None
34
+
35
+ def _get_client(self):
36
+ if self._client is None:
37
+ try:
38
+ import anthropic
39
+ self._client = anthropic.Anthropic(api_key=self.api_key)
40
+ except ImportError:
41
+ raise RuntimeError("Instala: pip install anthropic")
42
+ return self._client
43
+
44
+ def validate(self) -> bool:
45
+ try:
46
+ c = self._get_client()
47
+ c.messages.create(
48
+ model=self.model_id, max_tokens=10,
49
+ messages=[{"role": "user", "content": "hi"}]
50
+ )
51
+ return True
52
+ except Exception:
53
+ return False
54
+
55
+ def chat(
56
+ self,
57
+ messages: List[Dict],
58
+ stream_callback: Optional[Callable[[str], None]] = None,
59
+ ) -> ModelResponse:
60
+ """Chat sin tools (usado para mensajes simples)."""
61
+ return self._do_chat(messages, tools=None, stream_callback=stream_callback)
62
+
63
+ def chat_with_tools(
64
+ self,
65
+ messages: List[Dict],
66
+ tools: List[Dict],
67
+ stream_callback: Optional[Callable[[str], None]] = None,
68
+ ) -> ModelResponse:
69
+ """Chat con tool_calls nativos."""
70
+ return self._do_chat(messages, tools=tools, stream_callback=stream_callback)
71
+
72
+ def _do_chat(
73
+ self,
74
+ messages: List[Dict],
75
+ tools: Optional[List[Dict]],
76
+ stream_callback: Optional[Callable[[str], None]],
77
+ ) -> ModelResponse:
78
+ client = self._get_client()
79
+ system = ""
80
+ history = []
81
+ for m in messages:
82
+ if m["role"] == "system":
83
+ system = m["content"]
84
+ else:
85
+ history.append({"role": m["role"], "content": str(m["content"])})
86
+
87
+ kwargs: Dict[str, Any] = {
88
+ "model": self.model_id,
89
+ "max_tokens": self.max_tokens,
90
+ "messages": history,
91
+ }
92
+ if system:
93
+ kwargs["system"] = system
94
+ if tools:
95
+ kwargs["tools"] = tools
96
+
97
+ resp = ModelResponse(model_id=self.model_id)
98
+ try:
99
+ if stream_callback:
100
+ with client.messages.stream(**kwargs) as stream:
101
+ for token in stream.text_stream:
102
+ resp.text += token
103
+ stream_callback(token)
104
+ final = stream.get_final_message()
105
+ resp.stop_reason = final.stop_reason or "end_turn"
106
+ resp.input_tokens = final.usage.input_tokens
107
+ resp.output_tokens = final.usage.output_tokens
108
+ self._extract_tools(final, resp)
109
+ else:
110
+ msg = client.messages.create(**kwargs)
111
+ resp.stop_reason = msg.stop_reason or "end_turn"
112
+ resp.input_tokens = msg.usage.input_tokens
113
+ resp.output_tokens = msg.usage.output_tokens
114
+ for block in msg.content:
115
+ if hasattr(block, "text"):
116
+ resp.text += block.text
117
+ self._extract_tools(msg, resp)
118
+ except Exception as e:
119
+ resp.text = f"[Error Claude] {type(e).__name__}: {e}"
120
+ resp.stop_reason = "error"
121
+
122
+ p = PRICING.get(self.model_id, {"in": 3.0, "out": 15.0})
123
+ resp.calc_cost(p["in"], p["out"])
124
+ self.record(resp)
125
+ return resp
126
+
127
+ def _extract_tools(self, msg: Any, resp: ModelResponse):
128
+ import uuid
129
+ for block in msg.content:
130
+ if block.type == "tool_use":
131
+ resp.tool_calls.append(ToolCall(
132
+ id=block.id,
133
+ name=block.name,
134
+ arguments=block.input if isinstance(block.input, dict) else {},
135
+ raw=block,
136
+ ))
137
+ if resp.tool_calls:
138
+ resp.stop_reason = "tool_use"
139
+
140
+ def count_tokens(self, text: str) -> int:
141
+ return len(text) // 4
142
+
143
+ @property
144
+ def provider_name(self) -> str: return "Anthropic Claude"
145
+ @property
146
+ def available_models(self) -> List[str]: return MODELS