hanzo-mcp 0.6.12__py3-none-any.whl → 0.7.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.

Potentially problematic release.


This version of hanzo-mcp might be problematic. Click here for more details.

Files changed (117) hide show
  1. hanzo_mcp/__init__.py +2 -2
  2. hanzo_mcp/analytics/__init__.py +5 -0
  3. hanzo_mcp/analytics/posthog_analytics.py +364 -0
  4. hanzo_mcp/cli.py +5 -5
  5. hanzo_mcp/cli_enhanced.py +7 -7
  6. hanzo_mcp/cli_plugin.py +91 -0
  7. hanzo_mcp/config/__init__.py +1 -1
  8. hanzo_mcp/config/settings.py +70 -7
  9. hanzo_mcp/config/tool_config.py +20 -6
  10. hanzo_mcp/dev_server.py +3 -3
  11. hanzo_mcp/prompts/project_system.py +1 -1
  12. hanzo_mcp/server.py +40 -3
  13. hanzo_mcp/server_enhanced.py +69 -0
  14. hanzo_mcp/tools/__init__.py +140 -31
  15. hanzo_mcp/tools/agent/__init__.py +85 -4
  16. hanzo_mcp/tools/agent/agent_tool.py +104 -6
  17. hanzo_mcp/tools/agent/agent_tool_v2.py +459 -0
  18. hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
  19. hanzo_mcp/tools/agent/clarification_tool.py +68 -0
  20. hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
  21. hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
  22. hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
  23. hanzo_mcp/tools/agent/code_auth.py +436 -0
  24. hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
  25. hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
  26. hanzo_mcp/tools/agent/critic_tool.py +376 -0
  27. hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
  28. hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
  29. hanzo_mcp/tools/agent/iching_tool.py +380 -0
  30. hanzo_mcp/tools/agent/network_tool.py +273 -0
  31. hanzo_mcp/tools/agent/prompt.py +62 -20
  32. hanzo_mcp/tools/agent/review_tool.py +433 -0
  33. hanzo_mcp/tools/agent/swarm_tool.py +535 -0
  34. hanzo_mcp/tools/agent/swarm_tool_v2.py +594 -0
  35. hanzo_mcp/tools/common/__init__.py +15 -1
  36. hanzo_mcp/tools/common/base.py +5 -4
  37. hanzo_mcp/tools/common/batch_tool.py +103 -11
  38. hanzo_mcp/tools/common/config_tool.py +2 -2
  39. hanzo_mcp/tools/common/context.py +2 -2
  40. hanzo_mcp/tools/common/context_fix.py +26 -0
  41. hanzo_mcp/tools/common/critic_tool.py +196 -0
  42. hanzo_mcp/tools/common/decorators.py +208 -0
  43. hanzo_mcp/tools/common/enhanced_base.py +106 -0
  44. hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
  45. hanzo_mcp/tools/common/forgiving_edit.py +243 -0
  46. hanzo_mcp/tools/common/mode.py +116 -0
  47. hanzo_mcp/tools/common/mode_loader.py +105 -0
  48. hanzo_mcp/tools/common/paginated_base.py +230 -0
  49. hanzo_mcp/tools/common/paginated_response.py +307 -0
  50. hanzo_mcp/tools/common/pagination.py +226 -0
  51. hanzo_mcp/tools/common/permissions.py +1 -1
  52. hanzo_mcp/tools/common/personality.py +936 -0
  53. hanzo_mcp/tools/common/plugin_loader.py +287 -0
  54. hanzo_mcp/tools/common/stats.py +4 -4
  55. hanzo_mcp/tools/common/tool_list.py +4 -1
  56. hanzo_mcp/tools/common/truncate.py +101 -0
  57. hanzo_mcp/tools/common/validation.py +1 -1
  58. hanzo_mcp/tools/config/__init__.py +3 -1
  59. hanzo_mcp/tools/config/config_tool.py +1 -1
  60. hanzo_mcp/tools/config/mode_tool.py +209 -0
  61. hanzo_mcp/tools/database/__init__.py +1 -1
  62. hanzo_mcp/tools/editor/__init__.py +1 -1
  63. hanzo_mcp/tools/filesystem/__init__.py +48 -14
  64. hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
  65. hanzo_mcp/tools/filesystem/batch_search.py +3 -3
  66. hanzo_mcp/tools/filesystem/diff.py +2 -2
  67. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
  68. hanzo_mcp/tools/filesystem/rules_tool.py +235 -0
  69. hanzo_mcp/tools/filesystem/{unified_search.py → search_tool.py} +12 -12
  70. hanzo_mcp/tools/filesystem/{symbols_unified.py → symbols_tool.py} +104 -5
  71. hanzo_mcp/tools/filesystem/watch.py +3 -2
  72. hanzo_mcp/tools/jupyter/__init__.py +2 -2
  73. hanzo_mcp/tools/jupyter/jupyter.py +1 -1
  74. hanzo_mcp/tools/llm/__init__.py +3 -3
  75. hanzo_mcp/tools/llm/llm_tool.py +648 -143
  76. hanzo_mcp/tools/lsp/__init__.py +5 -0
  77. hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
  78. hanzo_mcp/tools/mcp/__init__.py +2 -2
  79. hanzo_mcp/tools/mcp/{mcp_unified.py → mcp_tool.py} +3 -3
  80. hanzo_mcp/tools/memory/__init__.py +76 -0
  81. hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
  82. hanzo_mcp/tools/memory/memory_tools.py +456 -0
  83. hanzo_mcp/tools/search/__init__.py +6 -0
  84. hanzo_mcp/tools/search/find_tool.py +581 -0
  85. hanzo_mcp/tools/search/unified_search.py +953 -0
  86. hanzo_mcp/tools/shell/__init__.py +11 -6
  87. hanzo_mcp/tools/shell/auto_background.py +203 -0
  88. hanzo_mcp/tools/shell/base_process.py +57 -29
  89. hanzo_mcp/tools/shell/bash_session_executor.py +1 -1
  90. hanzo_mcp/tools/shell/{bash_unified.py → bash_tool.py} +18 -34
  91. hanzo_mcp/tools/shell/command_executor.py +2 -2
  92. hanzo_mcp/tools/shell/{npx_unified.py → npx_tool.py} +16 -33
  93. hanzo_mcp/tools/shell/open.py +2 -2
  94. hanzo_mcp/tools/shell/{process_unified.py → process_tool.py} +1 -1
  95. hanzo_mcp/tools/shell/run_command_windows.py +1 -1
  96. hanzo_mcp/tools/shell/streaming_command.py +594 -0
  97. hanzo_mcp/tools/shell/uvx.py +47 -2
  98. hanzo_mcp/tools/shell/uvx_background.py +47 -2
  99. hanzo_mcp/tools/shell/{uvx_unified.py → uvx_tool.py} +16 -33
  100. hanzo_mcp/tools/todo/__init__.py +14 -19
  101. hanzo_mcp/tools/todo/todo.py +22 -1
  102. hanzo_mcp/tools/vector/__init__.py +1 -1
  103. hanzo_mcp/tools/vector/infinity_store.py +2 -2
  104. hanzo_mcp/tools/vector/project_manager.py +1 -1
  105. hanzo_mcp/types.py +23 -0
  106. hanzo_mcp-0.7.0.dist-info/METADATA +516 -0
  107. hanzo_mcp-0.7.0.dist-info/RECORD +180 -0
  108. {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/entry_points.txt +1 -0
  109. hanzo_mcp/tools/common/palette.py +0 -344
  110. hanzo_mcp/tools/common/palette_loader.py +0 -108
  111. hanzo_mcp/tools/config/palette_tool.py +0 -179
  112. hanzo_mcp/tools/llm/llm_unified.py +0 -851
  113. hanzo_mcp-0.6.12.dist-info/METADATA +0 -339
  114. hanzo_mcp-0.6.12.dist-info/RECORD +0 -135
  115. hanzo_mcp-0.6.12.dist-info/licenses/LICENSE +0 -21
  116. {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/WHEEL +0 -0
  117. {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,10 @@
1
- """Universal LLM tool using LiteLLM."""
1
+ """Unified LLM tool with multiple actions including consensus mode."""
2
2
 
3
+ from typing import Annotated, TypedDict, Unpack, final, override, Optional, List, Dict, Any
4
+ import asyncio
3
5
  import os
4
6
  import json
5
- from typing import Annotated, Optional, TypedDict, Unpack, final, override, List, Dict, Any
6
- import asyncio
7
+ from pathlib import Path
7
8
 
8
9
  from mcp.server.fastmcp import Context as MCPContext
9
10
  from pydantic import Field
@@ -11,27 +12,44 @@ from pydantic import Field
11
12
  from hanzo_mcp.tools.common.base import BaseTool
12
13
  from hanzo_mcp.tools.common.context import create_tool_context
13
14
 
15
+ # Check if litellm is available
14
16
  try:
15
17
  import litellm
16
- from litellm import completion, acompletion
17
18
  LITELLM_AVAILABLE = True
18
19
  except ImportError:
19
20
  LITELLM_AVAILABLE = False
20
21
 
21
22
 
22
- Model = Annotated[
23
+ # Parameter types
24
+ Action = Annotated[
23
25
  str,
24
26
  Field(
25
- description="Model name in LiteLLM format (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini/gemini-pro')",
26
- min_length=1,
27
+ description="Action to perform: query, consensus, list, models, enable, disable, test",
28
+ default="query",
29
+ ),
30
+ ]
31
+
32
+ Model = Annotated[
33
+ Optional[str],
34
+ Field(
35
+ description="Model name (e.g., gpt-4, claude-3-opus-20240229)",
36
+ default=None,
37
+ ),
38
+ ]
39
+
40
+ Models = Annotated[
41
+ Optional[List[str]],
42
+ Field(
43
+ description="List of models for consensus mode",
44
+ default=None,
27
45
  ),
28
46
  ]
29
47
 
30
48
  Prompt = Annotated[
31
- str,
49
+ Optional[str],
32
50
  Field(
33
- description="The prompt or question to send to the model",
34
- min_length=1,
51
+ description="The prompt to send to the LLM",
52
+ default=None,
35
53
  ),
36
54
  ]
37
55
 
@@ -46,7 +64,7 @@ SystemPrompt = Annotated[
46
64
  Temperature = Annotated[
47
65
  float,
48
66
  Field(
49
- description="Temperature for response randomness (0.0-2.0)",
67
+ description="Temperature for response randomness (0-2)",
50
68
  default=0.7,
51
69
  ),
52
70
  ]
@@ -75,82 +93,140 @@ Stream = Annotated[
75
93
  ),
76
94
  ]
77
95
 
96
+ Provider = Annotated[
97
+ Optional[str],
98
+ Field(
99
+ description="Provider name for list/enable/disable actions",
100
+ default=None,
101
+ ),
102
+ ]
78
103
 
79
- class LLMToolParams(TypedDict, total=False):
80
- """Parameters for LLM tool."""
104
+ IncludeRaw = Annotated[
105
+ bool,
106
+ Field(
107
+ description="Include raw responses in consensus mode",
108
+ default=False,
109
+ ),
110
+ ]
81
111
 
82
- model: str
83
- prompt: str
112
+ JudgeModel = Annotated[
113
+ Optional[str],
114
+ Field(
115
+ description="Model to use as judge/aggregator in consensus",
116
+ default=None,
117
+ ),
118
+ ]
119
+
120
+ DevilsAdvocate = Annotated[
121
+ bool,
122
+ Field(
123
+ description="Enable devil's advocate mode (10th model critiques others)",
124
+ default=False,
125
+ ),
126
+ ]
127
+
128
+ ConsensusSize = Annotated[
129
+ Optional[int],
130
+ Field(
131
+ description="Number of models to use in consensus (default: 3)",
132
+ default=None,
133
+ ),
134
+ ]
135
+
136
+
137
+ class LLMParams(TypedDict, total=False):
138
+ """Parameters for LLM tool."""
139
+ action: str
140
+ model: Optional[str]
141
+ models: Optional[List[str]]
142
+ prompt: Optional[str]
84
143
  system_prompt: Optional[str]
85
144
  temperature: float
86
145
  max_tokens: Optional[int]
87
146
  json_mode: bool
88
147
  stream: bool
148
+ provider: Optional[str]
149
+ include_raw: bool
150
+ judge_model: Optional[str]
151
+ devils_advocate: bool
152
+ consensus_size: Optional[int]
89
153
 
90
154
 
91
155
  @final
92
156
  class LLMTool(BaseTool):
93
- """Universal LLM tool using LiteLLM."""
157
+ """Unified LLM tool with multiple actions."""
158
+
159
+ # Config file for settings
160
+ CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "llm_config.json"
94
161
 
95
- # Common environment variables for API keys
162
+ # Default consensus models in order of preference
163
+ DEFAULT_CONSENSUS_MODELS = [
164
+ "gpt-4o", # OpenAI's latest
165
+ "claude-3-opus-20240229", # Claude's most capable
166
+ "gemini/gemini-1.5-pro", # Google's best
167
+ "groq/llama3-70b-8192", # Fast Groq
168
+ "mistral/mistral-large-latest", # Mistral's best
169
+ "perplexity/llama-3.1-sonar-large-128k-chat", # Perplexity with search
170
+ ]
171
+
172
+ # API key environment variables
96
173
  API_KEY_ENV_VARS = {
97
174
  "openai": ["OPENAI_API_KEY"],
98
175
  "anthropic": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
99
- "google": ["GOOGLE_API_KEY", "GEMINI_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"],
176
+ "google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
100
177
  "groq": ["GROQ_API_KEY"],
178
+ "mistral": ["MISTRAL_API_KEY"],
179
+ "perplexity": ["PERPLEXITY_API_KEY", "PERPLEXITYAI_API_KEY"],
180
+ "together": ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
101
181
  "cohere": ["COHERE_API_KEY"],
102
182
  "replicate": ["REPLICATE_API_KEY"],
103
183
  "huggingface": ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
104
- "together": ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
105
- "mistral": ["MISTRAL_API_KEY"],
106
- "perplexity": ["PERPLEXITY_API_KEY"],
107
- "anyscale": ["ANYSCALE_API_KEY"],
108
- "deepinfra": ["DEEPINFRA_API_KEY"],
109
- "ai21": ["AI21_API_KEY"],
110
- "nvidia": ["NVIDIA_API_KEY"],
184
+ "bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
185
+ "vertex": ["GOOGLE_APPLICATION_CREDENTIALS"],
186
+ "azure": ["AZURE_API_KEY"],
111
187
  "voyage": ["VOYAGE_API_KEY"],
112
- "aws": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], # For Bedrock
113
- "azure": ["AZURE_API_KEY", "AZURE_OPENAI_API_KEY"],
188
+ "deepseek": ["DEEPSEEK_API_KEY"],
114
189
  }
115
190
 
116
- # Model prefixes for each provider
117
- PROVIDER_MODELS = {
118
- "openai": ["gpt-4", "gpt-3.5", "o1", "davinci", "curie", "babbage", "ada"],
119
- "anthropic": ["claude-3", "claude-2", "claude-instant"],
120
- "google": ["gemini", "palm", "bison", "gecko"],
121
- "groq": ["mixtral", "llama2", "llama3"],
122
- "cohere": ["command", "command-light"],
123
- "mistral": ["mistral-tiny", "mistral-small", "mistral-medium", "mistral-large"],
124
- "perplexity": ["pplx", "sonar"],
125
- "together": ["together"],
126
- "bedrock": ["bedrock/"],
127
- "azure": ["azure/"],
128
- }
129
-
130
191
  def __init__(self):
131
- """Initialize the LLM tool."""
192
+ """Initialize the unified LLM tool."""
132
193
  self.available_providers = self._detect_available_providers()
194
+ self.config = self._load_config()
133
195
 
134
- # Configure LiteLLM settings
135
- if LITELLM_AVAILABLE:
136
- # Enable verbose logging for debugging
137
- litellm.set_verbose = False
138
- # Set default timeout
139
- litellm.request_timeout = 120
140
-
141
196
  def _detect_available_providers(self) -> Dict[str, List[str]]:
142
- """Detect which LLM providers have API keys configured."""
197
+ """Detect which providers have API keys configured."""
143
198
  available = {}
144
199
 
145
200
  for provider, env_vars in self.API_KEY_ENV_VARS.items():
146
201
  for var in env_vars:
147
202
  if os.getenv(var):
148
- if provider not in available:
149
- available[provider] = []
150
- available[provider].append(var)
203
+ available[provider] = env_vars
151
204
  break
152
205
 
153
206
  return available
207
+
208
+ def _load_config(self) -> Dict[str, Any]:
209
+ """Load configuration from file."""
210
+ if self.CONFIG_FILE.exists():
211
+ try:
212
+ with open(self.CONFIG_FILE, 'r') as f:
213
+ return json.load(f)
214
+ except:
215
+ pass
216
+
217
+ # Default config
218
+ return {
219
+ "disabled_providers": [],
220
+ "consensus_models": None, # Use defaults if None
221
+ "default_judge_model": "gpt-4o",
222
+ "consensus_size": 3,
223
+ }
224
+
225
+ def _save_config(self):
226
+ """Save configuration to file."""
227
+ self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
228
+ with open(self.CONFIG_FILE, 'w') as f:
229
+ json.dump(self.config, f, indent=2)
154
230
 
155
231
  @property
156
232
  @override
@@ -162,149 +238,578 @@ class LLMTool(BaseTool):
162
238
  @override
163
239
  def description(self) -> str:
164
240
  """Get the tool description."""
165
- providers_list = ", ".join(sorted(self.available_providers.keys())) if self.available_providers else "None"
241
+ available = list(self.available_providers.keys())
166
242
 
167
- return f"""Query any LLM using LiteLLM's unified interface.
168
-
169
- Supports 100+ models from various providers through a single interface.
170
- Automatically uses API keys from environment variables.
243
+ return f"""Query LLMs. Default: single query. Actions: consensus, list, models, test.
171
244
 
172
- Detected providers: {providers_list}
245
+ Usage:
246
+ llm "What is the capital of France?"
247
+ llm "Explain this code" --model gpt-4o
248
+ llm --action consensus "Is this approach correct?" --devils-advocate
249
+ llm --action models --provider openai
173
250
 
174
- Common models:
175
- - OpenAI: gpt-4o, gpt-4, gpt-3.5-turbo, o1-preview, o1-mini
176
- - Anthropic: claude-3-opus-20240229, claude-3-sonnet-20240229, claude-3-haiku-20240307
177
- - Google: gemini/gemini-pro, gemini/gemini-1.5-pro, gemini/gemini-1.5-flash
178
- - Groq: groq/mixtral-8x7b-32768, groq/llama3-70b-8192
179
- - Mistral: mistral/mistral-large-latest, mistral/mistral-medium
180
- - Perplexity: perplexity/sonar-medium-online
181
- - Together: together/mixtral-8x22b
182
-
183
- Examples:
184
- - llm --model "gpt-4" --prompt "Explain quantum computing"
185
- - llm --model "claude-3-opus-20240229" --prompt "Write a haiku about coding"
186
- - llm --model "gemini/gemini-pro" --prompt "What is the meaning of life?" --temperature 0.9
187
- - llm --model "groq/mixtral-8x7b-32768" --prompt "Generate a JSON schema" --json-mode
188
-
189
- For provider-specific tools, use: openai, anthropic, gemini, groq, etc.
190
- For consensus across models, use: consensus
191
- """
251
+ Available: {', '.join(available) if available else 'None'}"""
192
252
 
193
253
  @override
194
254
  async def call(
195
255
  self,
196
256
  ctx: MCPContext,
197
- **params: Unpack[LLMToolParams],
257
+ **params: Unpack[LLMParams],
198
258
  ) -> str:
199
- """Query an LLM.
200
-
201
- Args:
202
- ctx: MCP context
203
- **params: Tool parameters
204
-
205
- Returns:
206
- LLM response
207
- """
208
- tool_ctx = create_tool_context(ctx)
209
- await tool_ctx.set_tool_info(self.name)
259
+ """Execute LLM action."""
260
+ # Create tool context only if we have a proper MCP context
261
+ tool_ctx = None
262
+ try:
263
+ if hasattr(ctx, 'client') and ctx.client and hasattr(ctx.client, 'server'):
264
+ tool_ctx = create_tool_context(ctx)
265
+ if tool_ctx:
266
+ await tool_ctx.set_tool_info(self.name)
267
+ except:
268
+ # Running in test mode without MCP context
269
+ pass
210
270
 
211
271
  if not LITELLM_AVAILABLE:
212
272
  return "Error: LiteLLM is not installed. Install it with: pip install litellm"
213
273
 
214
- # Extract parameters
274
+ # Extract action
275
+ action = params.get("action", "query")
276
+
277
+ # Route to appropriate handler
278
+ if action == "query":
279
+ return await self._handle_query(tool_ctx, params)
280
+ elif action == "consensus":
281
+ return await self._handle_consensus(tool_ctx, params)
282
+ elif action == "list":
283
+ return self._handle_list()
284
+ elif action == "models":
285
+ return self._handle_models(params.get("provider"))
286
+ elif action == "enable":
287
+ return self._handle_enable(params.get("provider"))
288
+ elif action == "disable":
289
+ return self._handle_disable(params.get("provider"))
290
+ elif action == "test":
291
+ return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
292
+ else:
293
+ return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
294
+
295
+ async def _handle_query(self, tool_ctx, params: Dict[str, Any]) -> str:
296
+ """Handle single model query."""
215
297
  model = params.get("model")
216
- if not model:
217
- return "Error: model is required"
218
-
219
298
  prompt = params.get("prompt")
299
+
220
300
  if not prompt:
221
- return "Error: prompt is required"
222
-
223
- system_prompt = params.get("system_prompt")
224
- temperature = params.get("temperature", 0.7)
225
- max_tokens = params.get("max_tokens")
226
- json_mode = params.get("json_mode", False)
227
- stream = params.get("stream", False)
228
-
301
+ return "Error: prompt is required for query action"
302
+
303
+ # Auto-select model if not specified
304
+ if not model:
305
+ if self.available_providers:
306
+ # Use first available model
307
+ if "openai" in self.available_providers:
308
+ model = "gpt-4o-mini"
309
+ elif "anthropic" in self.available_providers:
310
+ model = "claude-3-haiku-20240307"
311
+ elif "google" in self.available_providers:
312
+ model = "gemini/gemini-1.5-flash"
313
+ else:
314
+ # Use first provider's default
315
+ provider = list(self.available_providers.keys())[0]
316
+ model = f"{provider}/default"
317
+ else:
318
+ return "Error: No model specified and no API keys found"
319
+
229
320
  # Check if we have API key for this model
230
321
  provider = self._get_provider_for_model(model)
231
322
  if provider and provider not in self.available_providers:
232
323
  env_vars = self.API_KEY_ENV_VARS.get(provider, [])
233
324
  return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
234
-
325
+
235
326
  # Build messages
236
327
  messages = []
237
- if system_prompt:
238
- messages.append({"role": "system", "content": system_prompt})
328
+ if params.get("system_prompt"):
329
+ messages.append({"role": "system", "content": params["system_prompt"]})
239
330
  messages.append({"role": "user", "content": prompt})
240
-
331
+
241
332
  # Build kwargs
242
333
  kwargs = {
243
334
  "model": model,
244
335
  "messages": messages,
245
- "temperature": temperature,
336
+ "temperature": params.get("temperature", 0.7),
246
337
  }
247
338
 
248
- if max_tokens:
249
- kwargs["max_tokens"] = max_tokens
339
+ if params.get("max_tokens"):
340
+ kwargs["max_tokens"] = params["max_tokens"]
250
341
 
251
- if json_mode:
342
+ if params.get("json_mode"):
252
343
  kwargs["response_format"] = {"type": "json_object"}
253
-
254
- await tool_ctx.info(f"Querying {model}...")
255
-
344
+
345
+ if params.get("stream"):
346
+ kwargs["stream"] = True
347
+
256
348
  try:
257
- if stream:
258
- # Streaming response
349
+ if tool_ctx:
350
+ await tool_ctx.info(f"Querying {model}...")
351
+
352
+ if kwargs.get("stream"):
353
+ # Handle streaming response
259
354
  response_text = ""
260
- async for chunk in await acompletion(**kwargs, stream=True):
355
+ async for chunk in await litellm.acompletion(**kwargs):
261
356
  if chunk.choices[0].delta.content:
262
357
  response_text += chunk.choices[0].delta.content
263
- # Could emit progress here if needed
264
-
265
358
  return response_text
266
359
  else:
267
- # Non-streaming response
268
- response = await acompletion(**kwargs)
360
+ # Regular response
361
+ response = await litellm.acompletion(**kwargs)
269
362
  return response.choices[0].message.content
270
-
363
+
271
364
  except Exception as e:
272
365
  error_msg = str(e)
273
-
274
- # Provide helpful error messages
275
- if "api_key" in error_msg.lower():
276
- provider = self._get_provider_for_model(model)
277
- env_vars = self.API_KEY_ENV_VARS.get(provider, [])
278
- return f"Error: API key issue for {provider}. Make sure one of these is set: {', '.join(env_vars)}\n\nOriginal error: {error_msg}"
279
- elif "model" in error_msg.lower() and "not found" in error_msg.lower():
280
- return f"Error: Model '{model}' not found or not accessible. Check the model name and your API permissions.\n\nOriginal error: {error_msg}"
366
+ if "model_not_found" in error_msg or "does not exist" in error_msg:
367
+ return f"Error: Model '{model}' not found. Use 'llm --action models' to see available models."
281
368
  else:
282
369
  return f"Error calling LLM: {error_msg}"
283
370
 
371
+ async def _handle_consensus(self, tool_ctx, params: Dict[str, Any]) -> str:
372
+ """Handle consensus mode with multiple models."""
373
+ prompt = params.get("prompt")
374
+ if not prompt:
375
+ return "Error: prompt is required for consensus action"
376
+
377
+ # Determine models to use
378
+ models = params.get("models")
379
+ if not models:
380
+ # Use configured or default models
381
+ consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
382
+ models = self._get_consensus_models(consensus_size)
383
+
384
+ if not models:
385
+ return "Error: No models available for consensus. Set API keys for at least 2 providers."
386
+
387
+ if len(models) < 2:
388
+ return "Error: Consensus requires at least 2 models"
389
+
390
+ # Check for devil's advocate mode
391
+ devils_advocate = params.get("devils_advocate", False)
392
+ if devils_advocate and len(models) < 3:
393
+ return "Error: Devil's advocate mode requires at least 3 models"
394
+
395
+ if tool_ctx:
396
+ await tool_ctx.info(f"Running consensus with {len(models)} models...")
397
+
398
+ # Query models in parallel
399
+ system_prompt = params.get("system_prompt")
400
+ temperature = params.get("temperature", 0.7)
401
+ max_tokens = params.get("max_tokens")
402
+
403
+ # Split models if using devil's advocate
404
+ if devils_advocate:
405
+ consensus_models = models[:-1]
406
+ devil_model = models[-1]
407
+ else:
408
+ consensus_models = models
409
+ devil_model = None
410
+
411
+ # Query consensus models
412
+ responses = await self._query_models_parallel(
413
+ consensus_models, prompt, system_prompt, temperature, max_tokens, tool_ctx
414
+ )
415
+
416
+ # Get devil's advocate response if enabled
417
+ devil_response = None
418
+ if devil_model:
419
+ # Create devil's advocate prompt
420
+ responses_text = "\n\n".join([
421
+ f"Model {i+1}: {resp['response']}"
422
+ for i, resp in enumerate(responses) if resp['response']
423
+ ])
424
+
425
+ devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
426
+
427
+ Original Question: {prompt}
428
+
429
+ Responses from other models:
430
+ {responses_text}
431
+
432
+ Provide your critical analysis:"""
433
+
434
+ devil_result = await self._query_single_model(
435
+ devil_model, devil_prompt, system_prompt, temperature, max_tokens
436
+ )
437
+
438
+ if devil_result['success']:
439
+ devil_response = {
440
+ 'model': devil_model,
441
+ 'response': devil_result['response'],
442
+ 'time_ms': devil_result['time_ms']
443
+ }
444
+
445
+ # Aggregate responses
446
+ judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
447
+ include_raw = params.get("include_raw", False)
448
+
449
+ return await self._aggregate_consensus(
450
+ responses, prompt, judge_model, include_raw, devil_response, tool_ctx
451
+ )
452
+
453
+ def _handle_list(self) -> str:
454
+ """List available providers."""
455
+ output = ["=== LLM Providers ==="]
456
+
457
+ # Get all possible providers
458
+ all_providers = sorted(self.API_KEY_ENV_VARS.keys())
459
+ disabled = self.config.get("disabled_providers", [])
460
+
461
+ output.append(f"Total providers: {len(all_providers)}")
462
+ output.append(f"Available: {len(self.available_providers)}")
463
+ output.append(f"Disabled: {len(disabled)}\n")
464
+
465
+ for provider in all_providers:
466
+ status_parts = []
467
+
468
+ # Check if API key exists
469
+ if provider in self.available_providers:
470
+ status_parts.append("✅ API key found")
471
+ else:
472
+ status_parts.append("❌ No API key")
473
+
474
+ # Check if disabled
475
+ if provider in disabled:
476
+ status_parts.append("🚫 Disabled")
477
+
478
+ # Show environment variables
479
+ env_vars = self.API_KEY_ENV_VARS.get(provider, [])
480
+ status = " | ".join(status_parts)
481
+
482
+ output.append(f"{provider}: {status}")
483
+ output.append(f" Environment variables: {', '.join(env_vars)}")
484
+
485
+ output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
486
+
487
+ return "\n".join(output)
488
+
489
+ def _handle_models(self, provider: Optional[str] = None) -> str:
490
+ """List available models."""
491
+ try:
492
+ all_models = self._get_all_models()
493
+
494
+ if not all_models:
495
+ return "No models available or LiteLLM not properly initialized"
496
+
497
+ output = ["=== Available LLM Models ==="]
498
+
499
+ if provider:
500
+ # Show models for specific provider
501
+ provider_lower = provider.lower()
502
+ models = all_models.get(provider_lower, [])
503
+
504
+ if not models:
505
+ return f"No models found for provider '{provider}'"
506
+
507
+ output.append(f"\n{provider.upper()} ({len(models)} models):")
508
+ output.append("-" * 40)
509
+
510
+ # Show first 50 models
511
+ for model in models[:50]:
512
+ output.append(f" {model}")
513
+
514
+ if len(models) > 50:
515
+ output.append(f" ... and {len(models) - 50} more")
516
+ else:
517
+ # Show summary of all providers
518
+ total_models = sum(len(models) for models in all_models.values())
519
+ output.append(f"Total models available: {total_models}")
520
+ output.append("")
521
+
522
+ # Show providers with counts
523
+ for provider_name, models in sorted(all_models.items()):
524
+ if models:
525
+ available = "✅" if provider_name in self.available_providers else "❌"
526
+ output.append(f"{available} {provider_name}: {len(models)} models")
527
+
528
+ output.append("\nUse 'llm --action models --provider <name>' to see specific models")
529
+
530
+ return "\n".join(output)
531
+
532
+ except Exception as e:
533
+ return f"Error listing models: {str(e)}"
534
+
535
+ def _handle_enable(self, provider: Optional[str]) -> str:
536
+ """Enable a provider."""
537
+ if not provider:
538
+ return "Error: provider is required for enable action"
539
+
540
+ provider = provider.lower()
541
+ disabled = self.config.get("disabled_providers", [])
542
+
543
+ if provider in disabled:
544
+ disabled.remove(provider)
545
+ self.config["disabled_providers"] = disabled
546
+ self._save_config()
547
+ return f"Successfully enabled {provider}"
548
+ else:
549
+ return f"{provider} is already enabled"
550
+
551
+ def _handle_disable(self, provider: Optional[str]) -> str:
552
+ """Disable a provider."""
553
+ if not provider:
554
+ return "Error: provider is required for disable action"
555
+
556
+ provider = provider.lower()
557
+ disabled = self.config.get("disabled_providers", [])
558
+
559
+ if provider not in disabled:
560
+ disabled.append(provider)
561
+ self.config["disabled_providers"] = disabled
562
+ self._save_config()
563
+ return f"Successfully disabled {provider}"
564
+ else:
565
+ return f"{provider} is already disabled"
566
+
567
+ async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
568
+ """Test a model or provider."""
569
+ if not model and not provider:
570
+ return "Error: Either model or provider is required for test action"
571
+
572
+ # If provider specified, test its default model
573
+ if provider and not model:
574
+ provider = provider.lower()
575
+ if provider == "openai":
576
+ model = "gpt-3.5-turbo"
577
+ elif provider == "anthropic":
578
+ model = "claude-3-haiku-20240307"
579
+ elif provider == "google":
580
+ model = "gemini/gemini-1.5-flash"
581
+ elif provider == "groq":
582
+ model = "groq/llama3-8b-8192"
583
+ else:
584
+ model = f"{provider}/default"
585
+
586
+ # Test the model
587
+ test_prompt = "Say 'Hello from Hanzo AI!' in exactly 5 words."
588
+
589
+ try:
590
+ if tool_ctx:
591
+ await tool_ctx.info(f"Testing {model}...")
592
+
593
+ response = await litellm.acompletion(
594
+ model=model,
595
+ messages=[{"role": "user", "content": test_prompt}],
596
+ temperature=0,
597
+ max_tokens=20
598
+ )
599
+
600
+ result = response.choices[0].message.content
601
+ return f"✅ {model} is working!\nResponse: {result}"
602
+
603
+ except Exception as e:
604
+ return f"❌ {model} failed: {str(e)}"
605
+
606
+ def _get_consensus_models(self, size: int) -> List[str]:
607
+ """Get models for consensus based on availability."""
608
+ # Use configured models if set
609
+ configured = self.config.get("consensus_models")
610
+ if configured:
611
+ return configured[:size]
612
+
613
+ # Otherwise, build list from available providers
614
+ models = []
615
+ disabled = self.config.get("disabled_providers", [])
616
+
617
+ # Try default models first
618
+ for model in self.DEFAULT_CONSENSUS_MODELS:
619
+ if len(models) >= size:
620
+ break
621
+
622
+ provider = self._get_provider_for_model(model)
623
+ if provider and provider in self.available_providers and provider not in disabled:
624
+ models.append(model)
625
+
626
+ # If still need more, add from available providers
627
+ if len(models) < size:
628
+ for provider in self.available_providers:
629
+ if provider in disabled:
630
+ continue
631
+
632
+ if provider == "openai" and "gpt-4o" not in models:
633
+ models.append("gpt-4o")
634
+ elif provider == "anthropic" and "claude-3-opus-20240229" not in models:
635
+ models.append("claude-3-opus-20240229")
636
+ elif provider == "google" and "gemini/gemini-1.5-pro" not in models:
637
+ models.append("gemini/gemini-1.5-pro")
638
+
639
+ if len(models) >= size:
640
+ break
641
+
642
+ return models
643
+
644
+ async def _query_models_parallel(
645
+ self, models: List[str], prompt: str, system_prompt: Optional[str],
646
+ temperature: float, max_tokens: Optional[int], tool_ctx
647
+ ) -> List[Dict[str, Any]]:
648
+ """Query multiple models in parallel."""
649
+ async def query_with_info(model: str) -> Dict[str, Any]:
650
+ result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
651
+ return {
652
+ 'model': model,
653
+ 'response': result.get('response'),
654
+ 'success': result.get('success', False),
655
+ 'error': result.get('error'),
656
+ 'time_ms': result.get('time_ms', 0)
657
+ }
658
+
659
+ # Run all queries in parallel
660
+ tasks = [query_with_info(model) for model in models]
661
+ results = await asyncio.gather(*tasks)
662
+
663
+ # Report results
664
+ successful = sum(1 for r in results if r['success'])
665
+ if tool_ctx:
666
+ await tool_ctx.info(f"Completed {successful}/{len(models)} model queries")
667
+
668
+ return results
669
+
670
+ async def _query_single_model(
671
+ self, model: str, prompt: str, system_prompt: Optional[str],
672
+ temperature: float, max_tokens: Optional[int]
673
+ ) -> Dict[str, Any]:
674
+ """Query a single model and return result with metadata."""
675
+ import time
676
+ start_time = time.time()
677
+
678
+ try:
679
+ messages = []
680
+ if system_prompt:
681
+ messages.append({"role": "system", "content": system_prompt})
682
+ messages.append({"role": "user", "content": prompt})
683
+
684
+ kwargs = {
685
+ "model": model,
686
+ "messages": messages,
687
+ "temperature": temperature,
688
+ }
689
+ if max_tokens:
690
+ kwargs["max_tokens"] = max_tokens
691
+
692
+ response = await litellm.acompletion(**kwargs)
693
+
694
+ return {
695
+ 'success': True,
696
+ 'response': response.choices[0].message.content,
697
+ 'time_ms': int((time.time() - start_time) * 1000)
698
+ }
699
+
700
+ except Exception as e:
701
+ return {
702
+ 'success': False,
703
+ 'error': str(e),
704
+ 'time_ms': int((time.time() - start_time) * 1000)
705
+ }
706
+
707
+ async def _aggregate_consensus(
708
+ self, responses: List[Dict[str, Any]], original_prompt: str,
709
+ judge_model: str, include_raw: bool, devil_response: Optional[Dict[str, Any]],
710
+ tool_ctx
711
+ ) -> str:
712
+ """Aggregate consensus responses using a judge model."""
713
+ # Prepare response data
714
+ successful_responses = [r for r in responses if r['success']]
715
+
716
+ if not successful_responses:
717
+ return "Error: All models failed to respond"
718
+
719
+ # Format responses for aggregation
720
+ responses_text = "\n\n".join([
721
+ f"Model: {r['model']}\nResponse: {r['response']}"
722
+ for r in successful_responses
723
+ ])
724
+
725
+ if devil_response:
726
+ responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
727
+
728
+ # Create aggregation prompt
729
+ aggregation_prompt = f"""Analyze the following responses from multiple AI models to this question:
730
+
731
+ <original_question>
732
+ {original_prompt}
733
+ </original_question>
734
+
735
+ <model_responses>
736
+ {responses_text}
737
+ </model_responses>
738
+
739
+ Please provide:
740
+ 1. A synthesis of the key points where models agree
741
+ 2. Notable differences or disagreements between responses
742
+ 3. A balanced conclusion incorporating the best insights
743
+ {f"4. Evaluation of the devil's advocate critique" if devil_response else ""}
744
+
745
+ Be concise and highlight the most important findings."""
746
+
747
+ # Get aggregation
748
+ try:
749
+ if tool_ctx:
750
+ await tool_ctx.info(f"Aggregating responses with {judge_model}...")
751
+
752
+ judge_result = await self._query_single_model(
753
+ judge_model, aggregation_prompt, None, 0.3, None
754
+ )
755
+
756
+ if not judge_result['success']:
757
+ return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
758
+
759
+ # Format output
760
+ output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
761
+ output.append(judge_result['response'])
762
+
763
+ # Add model list
764
+ output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
765
+ if devil_response:
766
+ output.append(f"Devil's Advocate: {devil_response['model']}")
767
+
768
+ # Add timing info
769
+ avg_time = sum(r['time_ms'] for r in responses) / len(responses)
770
+ output.append(f"\nAverage response time: {avg_time:.0f}ms")
771
+
772
+ # Include raw responses if requested
773
+ if include_raw:
774
+ output.append("\n\n=== Raw Responses ===")
775
+ for r in successful_responses:
776
+ output.append(f"\n{r['model']}:")
777
+ output.append("-" * 40)
778
+ output.append(r['response'])
779
+
780
+ if devil_response:
781
+ output.append(f"\nDevil's Advocate ({devil_response['model']}):")
782
+ output.append("-" * 40)
783
+ output.append(devil_response['response'])
784
+
785
+ return "\n".join(output)
786
+
787
+ except Exception as e:
788
+ return f"Error during aggregation: {str(e)}"
789
+
284
790
  def _get_provider_for_model(self, model: str) -> Optional[str]:
285
791
  """Determine the provider for a given model."""
286
792
  model_lower = model.lower()
287
793
 
288
- # Check explicit provider prefix (e.g., "groq/mixtral")
794
+ # Check explicit provider prefix
289
795
  if "/" in model:
290
- provider = model.split("/")[0]
291
- return provider
796
+ return model.split("/")[0]
292
797
 
293
798
  # Check model prefixes
294
- for provider, prefixes in self.PROVIDER_MODELS.items():
295
- for prefix in prefixes:
296
- if model_lower.startswith(prefix.lower()):
297
- return provider
799
+ if model_lower.startswith("gpt"):
800
+ return "openai"
801
+ elif model_lower.startswith("claude"):
802
+ return "anthropic"
803
+ elif model_lower.startswith("gemini"):
804
+ return "google"
805
+ elif model_lower.startswith("command"):
806
+ return "cohere"
298
807
 
299
- # Default to OpenAI for unknown models
808
+ # Default to OpenAI
300
809
  return "openai"
301
810
 
302
- @classmethod
303
- def get_all_models(cls) -> Dict[str, List[str]]:
304
- """Get all available models from LiteLLM organized by provider."""
305
- if not LITELLM_AVAILABLE:
306
- return {}
307
-
811
+ def _get_all_models(self) -> Dict[str, List[str]]:
812
+ """Get all available models from LiteLLM."""
308
813
  try:
309
814
  import litellm
310
815
 
@@ -343,4 +848,4 @@ For consensus across models, use: consensus
343
848
 
344
849
  def register(self, mcp_server) -> None:
345
850
  """Register this tool with the MCP server."""
346
- pass
851
+ pass