kubrick-cli 0.1.2__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.
@@ -0,0 +1,190 @@
1
+ """OpenAI provider adapter."""
2
+
3
+ import http.client
4
+ import json
5
+ import ssl
6
+ from typing import Dict, Iterator, List
7
+
8
+ from .base import ProviderAdapter, ProviderMetadata
9
+
10
+
11
+ class OpenAIProvider(ProviderAdapter):
12
+ """Provider adapter for OpenAI API."""
13
+
14
+ METADATA = ProviderMetadata(
15
+ name="openai",
16
+ display_name="OpenAI",
17
+ description="OpenAI API (GPT-4, GPT-3.5, etc.)",
18
+ config_fields=[
19
+ {
20
+ "key": "openai_api_key",
21
+ "label": "OpenAI API key",
22
+ "type": "password",
23
+ "help_text": "Get your API key from: https://platform.openai.com/api-keys",
24
+ },
25
+ {
26
+ "key": "openai_model",
27
+ "label": "Model name",
28
+ "type": "text",
29
+ "default": "gpt-4",
30
+ },
31
+ ],
32
+ )
33
+
34
+ def __init__(self, openai_api_key: str, openai_model: str = "gpt-4"):
35
+ """
36
+ Initialize OpenAI provider.
37
+
38
+ Args:
39
+ openai_api_key: OpenAI API key
40
+ openai_model: Model name (default: gpt-4)
41
+ """
42
+ self.api_key = openai_api_key
43
+ self._model_name = openai_model
44
+ self.base_url = "api.openai.com"
45
+ self.timeout = 600
46
+
47
+ def generate_streaming(
48
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
49
+ ) -> Iterator[str]:
50
+ """
51
+ Generate streaming response from OpenAI.
52
+
53
+ Args:
54
+ messages: List of message dicts with 'role' and 'content'
55
+ stream_options: Optional streaming parameters
56
+
57
+ Yields:
58
+ Text chunks as they arrive
59
+ """
60
+ # Build request payload
61
+ payload = {
62
+ "model": self._model_name,
63
+ "messages": messages,
64
+ "stream": True,
65
+ }
66
+
67
+ # Add optional parameters
68
+ if stream_options:
69
+ if "temperature" in stream_options:
70
+ payload["temperature"] = stream_options["temperature"]
71
+ if "max_tokens" in stream_options:
72
+ payload["max_tokens"] = stream_options["max_tokens"]
73
+
74
+ headers = {
75
+ "Content-Type": "application/json",
76
+ "Authorization": f"Bearer {self.api_key}",
77
+ }
78
+
79
+ body = json.dumps(payload).encode("utf-8")
80
+
81
+ # Create HTTPS connection
82
+ context = ssl.create_default_context()
83
+ conn = http.client.HTTPSConnection(
84
+ self.base_url, 443, timeout=self.timeout, context=context
85
+ )
86
+
87
+ try:
88
+ conn.request("POST", "/v1/chat/completions", body=body, headers=headers)
89
+ response = conn.getresponse()
90
+
91
+ if response.status != 200:
92
+ error_body = response.read().decode("utf-8")
93
+ raise Exception(f"OpenAI API error {response.status}: {error_body}")
94
+
95
+ # Read streaming response
96
+ buffer = ""
97
+ while True:
98
+ chunk = response.read(1024)
99
+ if not chunk:
100
+ break
101
+
102
+ if isinstance(chunk, bytes):
103
+ chunk = chunk.decode("utf-8")
104
+
105
+ buffer += chunk
106
+
107
+ # Process complete lines
108
+ while "\n" in buffer:
109
+ line, buffer = buffer.split("\n", 1)
110
+ line = line.strip()
111
+
112
+ if not line:
113
+ continue
114
+
115
+ # OpenAI SSE format: "data: {json}"
116
+ if line.startswith("data: "):
117
+ line = line[6:]
118
+
119
+ # Check for end signal
120
+ if line == "[DONE]":
121
+ return
122
+
123
+ try:
124
+ data = json.loads(line)
125
+ # Extract delta content
126
+ if "choices" in data and len(data["choices"]) > 0:
127
+ delta = data["choices"][0].get("delta", {})
128
+ content = delta.get("content", "")
129
+ if content:
130
+ yield content
131
+ except json.JSONDecodeError:
132
+ # Skip malformed JSON
133
+ continue
134
+
135
+ finally:
136
+ conn.close()
137
+
138
+ def generate(
139
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
140
+ ) -> str:
141
+ """
142
+ Generate non-streaming response from OpenAI.
143
+
144
+ Args:
145
+ messages: List of message dicts with 'role' and 'content'
146
+ stream_options: Optional parameters
147
+
148
+ Returns:
149
+ Complete response text
150
+ """
151
+ chunks = []
152
+ for chunk in self.generate_streaming(messages, stream_options):
153
+ chunks.append(chunk)
154
+ return "".join(chunks)
155
+
156
+ def is_healthy(self) -> bool:
157
+ """
158
+ Check if OpenAI API is accessible.
159
+
160
+ Returns:
161
+ True if healthy, False otherwise
162
+ """
163
+ try:
164
+ context = ssl.create_default_context()
165
+ conn = http.client.HTTPSConnection(
166
+ self.base_url, 443, timeout=10, context=context
167
+ )
168
+ headers = {
169
+ "Authorization": f"Bearer {self.api_key}",
170
+ }
171
+ conn.request("GET", "/v1/models", headers=headers)
172
+ response = conn.getresponse()
173
+ conn.close()
174
+ return response.status == 200
175
+ except Exception:
176
+ return False
177
+
178
+ @property
179
+ def provider_name(self) -> str:
180
+ """Get provider name."""
181
+ return "openai"
182
+
183
+ @property
184
+ def model_name(self) -> str:
185
+ """Get model name."""
186
+ return self._model_name
187
+
188
+ def set_model(self, model_name: str):
189
+ """Set model name dynamically."""
190
+ self._model_name = model_name
@@ -0,0 +1,96 @@
1
+ """Triton provider adapter."""
2
+
3
+ from typing import Dict, Iterator, List
4
+
5
+ from ..triton_client import TritonLLMClient
6
+ from .base import ProviderAdapter, ProviderMetadata
7
+
8
+
9
+ class TritonProvider(ProviderAdapter):
10
+ """Provider adapter for Triton Inference Server."""
11
+
12
+ METADATA = ProviderMetadata(
13
+ name="triton",
14
+ display_name="Triton",
15
+ description="Self-hosted Triton Inference Server (default)",
16
+ config_fields=[
17
+ {
18
+ "key": "triton_url",
19
+ "label": "Triton server URL",
20
+ "type": "url",
21
+ "default": "localhost:8000",
22
+ },
23
+ {
24
+ "key": "triton_model",
25
+ "label": "Triton model name",
26
+ "type": "text",
27
+ "default": "llm_decoupled",
28
+ },
29
+ ],
30
+ )
31
+
32
+ def __init__(self, triton_url: str = "localhost:8000", triton_model: str = "llm_decoupled"):
33
+ """
34
+ Initialize Triton provider.
35
+
36
+ Args:
37
+ triton_url: Triton server URL (default: localhost:8000)
38
+ triton_model: Triton model name (default: llm_decoupled)
39
+ """
40
+ self.client = TritonLLMClient(url=triton_url, model_name=triton_model)
41
+ self._url = triton_url
42
+ self._model_name = triton_model
43
+
44
+ def generate_streaming(
45
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
46
+ ) -> Iterator[str]:
47
+ """
48
+ Generate streaming response from Triton.
49
+
50
+ Args:
51
+ messages: List of message dicts with 'role' and 'content'
52
+ stream_options: Optional streaming parameters
53
+
54
+ Yields:
55
+ Text chunks as they arrive
56
+ """
57
+ yield from self.client.generate_streaming(messages, stream_options)
58
+
59
+ def generate(
60
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
61
+ ) -> str:
62
+ """
63
+ Generate non-streaming response from Triton.
64
+
65
+ Args:
66
+ messages: List of message dicts with 'role' and 'content'
67
+ stream_options: Optional parameters
68
+
69
+ Returns:
70
+ Complete response text
71
+ """
72
+ return self.client.generate(messages, stream_options)
73
+
74
+ def is_healthy(self) -> bool:
75
+ """
76
+ Check if Triton server is healthy.
77
+
78
+ Returns:
79
+ True if healthy, False otherwise
80
+ """
81
+ return self.client.is_healthy()
82
+
83
+ @property
84
+ def provider_name(self) -> str:
85
+ """Get provider name."""
86
+ return "triton"
87
+
88
+ @property
89
+ def model_name(self) -> str:
90
+ """Get model name."""
91
+ return self._model_name
92
+
93
+ @property
94
+ def url(self) -> str:
95
+ """Get Triton server URL."""
96
+ return self._url
kubrick_cli/safety.py ADDED
@@ -0,0 +1,216 @@
1
+ """Safety manager for dangerous command detection and validation."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Dict, Optional, Tuple
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Confirm
10
+
11
+ console = Console()
12
+
13
+
14
+ @dataclass
15
+ class SafetyConfig:
16
+ """Safety configuration settings."""
17
+
18
+ max_iterations: int = 15
19
+ max_tools_per_turn: int = 5
20
+ total_timeout_seconds: int = 600
21
+ tool_timeout_seconds: int = 30
22
+ max_file_size_mb: int = 10
23
+ require_dangerous_command_confirmation: bool = True
24
+
25
+ @classmethod
26
+ def from_config(cls, config: Dict) -> "SafetyConfig":
27
+ """
28
+ Create SafetyConfig from configuration dict.
29
+
30
+ Args:
31
+ config: Configuration dictionary
32
+
33
+ Returns:
34
+ SafetyConfig instance
35
+ """
36
+ return cls(
37
+ max_iterations=config.get("max_iterations", 15),
38
+ max_tools_per_turn=config.get("max_tools_per_turn", 5),
39
+ total_timeout_seconds=config.get("total_timeout_seconds", 600),
40
+ tool_timeout_seconds=config.get("tool_timeout_seconds", 30),
41
+ max_file_size_mb=config.get("max_file_size_mb", 10),
42
+ require_dangerous_command_confirmation=config.get(
43
+ "require_dangerous_command_confirmation", True
44
+ ),
45
+ )
46
+
47
+
48
+ class SafetyManager:
49
+ """
50
+ Manages safety checks for tool execution.
51
+
52
+ Features:
53
+ - Dangerous command detection
54
+ - User confirmation prompts
55
+ - File size limits
56
+ - Timeout enforcement
57
+ """
58
+
59
+ # Dangerous bash command patterns
60
+ DANGEROUS_PATTERNS = [
61
+ (r"\brm\s+-rf\s+/", "Recursive delete from root"),
62
+ (r"\brm\s+-rf\s+~", "Recursive delete from home"),
63
+ (r"\brm\s+-rf\s+\*", "Recursive delete all files"),
64
+ (r"\brm\s+-rf", "Recursive force delete"),
65
+ (r"\bsudo\b", "Elevated privileges"),
66
+ (r"\bchmod\s+777", "Overly permissive permissions"),
67
+ (r"\bchmod\s+-R\s+777", "Recursive overly permissive permissions"),
68
+ (r">\s*/dev/", "Writing to device files"),
69
+ (r"\bgit\s+push\s+--force", "Force push to git"),
70
+ (r"\bgit\s+push\s+-f", "Force push to git (short form)"),
71
+ (r"\bmkfs\b", "Format filesystem"),
72
+ (r"\bdd\s+.*of=/dev", "Writing to block device"),
73
+ (r"\b:(\()\|:\1&", "Fork bomb"),
74
+ (r"\bcurl\b.*\|\s*bash", "Pipe curl to bash"),
75
+ (r"\bwget\b.*\|\s*bash", "Pipe wget to bash"),
76
+ (r"\beval\b.*\$\(", "Eval with command substitution"),
77
+ ]
78
+
79
+ def __init__(self, config: SafetyConfig):
80
+ """
81
+ Initialize safety manager.
82
+
83
+ Args:
84
+ config: SafetyConfig instance
85
+ """
86
+ self.config = config
87
+
88
+ def validate_bash_command(
89
+ self, command: str
90
+ ) -> Tuple[bool, Optional[str]]:
91
+ """
92
+ Validate a bash command for dangerous patterns.
93
+
94
+ Args:
95
+ command: The bash command to validate
96
+
97
+ Returns:
98
+ Tuple of (is_safe, warning_message)
99
+ - is_safe: True if safe, False if dangerous
100
+ - warning_message: Description of the danger (if any)
101
+ """
102
+ command_lower = command.lower()
103
+
104
+ # Check each dangerous pattern
105
+ for pattern, description in self.DANGEROUS_PATTERNS:
106
+ if re.search(pattern, command_lower, re.IGNORECASE):
107
+ return False, f"Dangerous command detected: {description}"
108
+
109
+ return True, None
110
+
111
+ def get_user_confirmation(
112
+ self, warning: str, command: str
113
+ ) -> bool:
114
+ """
115
+ Get user confirmation for a dangerous command.
116
+
117
+ Args:
118
+ warning: Warning message
119
+ command: The dangerous command
120
+
121
+ Returns:
122
+ True if user confirms, False otherwise
123
+ """
124
+ if not self.config.require_dangerous_command_confirmation:
125
+ # Auto-allow if confirmation disabled
126
+ return True
127
+
128
+ # Display warning
129
+ console.print(
130
+ Panel(
131
+ f"[bold red]⚠️ WARNING[/bold red]\n\n"
132
+ f"{warning}\n\n"
133
+ f"[bold]Command:[/bold]\n"
134
+ f"[yellow]{command}[/yellow]\n\n"
135
+ f"This command could be dangerous.",
136
+ border_style="red",
137
+ title="Safety Check",
138
+ )
139
+ )
140
+
141
+ # Ask for confirmation
142
+ confirmed = Confirm.ask(
143
+ "[bold red]Do you want to execute this command?[/bold red]",
144
+ default=False,
145
+ )
146
+
147
+ if confirmed:
148
+ console.print("[yellow]⚠️ Proceeding with caution...[/yellow]")
149
+ else:
150
+ console.print("[green]✓ Command cancelled[/green]")
151
+
152
+ return confirmed
153
+
154
+ def validate_file_size(self, file_path: str, size_bytes: int) -> bool:
155
+ """
156
+ Validate file size against limits.
157
+
158
+ Args:
159
+ file_path: Path to the file
160
+ size_bytes: File size in bytes
161
+
162
+ Returns:
163
+ True if within limits, False otherwise
164
+ """
165
+ max_bytes = self.config.max_file_size_mb * 1024 * 1024
166
+
167
+ if size_bytes > max_bytes:
168
+ console.print(
169
+ f"[yellow]⚠️ File {file_path} exceeds size limit "
170
+ f"({size_bytes / 1024 / 1024:.1f}MB > {self.config.max_file_size_mb}MB)[/yellow]"
171
+ )
172
+ return False
173
+
174
+ return True
175
+
176
+ def check_iteration_limit(
177
+ self, current: int, max_iterations: int
178
+ ) -> bool:
179
+ """
180
+ Check if iteration limit has been reached.
181
+
182
+ Args:
183
+ current: Current iteration number
184
+ max_iterations: Maximum allowed iterations
185
+
186
+ Returns:
187
+ True if within limit, False otherwise
188
+ """
189
+ if current >= max_iterations:
190
+ console.print(
191
+ f"[yellow]⚠️ Max iteration limit reached ({max_iterations})[/yellow]"
192
+ )
193
+ return False
194
+
195
+ return True
196
+
197
+ def check_tool_limit(
198
+ self, current: int, max_tools: int
199
+ ) -> bool:
200
+ """
201
+ Check if tool call limit has been reached.
202
+
203
+ Args:
204
+ current: Current number of tool calls
205
+ max_tools: Maximum allowed tools
206
+
207
+ Returns:
208
+ True if within limit, False otherwise
209
+ """
210
+ if current > max_tools:
211
+ console.print(
212
+ f"[yellow]⚠️ Too many tool calls ({current} > {max_tools})[/yellow]"
213
+ )
214
+ return False
215
+
216
+ return True
@@ -0,0 +1,197 @@
1
+ """Parallel tool execution scheduler for improved performance."""
2
+
3
+ import concurrent.futures
4
+ from typing import Dict, List, Tuple
5
+
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ # Read-only tools that can be executed in parallel
12
+ READ_ONLY_TOOLS = {
13
+ "read_file",
14
+ "list_files",
15
+ "search_files",
16
+ }
17
+
18
+ # Write tools that must be executed sequentially
19
+ WRITE_TOOLS = {
20
+ "write_file",
21
+ "edit_file",
22
+ "create_directory",
23
+ "run_bash", # Conservative: treat bash as write operation
24
+ }
25
+
26
+
27
+ class ToolScheduler:
28
+ """
29
+ Schedules and executes tools with intelligent parallelization.
30
+
31
+ Strategy: Conservative parallelization
32
+ - Read-only tools (read_file, list_files, search_files) run in parallel
33
+ - Write tools (write_file, edit_file, run_bash, etc.) run sequentially
34
+ - Graceful error handling per tool
35
+ """
36
+
37
+ def __init__(
38
+ self, tool_executor, max_workers: int = 3, enable_parallel: bool = True
39
+ ):
40
+ """
41
+ Initialize tool scheduler.
42
+
43
+ Args:
44
+ tool_executor: ToolExecutor instance
45
+ max_workers: Maximum parallel workers (default: 3)
46
+ enable_parallel: Whether to enable parallel execution
47
+ """
48
+ self.tool_executor = tool_executor
49
+ self.max_workers = max_workers
50
+ self.enable_parallel = enable_parallel
51
+
52
+ def execute_tools(
53
+ self, tool_calls: List[Tuple[str, Dict]]
54
+ ) -> List[Dict]:
55
+ """
56
+ Execute a list of tool calls with intelligent scheduling.
57
+
58
+ Args:
59
+ tool_calls: List of (tool_name, parameters) tuples
60
+
61
+ Returns:
62
+ List of result dictionaries in the same order as input
63
+ """
64
+ if not self.enable_parallel or len(tool_calls) <= 1:
65
+ # Execute sequentially if parallel disabled or only one tool
66
+ return self._execute_sequential(tool_calls)
67
+
68
+ # Separate read-only and write tools
69
+ read_only_calls = []
70
+ write_calls = []
71
+ call_order = [] # Track original order
72
+
73
+ for i, (tool_name, params) in enumerate(tool_calls):
74
+ if tool_name in READ_ONLY_TOOLS:
75
+ read_only_calls.append((i, tool_name, params))
76
+ call_order.append(("read", len(read_only_calls) - 1))
77
+ else:
78
+ write_calls.append((i, tool_name, params))
79
+ call_order.append(("write", len(write_calls) - 1))
80
+
81
+ # Execute read-only tools in parallel
82
+ read_results = {}
83
+ if read_only_calls:
84
+ console.print(
85
+ f"[dim]→ Executing {len(read_only_calls)} read-only tool(s) in parallel[/dim]"
86
+ )
87
+ read_results = self._execute_parallel(read_only_calls)
88
+
89
+ # Execute write tools sequentially
90
+ write_results = {}
91
+ if write_calls:
92
+ write_results = self._execute_sequential_indexed(write_calls)
93
+
94
+ # Reconstruct results in original order
95
+ results = []
96
+ for call_type, index in call_order:
97
+ if call_type == "read":
98
+ results.append(read_results[index])
99
+ else:
100
+ results.append(write_results[index])
101
+
102
+ return results
103
+
104
+ def _execute_parallel(
105
+ self, indexed_calls: List[Tuple[int, str, Dict]]
106
+ ) -> Dict[int, Dict]:
107
+ """
108
+ Execute tools in parallel using ThreadPoolExecutor.
109
+
110
+ Args:
111
+ indexed_calls: List of (index, tool_name, parameters) tuples
112
+
113
+ Returns:
114
+ Dict mapping index to result
115
+ """
116
+ results = {}
117
+
118
+ with concurrent.futures.ThreadPoolExecutor(
119
+ max_workers=self.max_workers
120
+ ) as executor:
121
+ # Submit all tasks
122
+ future_to_index = {}
123
+ for index, tool_name, params in indexed_calls:
124
+ future = executor.submit(
125
+ self._execute_single, tool_name, params
126
+ )
127
+ future_to_index[future] = index
128
+
129
+ # Collect results as they complete
130
+ for future in concurrent.futures.as_completed(future_to_index):
131
+ index = future_to_index[future]
132
+ try:
133
+ result = future.result()
134
+ results[index] = result
135
+ except Exception as e:
136
+ # Handle execution errors gracefully
137
+ results[index] = {
138
+ "success": False,
139
+ "error": f"Parallel execution error: {str(e)}",
140
+ }
141
+
142
+ return results
143
+
144
+ def _execute_sequential(
145
+ self, tool_calls: List[Tuple[str, Dict]]
146
+ ) -> List[Dict]:
147
+ """
148
+ Execute tools sequentially.
149
+
150
+ Args:
151
+ tool_calls: List of (tool_name, parameters) tuples
152
+
153
+ Returns:
154
+ List of result dictionaries
155
+ """
156
+ results = []
157
+ for tool_name, params in tool_calls:
158
+ result = self._execute_single(tool_name, params)
159
+ results.append(result)
160
+ return results
161
+
162
+ def _execute_sequential_indexed(
163
+ self, indexed_calls: List[Tuple[int, str, Dict]]
164
+ ) -> Dict[int, Dict]:
165
+ """
166
+ Execute tools sequentially with index mapping.
167
+
168
+ Args:
169
+ indexed_calls: List of (index, tool_name, parameters) tuples
170
+
171
+ Returns:
172
+ Dict mapping index to result
173
+ """
174
+ results = {}
175
+ for index, tool_name, params in indexed_calls:
176
+ result = self._execute_single(tool_name, params)
177
+ results[index] = result
178
+ return results
179
+
180
+ def _execute_single(self, tool_name: str, params: Dict) -> Dict:
181
+ """
182
+ Execute a single tool.
183
+
184
+ Args:
185
+ tool_name: Name of tool
186
+ params: Tool parameters
187
+
188
+ Returns:
189
+ Result dictionary
190
+ """
191
+ try:
192
+ return self.tool_executor.execute(tool_name, params)
193
+ except Exception as e:
194
+ return {
195
+ "success": False,
196
+ "error": f"Tool execution error: {str(e)}",
197
+ }