thwip-cli 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.
- thwip/__init__.py +4 -0
- thwip/__main__.py +6 -0
- thwip/agents/__init__.py +98 -0
- thwip/agents/base.py +343 -0
- thwip/agents/claude_agent.py +346 -0
- thwip/agents/deepseek_agent.py +217 -0
- thwip/agents/google_agent.py +314 -0
- thwip/agents/groq_agent.py +181 -0
- thwip/agents/ollama_agent.py +180 -0
- thwip/agents/openai_agent.py +338 -0
- thwip/agents/openrouter_agent.py +192 -0
- thwip/cli.py +593 -0
- thwip/config.py +363 -0
- thwip/detector.py +259 -0
- thwip/limits.py +84 -0
- thwip/session.py +175 -0
- thwip/shortcuts.py +75 -0
- thwip/theme.py +380 -0
- thwip/tools/__init__.py +184 -0
- thwip/tools/code_runner.py +62 -0
- thwip/tools/file_editor.py +92 -0
- thwip/tools/git_ops.py +44 -0
- thwip/tools/terminal.py +65 -0
- thwip/utils.py +110 -0
- thwip_cli-1.0.0.dist-info/METADATA +157 -0
- thwip_cli-1.0.0.dist-info/RECORD +28 -0
- thwip_cli-1.0.0.dist-info/WHEEL +4 -0
- thwip_cli-1.0.0.dist-info/entry_points.txt +2 -0
thwip/tools/__init__.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool registry and schema translator for LLMs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
from thwip.tools.file_editor import FileEditor
|
|
9
|
+
from thwip.tools.terminal import TerminalRunner
|
|
10
|
+
from thwip.tools.code_runner import CodeRunner
|
|
11
|
+
from thwip.tools.git_ops import GitOps
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolManager:
|
|
15
|
+
"""Manages tool execution and provides schemas for agent tool calling."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, project_path: str = ".") -> None:
|
|
18
|
+
self.project_path = project_path
|
|
19
|
+
self.file_editor = FileEditor(project_path)
|
|
20
|
+
self.terminal = TerminalRunner(project_path)
|
|
21
|
+
self.code_runner = CodeRunner(project_path)
|
|
22
|
+
self.git = GitOps(project_path)
|
|
23
|
+
|
|
24
|
+
def get_openai_tools(self) -> list[dict[str, Any]]:
|
|
25
|
+
"""Return tool definitions formatted for OpenAI / DeepSeek / Groq."""
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
"type": "function",
|
|
29
|
+
"function": {
|
|
30
|
+
"name": "read_file",
|
|
31
|
+
"description": "Read content of a file in the project.",
|
|
32
|
+
"parameters": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"file_path": {"type": "string", "description": "Relative path to file"},
|
|
36
|
+
},
|
|
37
|
+
"required": ["file_path"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"type": "function",
|
|
43
|
+
"function": {
|
|
44
|
+
"name": "write_file",
|
|
45
|
+
"description": "Write or overwrite content in a file.",
|
|
46
|
+
"parameters": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"file_path": {"type": "string", "description": "Relative path to file"},
|
|
50
|
+
"content": {"type": "string", "description": "Full file content"},
|
|
51
|
+
},
|
|
52
|
+
"required": ["file_path", "content"],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "function",
|
|
58
|
+
"function": {
|
|
59
|
+
"name": "edit_file",
|
|
60
|
+
"description": "Replace exact target string with replacement text in a file.",
|
|
61
|
+
"parameters": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"file_path": {"type": "string", "description": "Relative path to file"},
|
|
65
|
+
"old_str": {"type": "string", "description": "Exact text to find and replace"},
|
|
66
|
+
"new_str": {"type": "string", "description": "Replacement text"},
|
|
67
|
+
},
|
|
68
|
+
"required": ["file_path", "old_str", "new_str"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"type": "function",
|
|
74
|
+
"function": {
|
|
75
|
+
"name": "run_command",
|
|
76
|
+
"description": "Run a shell command in project directory.",
|
|
77
|
+
"parameters": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"command": {"type": "string", "description": "Terminal command to run"},
|
|
81
|
+
},
|
|
82
|
+
"required": ["command"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"type": "function",
|
|
88
|
+
"function": {
|
|
89
|
+
"name": "list_files",
|
|
90
|
+
"description": "List files in the project workspace.",
|
|
91
|
+
"parameters": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": {
|
|
94
|
+
"sub_dir": {"type": "string", "description": "Subdirectory to list (default .)"},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def get_anthropic_tools(self) -> list[dict[str, Any]]:
|
|
102
|
+
"""Return tool definitions formatted for Anthropic Claude."""
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
"name": "read_file",
|
|
106
|
+
"description": "Read content of a file in the project.",
|
|
107
|
+
"input_schema": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"properties": {
|
|
110
|
+
"file_path": {"type": "string", "description": "Relative path to file"},
|
|
111
|
+
},
|
|
112
|
+
"required": ["file_path"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "write_file",
|
|
117
|
+
"description": "Write or overwrite content in a file.",
|
|
118
|
+
"input_schema": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"properties": {
|
|
121
|
+
"file_path": {"type": "string", "description": "Relative path to file"},
|
|
122
|
+
"content": {"type": "string", "description": "Full file content"},
|
|
123
|
+
},
|
|
124
|
+
"required": ["file_path", "content"],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"name": "edit_file",
|
|
129
|
+
"description": "Replace exact target string with replacement text in a file.",
|
|
130
|
+
"input_schema": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"properties": {
|
|
133
|
+
"file_path": {"type": "string", "description": "Relative path to file"},
|
|
134
|
+
"old_str": {"type": "string", "description": "Exact text to find and replace"},
|
|
135
|
+
"new_str": {"type": "string", "description": "Replacement text"},
|
|
136
|
+
},
|
|
137
|
+
"required": ["file_path", "old_str", "new_str"],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "run_command",
|
|
142
|
+
"description": "Run a shell command in project directory.",
|
|
143
|
+
"input_schema": {
|
|
144
|
+
"type": "object",
|
|
145
|
+
"properties": {
|
|
146
|
+
"command": {"type": "string", "description": "Terminal command to run"},
|
|
147
|
+
},
|
|
148
|
+
"required": ["command"],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"name": "list_files",
|
|
153
|
+
"description": "List files in the project workspace.",
|
|
154
|
+
"input_schema": {
|
|
155
|
+
"type": "object",
|
|
156
|
+
"properties": {
|
|
157
|
+
"sub_dir": {"type": "string", "description": "Subdirectory to list (default .)"},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
def execute_tool(self, tool_name: str, args: dict[str, Any]) -> str:
|
|
164
|
+
"""Execute a tool by name with arguments and return output."""
|
|
165
|
+
if tool_name == "read_file":
|
|
166
|
+
return self.file_editor.read_file(args.get("file_path", ""))
|
|
167
|
+
elif tool_name == "write_file":
|
|
168
|
+
return self.file_editor.write_file(args.get("file_path", ""), args.get("content", ""))
|
|
169
|
+
elif tool_name == "edit_file":
|
|
170
|
+
return self.file_editor.edit_file(
|
|
171
|
+
args.get("file_path", ""), args.get("old_str", ""), args.get("new_str", "")
|
|
172
|
+
)
|
|
173
|
+
elif tool_name == "run_command":
|
|
174
|
+
return self.terminal.run_command(args.get("command", ""))
|
|
175
|
+
elif tool_name == "list_files":
|
|
176
|
+
return self.file_editor.list_files(args.get("sub_dir", "."))
|
|
177
|
+
elif tool_name == "git_status":
|
|
178
|
+
return self.git.status()
|
|
179
|
+
elif tool_name == "git_diff":
|
|
180
|
+
return self.git.diff(args.get("staged", False))
|
|
181
|
+
elif tool_name == "run_python":
|
|
182
|
+
return self.code_runner.run_python(args.get("code", ""))
|
|
183
|
+
else:
|
|
184
|
+
return f"Error: Unknown tool '{tool_name}'."
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code execution runners for Python, Node, and scripts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CodeRunner:
|
|
13
|
+
"""Executes arbitrary code snippets in isolated temporary files."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, project_path: str | Path = ".") -> None:
|
|
16
|
+
self.project_path = Path(project_path).resolve()
|
|
17
|
+
|
|
18
|
+
def run_python(self, code: str, timeout: int = 20) -> str:
|
|
19
|
+
"""Execute a Python snippet."""
|
|
20
|
+
with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
|
|
21
|
+
f.write(code)
|
|
22
|
+
temp_file = f.name
|
|
23
|
+
try:
|
|
24
|
+
res = subprocess.run(
|
|
25
|
+
["python3", temp_file],
|
|
26
|
+
cwd=str(self.project_path),
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
out = res.stdout.strip()
|
|
32
|
+
err = res.stderr.strip()
|
|
33
|
+
return f"Return code: {res.returncode}\n{out}\n{err}".strip()
|
|
34
|
+
except subprocess.TimeoutExpired:
|
|
35
|
+
return f"Timeout: Python script exceeded {timeout}s."
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return f"Execution error: {e}"
|
|
38
|
+
finally:
|
|
39
|
+
Path(temp_file).unlink(missing_ok=True)
|
|
40
|
+
|
|
41
|
+
def run_node(self, code: str, timeout: int = 20) -> str:
|
|
42
|
+
"""Execute a Node.js snippet."""
|
|
43
|
+
with tempfile.NamedTemporaryFile(suffix=".js", mode="w", delete=False) as f:
|
|
44
|
+
f.write(code)
|
|
45
|
+
temp_file = f.name
|
|
46
|
+
try:
|
|
47
|
+
res = subprocess.run(
|
|
48
|
+
["node", temp_file],
|
|
49
|
+
cwd=str(self.project_path),
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
)
|
|
54
|
+
out = res.stdout.strip()
|
|
55
|
+
err = res.stderr.strip()
|
|
56
|
+
return f"Return code: {res.returncode}\n{out}\n{err}".strip()
|
|
57
|
+
except subprocess.TimeoutExpired:
|
|
58
|
+
return f"Timeout: Node script exceeded {timeout}s."
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return f"Execution error: {e}"
|
|
61
|
+
finally:
|
|
62
|
+
Path(temp_file).unlink(missing_ok=True)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File operations tool for coding agents.
|
|
3
|
+
|
|
4
|
+
Read, write, edit, search files within the project working directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileEditor:
|
|
15
|
+
"""Provides safe file operations rooted in a project directory."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, project_path: str | Path = ".") -> None:
|
|
18
|
+
self.project_path = Path(project_path).resolve()
|
|
19
|
+
|
|
20
|
+
def _resolve_path(self, file_path: str) -> Path:
|
|
21
|
+
p = Path(file_path)
|
|
22
|
+
if not p.is_absolute():
|
|
23
|
+
p = self.project_path / p
|
|
24
|
+
return p.resolve()
|
|
25
|
+
|
|
26
|
+
def read_file(self, file_path: str, max_lines: int = 500) -> str:
|
|
27
|
+
"""Read content of a file."""
|
|
28
|
+
target = self._resolve_path(file_path)
|
|
29
|
+
if not target.is_file():
|
|
30
|
+
return f"Error: File '{file_path}' does not exist."
|
|
31
|
+
try:
|
|
32
|
+
lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
33
|
+
if len(lines) > max_lines:
|
|
34
|
+
preview = "\n".join(lines[:max_lines])
|
|
35
|
+
return f"{preview}\n\n... [Truncated: showing first {max_lines} of {len(lines)} lines]"
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
return f"Error reading file '{file_path}': {e}"
|
|
39
|
+
|
|
40
|
+
def write_file(self, file_path: str, content: str) -> str:
|
|
41
|
+
"""Write/overwrite content to a file, creating parent directories if needed."""
|
|
42
|
+
target = self._resolve_path(file_path)
|
|
43
|
+
try:
|
|
44
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
target.write_text(content, encoding="utf-8")
|
|
46
|
+
return f"Successfully wrote {len(content.splitlines())} lines to '{file_path}'."
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return f"Error writing to file '{file_path}': {e}"
|
|
49
|
+
|
|
50
|
+
def edit_file(self, file_path: str, old_str: str, new_str: str) -> str:
|
|
51
|
+
"""Replace exact target content with new content in a file."""
|
|
52
|
+
target = self._resolve_path(file_path)
|
|
53
|
+
if not target.is_file():
|
|
54
|
+
return f"Error: File '{file_path}' does not exist."
|
|
55
|
+
try:
|
|
56
|
+
content = target.read_text(encoding="utf-8")
|
|
57
|
+
if old_str not in content:
|
|
58
|
+
return f"Error: Target text to replace not found in '{file_path}'."
|
|
59
|
+
count = content.count(old_str)
|
|
60
|
+
if count > 1:
|
|
61
|
+
return f"Error: Target text found {count} times. Please specify a more unique target block."
|
|
62
|
+
new_content = content.replace(old_str, new_str, 1)
|
|
63
|
+
target.write_text(new_content, encoding="utf-8")
|
|
64
|
+
return f"Successfully updated '{file_path}'."
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return f"Error editing file '{file_path}': {e}"
|
|
67
|
+
|
|
68
|
+
def list_files(self, sub_dir: str = ".", max_entries: int = 100) -> str:
|
|
69
|
+
"""List files and directories within project."""
|
|
70
|
+
target = self._resolve_path(sub_dir)
|
|
71
|
+
if not target.is_dir():
|
|
72
|
+
return f"Error: Directory '{sub_dir}' does not exist."
|
|
73
|
+
entries = []
|
|
74
|
+
try:
|
|
75
|
+
for root, dirs, files in os.walk(target):
|
|
76
|
+
# Skip hidden folders like .git, node_modules, __pycache__
|
|
77
|
+
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "node_modules" and d != "__pycache__"]
|
|
78
|
+
for f in files:
|
|
79
|
+
if f.startswith("."):
|
|
80
|
+
continue
|
|
81
|
+
full_p = Path(root) / f
|
|
82
|
+
rel_p = full_p.relative_to(self.project_path)
|
|
83
|
+
entries.append(str(rel_p))
|
|
84
|
+
if len(entries) >= max_entries:
|
|
85
|
+
break
|
|
86
|
+
if len(entries) >= max_entries:
|
|
87
|
+
break
|
|
88
|
+
if not entries:
|
|
89
|
+
return "Directory is empty."
|
|
90
|
+
return "\n".join(sorted(entries))
|
|
91
|
+
except Exception as e:
|
|
92
|
+
return f"Error listing directory: {e}"
|
thwip/tools/git_ops.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git operations tool for coding agents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitOps:
|
|
12
|
+
"""Git version control integration."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, project_path: str | Path = ".") -> None:
|
|
15
|
+
self.project_path = Path(project_path).resolve()
|
|
16
|
+
|
|
17
|
+
def _run_git(self, args: list[str]) -> str:
|
|
18
|
+
try:
|
|
19
|
+
res = subprocess.run(
|
|
20
|
+
["git"] + args,
|
|
21
|
+
cwd=str(self.project_path),
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
timeout=15,
|
|
25
|
+
)
|
|
26
|
+
out = res.stdout.strip()
|
|
27
|
+
err = res.stderr.strip()
|
|
28
|
+
if res.returncode != 0:
|
|
29
|
+
return f"Git error (code {res.returncode}): {err or out}"
|
|
30
|
+
return out or "Success."
|
|
31
|
+
except Exception as e:
|
|
32
|
+
return f"Git operation failed: {e}"
|
|
33
|
+
|
|
34
|
+
def status(self) -> str:
|
|
35
|
+
return self._run_git(["status", "--short"])
|
|
36
|
+
|
|
37
|
+
def diff(self, staged: bool = False) -> str:
|
|
38
|
+
args = ["diff"]
|
|
39
|
+
if staged:
|
|
40
|
+
args.append("--staged")
|
|
41
|
+
return self._run_git(args)
|
|
42
|
+
|
|
43
|
+
def log(self, max_count: int = 5) -> str:
|
|
44
|
+
return self._run_git(["log", f"-n{max_count}", "--oneline"])
|
thwip/tools/terminal.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal execution tools for coding agents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TerminalRunner:
|
|
13
|
+
"""Executes commands and captures output in the project directory."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, project_path: str | Path = ".") -> None:
|
|
16
|
+
self.project_path = Path(project_path).resolve()
|
|
17
|
+
|
|
18
|
+
def run_command(self, command: str, timeout: int = 30) -> str:
|
|
19
|
+
"""Run a shell command synchronously and return combined stdout/stderr."""
|
|
20
|
+
try:
|
|
21
|
+
res = subprocess.run(
|
|
22
|
+
command,
|
|
23
|
+
shell=True,
|
|
24
|
+
cwd=str(self.project_path),
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
timeout=timeout,
|
|
28
|
+
)
|
|
29
|
+
out = res.stdout.strip()
|
|
30
|
+
err = res.stderr.strip()
|
|
31
|
+
ret = res.returncode
|
|
32
|
+
res_str = f"Exit code: {ret}\n"
|
|
33
|
+
if out:
|
|
34
|
+
res_str += f"STDOUT:\n{out}\n"
|
|
35
|
+
if err:
|
|
36
|
+
res_str += f"STDERR:\n{err}\n"
|
|
37
|
+
return res_str.strip()
|
|
38
|
+
except subprocess.TimeoutExpired:
|
|
39
|
+
return f"Error: Command timed out after {timeout} seconds."
|
|
40
|
+
except Exception as e:
|
|
41
|
+
return f"Error executing command: {e}"
|
|
42
|
+
|
|
43
|
+
async def run_command_async(self, command: str, timeout: int = 30) -> str:
|
|
44
|
+
"""Run a shell command asynchronously."""
|
|
45
|
+
try:
|
|
46
|
+
proc = await asyncio.create_subprocess_shell(
|
|
47
|
+
command,
|
|
48
|
+
cwd=str(self.project_path),
|
|
49
|
+
stdout=asyncio.subprocess.PIPE,
|
|
50
|
+
stderr=asyncio.subprocess.PIPE,
|
|
51
|
+
)
|
|
52
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
53
|
+
out = stdout.decode("utf-8", errors="replace").strip()
|
|
54
|
+
err = stderr.decode("utf-8", errors="replace").strip()
|
|
55
|
+
ret = proc.returncode
|
|
56
|
+
res_str = f"Exit code: {ret}\n"
|
|
57
|
+
if out:
|
|
58
|
+
res_str += f"STDOUT:\n{out}\n"
|
|
59
|
+
if err:
|
|
60
|
+
res_str += f"STDERR:\n{err}\n"
|
|
61
|
+
return res_str.strip()
|
|
62
|
+
except asyncio.TimeoutError:
|
|
63
|
+
return f"Error: Command timed out after {timeout} seconds."
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return f"Error executing command: {e}"
|
thwip/utils.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for thwip.
|
|
3
|
+
|
|
4
|
+
Streaming helpers, markdown rendering, token counting, cost estimation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
from typing import AsyncIterator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Token & Cost Estimation
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
# Approximate cost per 1M tokens (input/output): updated periodically
|
|
19
|
+
MODEL_PRICING: dict[str, dict[str, float]] = {
|
|
20
|
+
# Anthropic
|
|
21
|
+
"claude-sonnet-4": {"input": 3.0, "output": 15.0},
|
|
22
|
+
"claude-opus-4": {"input": 15.0, "output": 75.0},
|
|
23
|
+
"claude-haiku-3.5": {"input": 0.80, "output": 4.0},
|
|
24
|
+
# Google
|
|
25
|
+
"gemini-2.5-pro": {"input": 1.25, "output": 10.0},
|
|
26
|
+
"gemini-2.5-flash": {"input": 0.15, "output": 0.60},
|
|
27
|
+
# OpenAI
|
|
28
|
+
"gpt-4.1": {"input": 2.0, "output": 8.0},
|
|
29
|
+
"gpt-4o": {"input": 2.5, "output": 10.0},
|
|
30
|
+
"o3": {"input": 2.0, "output": 8.0},
|
|
31
|
+
"o4-mini": {"input": 1.10, "output": 4.40},
|
|
32
|
+
"codex-mini": {"input": 1.50, "output": 6.0},
|
|
33
|
+
# DeepSeek
|
|
34
|
+
"deepseek-chat": {"input": 0.14, "output": 0.28},
|
|
35
|
+
"deepseek-reasoner": {"input": 0.55, "output": 2.19},
|
|
36
|
+
# Groq
|
|
37
|
+
"llama-3.3-70b-versatile": {"input": 0.59, "output": 0.79},
|
|
38
|
+
"mixtral-8x7b-32768": {"input": 0.24, "output": 0.24},
|
|
39
|
+
# Mistral
|
|
40
|
+
"mistral-large-latest": {"input": 2.0, "output": 6.0},
|
|
41
|
+
"codestral-latest": {"input": 0.30, "output": 0.90},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
|
46
|
+
"""Estimate cost in USD for a given model and token counts."""
|
|
47
|
+
pricing = MODEL_PRICING.get(model)
|
|
48
|
+
if not pricing:
|
|
49
|
+
return 0.0
|
|
50
|
+
input_cost = (input_tokens / 1_000_000) * pricing["input"]
|
|
51
|
+
output_cost = (output_tokens / 1_000_000) * pricing["output"]
|
|
52
|
+
return input_cost + output_cost
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_cost(cost: float) -> str:
|
|
56
|
+
"""Format cost as a readable string."""
|
|
57
|
+
if cost < 0.01:
|
|
58
|
+
return f"${cost:.6f}"
|
|
59
|
+
elif cost < 1.0:
|
|
60
|
+
return f"${cost:.4f}"
|
|
61
|
+
else:
|
|
62
|
+
return f"${cost:.2f}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_tokens(count: int) -> str:
|
|
66
|
+
"""Format token count with commas."""
|
|
67
|
+
if count >= 1_000_000:
|
|
68
|
+
return f"{count / 1_000_000:.1f}M"
|
|
69
|
+
elif count >= 1_000:
|
|
70
|
+
return f"{count / 1_000:.1f}K"
|
|
71
|
+
return str(count)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Async Helpers
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def collect_stream(stream: AsyncIterator[str]) -> str:
|
|
79
|
+
"""Collect all chunks from an async stream into a single string."""
|
|
80
|
+
parts: list[str] = []
|
|
81
|
+
async for chunk in stream:
|
|
82
|
+
parts.append(chunk)
|
|
83
|
+
return "".join(parts)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# String Helpers
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def truncate(text: str, max_len: int = 80) -> str:
|
|
91
|
+
"""Truncate text with ellipsis."""
|
|
92
|
+
if len(text) <= max_len:
|
|
93
|
+
return text
|
|
94
|
+
return text[: max_len - 3] + "..."
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def mask_key(key: str) -> str:
|
|
98
|
+
"""Mask an API key for display: sk-ant-...xyz123"""
|
|
99
|
+
if not key or len(key) < 10:
|
|
100
|
+
return "****"
|
|
101
|
+
return f"{key[:7]}...{key[-6:]}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def slugify(text: str) -> str:
|
|
105
|
+
"""Convert text to a URL/filename-safe slug."""
|
|
106
|
+
import re
|
|
107
|
+
text = text.lower().strip()
|
|
108
|
+
text = re.sub(r"[^\w\s-]", "", text)
|
|
109
|
+
text = re.sub(r"[\s_]+", "-", text)
|
|
110
|
+
return text.strip("-")
|