kubrick-cli 0.1.4__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.
- kubrick_cli/__init__.py +47 -0
- kubrick_cli/agent_loop.py +274 -0
- kubrick_cli/classifier.py +194 -0
- kubrick_cli/config.py +247 -0
- kubrick_cli/display.py +154 -0
- kubrick_cli/execution_strategy.py +195 -0
- kubrick_cli/main.py +806 -0
- kubrick_cli/planning.py +319 -0
- kubrick_cli/progress.py +162 -0
- kubrick_cli/providers/__init__.py +6 -0
- kubrick_cli/providers/anthropic_provider.py +209 -0
- kubrick_cli/providers/base.py +136 -0
- kubrick_cli/providers/factory.py +161 -0
- kubrick_cli/providers/openai_provider.py +181 -0
- kubrick_cli/providers/triton_provider.py +96 -0
- kubrick_cli/safety.py +204 -0
- kubrick_cli/scheduler.py +183 -0
- kubrick_cli/setup_wizard.py +161 -0
- kubrick_cli/tools.py +400 -0
- kubrick_cli/triton_client.py +177 -0
- kubrick_cli-0.1.4.dist-info/METADATA +137 -0
- kubrick_cli-0.1.4.dist-info/RECORD +26 -0
- kubrick_cli-0.1.4.dist-info/WHEEL +5 -0
- kubrick_cli-0.1.4.dist-info/entry_points.txt +2 -0
- kubrick_cli-0.1.4.dist-info/licenses/LICENSE +21 -0
- kubrick_cli-0.1.4.dist-info/top_level.txt +1 -0
kubrick_cli/tools.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Tool definitions and execution handlers."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
TOOL_DEFINITIONS = [
|
|
8
|
+
{
|
|
9
|
+
"name": "read_file",
|
|
10
|
+
"description": "Read the contents of a file from the filesystem",
|
|
11
|
+
"read_only": True,
|
|
12
|
+
"estimated_duration": 1,
|
|
13
|
+
"parameters": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"file_path": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "The absolute or relative path to the file to read",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
"required": ["file_path"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "write_file",
|
|
26
|
+
"description": "Write content to a file (creates or overwrites)",
|
|
27
|
+
"read_only": False,
|
|
28
|
+
"estimated_duration": 2,
|
|
29
|
+
"parameters": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"file_path": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "The absolute or relative path to the file to write",
|
|
35
|
+
},
|
|
36
|
+
"content": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "The content to write to the file",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"required": ["file_path", "content"],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "edit_file",
|
|
46
|
+
"description": "Edit a file by replacing a specific string with new content",
|
|
47
|
+
"read_only": False,
|
|
48
|
+
"estimated_duration": 2,
|
|
49
|
+
"parameters": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"file_path": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "The path to the file to edit",
|
|
55
|
+
},
|
|
56
|
+
"old_string": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "The exact string to find and replace",
|
|
59
|
+
},
|
|
60
|
+
"new_string": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "The new string to replace with",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"required": ["file_path", "old_string", "new_string"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "list_files",
|
|
70
|
+
"description": "List files matching a glob pattern (*.py, **/*.py, etc)",
|
|
71
|
+
"read_only": True,
|
|
72
|
+
"estimated_duration": 1,
|
|
73
|
+
"parameters": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"pattern": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"description": "Glob pattern to match files (e.g., '*.py', 'src/**/*.ts')",
|
|
79
|
+
},
|
|
80
|
+
"directory": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"description": "Directory to search in (defaults to current directory)",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"required": ["pattern"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "search_files",
|
|
90
|
+
"description": "Search for text content within files using grep-like functionality",
|
|
91
|
+
"read_only": True,
|
|
92
|
+
"estimated_duration": 2,
|
|
93
|
+
"parameters": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {
|
|
96
|
+
"pattern": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"description": "Text pattern or regex to search for",
|
|
99
|
+
},
|
|
100
|
+
"file_pattern": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"description": "File glob pattern to search within (e.g., '*.py')",
|
|
103
|
+
},
|
|
104
|
+
"directory": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"description": "Directory to search in (defaults to current directory)",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
"required": ["pattern"],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"name": "run_bash",
|
|
114
|
+
"description": "Execute a bash command and return its output",
|
|
115
|
+
"read_only": False,
|
|
116
|
+
"estimated_duration": 5,
|
|
117
|
+
"parameters": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"command": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"description": "The bash command to execute",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
"required": ["command"],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "create_directory",
|
|
130
|
+
"description": "Create a new directory",
|
|
131
|
+
"read_only": False,
|
|
132
|
+
"estimated_duration": 1,
|
|
133
|
+
"parameters": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"properties": {
|
|
136
|
+
"path": {
|
|
137
|
+
"type": "string",
|
|
138
|
+
"description": "The path of the directory to create",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
"required": ["path"],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ToolExecutor:
|
|
148
|
+
"""Handles execution of tools."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, working_dir: str = None, safety_manager=None):
|
|
151
|
+
"""
|
|
152
|
+
Initialize tool executor.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
working_dir: Working directory for file operations (defaults to current directory)
|
|
156
|
+
safety_manager: Optional SafetyManager for validation
|
|
157
|
+
"""
|
|
158
|
+
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
|
159
|
+
self.safety_manager = safety_manager
|
|
160
|
+
|
|
161
|
+
def _resolve_path(self, path: str) -> Path:
|
|
162
|
+
"""Resolve a path relative to working directory."""
|
|
163
|
+
p = Path(path)
|
|
164
|
+
if p.is_absolute():
|
|
165
|
+
return p
|
|
166
|
+
return (self.working_dir / p).resolve()
|
|
167
|
+
|
|
168
|
+
def execute(self, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
169
|
+
"""
|
|
170
|
+
Execute a tool with given parameters.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
tool_name: Name of the tool to execute
|
|
174
|
+
parameters: Tool parameters
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Dict with 'success', 'result', and optionally 'error' keys
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
if tool_name == "read_file":
|
|
181
|
+
return self._read_file(parameters)
|
|
182
|
+
elif tool_name == "write_file":
|
|
183
|
+
return self._write_file(parameters)
|
|
184
|
+
elif tool_name == "edit_file":
|
|
185
|
+
return self._edit_file(parameters)
|
|
186
|
+
elif tool_name == "list_files":
|
|
187
|
+
return self._list_files(parameters)
|
|
188
|
+
elif tool_name == "search_files":
|
|
189
|
+
return self._search_files(parameters)
|
|
190
|
+
elif tool_name == "run_bash":
|
|
191
|
+
return self._run_bash(parameters)
|
|
192
|
+
elif tool_name == "create_directory":
|
|
193
|
+
return self._create_directory(parameters)
|
|
194
|
+
else:
|
|
195
|
+
return {
|
|
196
|
+
"success": False,
|
|
197
|
+
"error": f"Unknown tool: {tool_name}",
|
|
198
|
+
}
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return {
|
|
201
|
+
"success": False,
|
|
202
|
+
"error": str(e),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def _read_file(self, params: Dict) -> Dict:
|
|
206
|
+
"""Read a file."""
|
|
207
|
+
file_path = self._resolve_path(params["file_path"])
|
|
208
|
+
|
|
209
|
+
if not file_path.exists():
|
|
210
|
+
return {"success": False, "error": f"File not found: {file_path}"}
|
|
211
|
+
|
|
212
|
+
content = file_path.read_text()
|
|
213
|
+
return {
|
|
214
|
+
"success": True,
|
|
215
|
+
"result": f"Content of {file_path}:\n\n{content}",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def _write_file(self, params: Dict) -> Dict:
|
|
219
|
+
"""Write to a file."""
|
|
220
|
+
file_path = self._resolve_path(params["file_path"])
|
|
221
|
+
content = params["content"]
|
|
222
|
+
|
|
223
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
file_path.write_text(content)
|
|
226
|
+
return {
|
|
227
|
+
"success": True,
|
|
228
|
+
"result": f"Successfully wrote {len(content)} characters to {file_path}",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def _edit_file(self, params: Dict) -> Dict:
|
|
232
|
+
"""Edit a file by replacing text."""
|
|
233
|
+
file_path = self._resolve_path(params["file_path"])
|
|
234
|
+
old_string = params["old_string"]
|
|
235
|
+
new_string = params["new_string"]
|
|
236
|
+
|
|
237
|
+
if not file_path.exists():
|
|
238
|
+
return {"success": False, "error": f"File not found: {file_path}"}
|
|
239
|
+
|
|
240
|
+
content = file_path.read_text()
|
|
241
|
+
|
|
242
|
+
if old_string not in content:
|
|
243
|
+
return {
|
|
244
|
+
"success": False,
|
|
245
|
+
"error": f"String not found in file: {old_string[:100]}...",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
249
|
+
file_path.write_text(new_content)
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"success": True,
|
|
253
|
+
"result": f"Successfully edited {file_path}",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
def _list_files(self, params: Dict) -> Dict:
|
|
257
|
+
"""List files matching a pattern."""
|
|
258
|
+
pattern = params["pattern"]
|
|
259
|
+
directory = params.get("directory", ".")
|
|
260
|
+
search_dir = self._resolve_path(directory)
|
|
261
|
+
|
|
262
|
+
files = []
|
|
263
|
+
directories = []
|
|
264
|
+
|
|
265
|
+
for match in search_dir.glob(pattern):
|
|
266
|
+
rel_path = match.relative_to(search_dir)
|
|
267
|
+
if match.is_file():
|
|
268
|
+
files.append(str(rel_path))
|
|
269
|
+
elif match.is_dir():
|
|
270
|
+
directories.append(str(rel_path) + "/")
|
|
271
|
+
|
|
272
|
+
files.sort()
|
|
273
|
+
directories.sort()
|
|
274
|
+
|
|
275
|
+
matches = directories + files
|
|
276
|
+
|
|
277
|
+
if not matches:
|
|
278
|
+
result = f"No files or directories found matching pattern: {pattern}"
|
|
279
|
+
else:
|
|
280
|
+
dir_count = len(directories)
|
|
281
|
+
file_count = len(files)
|
|
282
|
+
result = f"Found {dir_count} directories and {file_count} files:\n"
|
|
283
|
+
|
|
284
|
+
if directories:
|
|
285
|
+
result += "\nDirectories:\n" + "\n".join(directories)
|
|
286
|
+
if files:
|
|
287
|
+
result += "\n\nFiles:\n" + "\n".join(files)
|
|
288
|
+
|
|
289
|
+
return {"success": True, "result": result}
|
|
290
|
+
|
|
291
|
+
def _search_files(self, params: Dict) -> Dict:
|
|
292
|
+
"""Search for text in files."""
|
|
293
|
+
pattern = params["pattern"]
|
|
294
|
+
file_pattern = params.get("file_pattern", "**/*")
|
|
295
|
+
directory = params.get("directory", ".")
|
|
296
|
+
search_dir = self._resolve_path(directory)
|
|
297
|
+
|
|
298
|
+
results = []
|
|
299
|
+
|
|
300
|
+
for file_path in search_dir.glob(file_pattern):
|
|
301
|
+
if not file_path.is_file():
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
content = file_path.read_text()
|
|
306
|
+
lines = content.split("\n")
|
|
307
|
+
|
|
308
|
+
for line_num, line in enumerate(lines, 1):
|
|
309
|
+
if pattern in line:
|
|
310
|
+
rel_path = file_path.relative_to(search_dir)
|
|
311
|
+
results.append(f"{rel_path}:{line_num}: {line.strip()}")
|
|
312
|
+
except (UnicodeDecodeError, PermissionError):
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
if not results:
|
|
316
|
+
result = f"No matches found for pattern: {pattern}"
|
|
317
|
+
else:
|
|
318
|
+
result = f"Found {len(results)} matches:\n" + "\n".join(results[:50])
|
|
319
|
+
if len(results) > 50:
|
|
320
|
+
result += f"\n... and {len(results) - 50} more"
|
|
321
|
+
|
|
322
|
+
return {"success": True, "result": result}
|
|
323
|
+
|
|
324
|
+
def _run_bash(self, params: Dict) -> Dict:
|
|
325
|
+
"""Run a bash command."""
|
|
326
|
+
command = params["command"]
|
|
327
|
+
|
|
328
|
+
if self.safety_manager:
|
|
329
|
+
is_safe, warning = self.safety_manager.validate_bash_command(command)
|
|
330
|
+
|
|
331
|
+
if not is_safe:
|
|
332
|
+
confirmed = self.safety_manager.get_user_confirmation(warning, command)
|
|
333
|
+
|
|
334
|
+
if not confirmed:
|
|
335
|
+
return {
|
|
336
|
+
"success": False,
|
|
337
|
+
"error": "Command cancelled by user (dangerous command)",
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
timeout = 30
|
|
342
|
+
if self.safety_manager and hasattr(self.safety_manager, "config"):
|
|
343
|
+
timeout = self.safety_manager.config.tool_timeout_seconds
|
|
344
|
+
|
|
345
|
+
# nosec B602: shell=True is required for bash command execution
|
|
346
|
+
# Commands are validated by SafetyManager before execution
|
|
347
|
+
result = subprocess.run(
|
|
348
|
+
command,
|
|
349
|
+
shell=True, # nosec
|
|
350
|
+
capture_output=True,
|
|
351
|
+
text=True,
|
|
352
|
+
timeout=timeout,
|
|
353
|
+
cwd=self.working_dir,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
output = result.stdout
|
|
357
|
+
if result.stderr:
|
|
358
|
+
output += f"\nSTDERR:\n{result.stderr}"
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
"success": True,
|
|
362
|
+
"result": f"Command: {command}\nExit code: {result.returncode}\n\n{output}",
|
|
363
|
+
}
|
|
364
|
+
except subprocess.TimeoutExpired:
|
|
365
|
+
return {
|
|
366
|
+
"success": False,
|
|
367
|
+
"error": f"Command timed out after {timeout} seconds",
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
def _create_directory(self, params: Dict) -> Dict:
|
|
371
|
+
"""Create a directory."""
|
|
372
|
+
path = self._resolve_path(params["path"])
|
|
373
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
return {
|
|
375
|
+
"success": True,
|
|
376
|
+
"result": f"Created directory: {path}",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def get_tools_prompt() -> str:
|
|
381
|
+
"""Get a formatted string describing available tools for the LLM."""
|
|
382
|
+
tools_desc = ""
|
|
383
|
+
|
|
384
|
+
for tool in TOOL_DEFINITIONS:
|
|
385
|
+
tools_desc += f"### {tool['name']}\n"
|
|
386
|
+
tools_desc += f"{tool['description']}\n\n"
|
|
387
|
+
|
|
388
|
+
props = tool["parameters"].get("properties", {})
|
|
389
|
+
required = tool["parameters"].get("required", [])
|
|
390
|
+
|
|
391
|
+
if props:
|
|
392
|
+
tools_desc += "**Parameters:**\n"
|
|
393
|
+
for param_name, param_info in props.items():
|
|
394
|
+
req_marker = " (required)" if param_name in required else " (optional)"
|
|
395
|
+
param_desc = param_info.get("description", "No description")
|
|
396
|
+
tools_desc += f"- `{param_name}`: {param_desc}{req_marker}\n"
|
|
397
|
+
|
|
398
|
+
tools_desc += "\n"
|
|
399
|
+
|
|
400
|
+
return tools_desc
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Triton client for streaming LLM requests using HTTP only (no dependencies)."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import ssl
|
|
6
|
+
from typing import Dict, Iterator, List
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TritonLLMClient:
|
|
11
|
+
"""Client for interacting with Triton LLM backend using HTTP (no extra dependencies)."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
url: str = "localhost:8000",
|
|
16
|
+
model_name: str = "llm_decoupled",
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Initialize Triton LLM client.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
url: Triton server URL (host:port, default: localhost:8000)
|
|
23
|
+
model_name: Name of the Triton model to use
|
|
24
|
+
"""
|
|
25
|
+
self.model_name = model_name
|
|
26
|
+
|
|
27
|
+
if not url.startswith("http://") and not url.startswith("https://"):
|
|
28
|
+
url = f"http://{url}"
|
|
29
|
+
|
|
30
|
+
parsed = urlparse(url)
|
|
31
|
+
self.is_https = parsed.scheme == "https"
|
|
32
|
+
self.host = parsed.hostname or "localhost"
|
|
33
|
+
self.port = parsed.port or (443 if self.is_https else 8000)
|
|
34
|
+
self.timeout = 600
|
|
35
|
+
self.url = f"{parsed.scheme}://{self.host}:{self.port}"
|
|
36
|
+
|
|
37
|
+
def _get_connection(self) -> http.client.HTTPConnection:
|
|
38
|
+
"""Create an HTTP(S) connection."""
|
|
39
|
+
if self.is_https:
|
|
40
|
+
context = ssl.create_default_context()
|
|
41
|
+
return http.client.HTTPSConnection(
|
|
42
|
+
self.host, self.port, timeout=self.timeout, context=context
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
return http.client.HTTPConnection(
|
|
46
|
+
self.host, self.port, timeout=self.timeout
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def generate_streaming(
|
|
50
|
+
self,
|
|
51
|
+
messages: List[Dict[str, str]],
|
|
52
|
+
stream_options: Dict = None,
|
|
53
|
+
) -> Iterator[str]:
|
|
54
|
+
"""
|
|
55
|
+
Generate streaming response from LLM.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
messages: List of message dicts with 'role' and 'content'
|
|
59
|
+
stream_options: Optional streaming parameters
|
|
60
|
+
|
|
61
|
+
Yields:
|
|
62
|
+
Text chunks as they arrive
|
|
63
|
+
"""
|
|
64
|
+
if stream_options is None:
|
|
65
|
+
stream_options = {"streaming": True}
|
|
66
|
+
else:
|
|
67
|
+
stream_options = {"streaming": True, **stream_options}
|
|
68
|
+
|
|
69
|
+
payload = {
|
|
70
|
+
"text_input": json.dumps(messages),
|
|
71
|
+
"parameters": stream_options,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
headers = {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"Accept": "text/event-stream",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
body = json.dumps(payload).encode("utf-8")
|
|
80
|
+
path = f"/v2/models/{self.model_name}/generate_stream"
|
|
81
|
+
|
|
82
|
+
conn = None
|
|
83
|
+
try:
|
|
84
|
+
conn = self._get_connection()
|
|
85
|
+
conn.request("POST", path, body=body, headers=headers)
|
|
86
|
+
response = conn.getresponse()
|
|
87
|
+
|
|
88
|
+
if response.status not in (200, 201):
|
|
89
|
+
error_body = response.read().decode("utf-8")
|
|
90
|
+
raise Exception(f"Server returned {response.status}: {error_body}")
|
|
91
|
+
|
|
92
|
+
byte_buffer = b""
|
|
93
|
+
while True:
|
|
94
|
+
chunk = response.read(1024)
|
|
95
|
+
if not chunk:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
byte_buffer += chunk
|
|
99
|
+
|
|
100
|
+
while b"\n" in byte_buffer:
|
|
101
|
+
line_bytes, byte_buffer = byte_buffer.split(b"\n", 1)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
line = line_bytes.decode("utf-8").strip()
|
|
105
|
+
except UnicodeDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if not line:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if line.startswith("data: "):
|
|
112
|
+
line = line[6:]
|
|
113
|
+
|
|
114
|
+
if line == "[DONE]":
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(line)
|
|
119
|
+
|
|
120
|
+
output_data = None
|
|
121
|
+
if "text_output" in data:
|
|
122
|
+
output_data = data["text_output"]
|
|
123
|
+
elif "outputs" in data and len(data["outputs"]) > 0:
|
|
124
|
+
output_data = data["outputs"][0].get("data", [""])[0]
|
|
125
|
+
|
|
126
|
+
if output_data:
|
|
127
|
+
try:
|
|
128
|
+
chunk_data = json.loads(output_data)
|
|
129
|
+
|
|
130
|
+
if chunk_data.get("type") == "chunk":
|
|
131
|
+
yield chunk_data.get("content", "")
|
|
132
|
+
elif chunk_data.get("type") == "complete":
|
|
133
|
+
return
|
|
134
|
+
elif chunk_data.get("type") == "error":
|
|
135
|
+
raise Exception(
|
|
136
|
+
f"LLM error: {chunk_data.get('content')}"
|
|
137
|
+
)
|
|
138
|
+
except (json.JSONDecodeError, TypeError):
|
|
139
|
+
yield output_data
|
|
140
|
+
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
finally:
|
|
145
|
+
if conn:
|
|
146
|
+
conn.close()
|
|
147
|
+
|
|
148
|
+
def generate(
|
|
149
|
+
self,
|
|
150
|
+
messages: List[Dict[str, str]],
|
|
151
|
+
stream_options: Dict = None,
|
|
152
|
+
) -> str:
|
|
153
|
+
"""
|
|
154
|
+
Generate non-streaming response from LLM.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
messages: List of message dicts with 'role' and 'content'
|
|
158
|
+
stream_options: Optional parameters
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Complete response text
|
|
162
|
+
"""
|
|
163
|
+
chunks = []
|
|
164
|
+
for chunk in self.generate_streaming(messages, stream_options):
|
|
165
|
+
chunks.append(chunk)
|
|
166
|
+
return "".join(chunks)
|
|
167
|
+
|
|
168
|
+
def is_healthy(self) -> bool:
|
|
169
|
+
"""Check if Triton server is healthy."""
|
|
170
|
+
try:
|
|
171
|
+
conn = self._get_connection()
|
|
172
|
+
conn.request("GET", "/v2/health/live")
|
|
173
|
+
response = conn.getresponse()
|
|
174
|
+
conn.close()
|
|
175
|
+
return response.status == 200
|
|
176
|
+
except Exception:
|
|
177
|
+
return False
|