llmkit-cli 0.1.0__tar.gz
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.
- llmkit_cli-0.1.0/PKG-INFO +193 -0
- llmkit_cli-0.1.0/README.md +168 -0
- llmkit_cli-0.1.0/agent_cmd.py +301 -0
- llmkit_cli-0.1.0/check.py +74 -0
- llmkit_cli-0.1.0/cli.py +98 -0
- llmkit_cli-0.1.0/env.py +65 -0
- llmkit_cli-0.1.0/git.py +2 -0
- llmkit_cli-0.1.0/git_cmds.py +140 -0
- llmkit_cli-0.1.0/init.py +63 -0
- llmkit_cli-0.1.0/llmkit_cli.egg-info/PKG-INFO +193 -0
- llmkit_cli-0.1.0/llmkit_cli.egg-info/SOURCES.txt +26 -0
- llmkit_cli-0.1.0/llmkit_cli.egg-info/dependency_links.txt +1 -0
- llmkit_cli-0.1.0/llmkit_cli.egg-info/entry_points.txt +2 -0
- llmkit_cli-0.1.0/llmkit_cli.egg-info/requires.txt +12 -0
- llmkit_cli-0.1.0/llmkit_cli.egg-info/top_level.txt +9 -0
- llmkit_cli-0.1.0/lock.py +43 -0
- llmkit_cli-0.1.0/providers/__init__.py +0 -0
- llmkit_cli-0.1.0/providers/anthropic.py +14 -0
- llmkit_cli-0.1.0/providers/deepseek.py +13 -0
- llmkit_cli-0.1.0/providers/groq.py +13 -0
- llmkit_cli-0.1.0/providers/local.py +20 -0
- llmkit_cli-0.1.0/providers/mistral.py +9 -0
- llmkit_cli-0.1.0/providers/openai.py +13 -0
- llmkit_cli-0.1.0/providers/together.py +13 -0
- llmkit_cli-0.1.0/providers/utils.py +28 -0
- llmkit_cli-0.1.0/pyproject.toml +39 -0
- llmkit_cli-0.1.0/setup.cfg +4 -0
- llmkit_cli-0.1.0/tests/test_llmkit.py +600 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmkit-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run any LLM with one config file. No framework lock-in.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/abinzagr/llmkit
|
|
7
|
+
Project-URL: Repository, https://github.com/abinzagr/llmkit
|
|
8
|
+
Keywords: llm,ai,openai,anthropic,groq,cli
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: pyyaml
|
|
15
|
+
Requires-Dist: python-dotenv
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Requires-Dist: openai
|
|
18
|
+
Requires-Dist: anthropic
|
|
19
|
+
Requires-Dist: groq
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: together; extra == "all"
|
|
22
|
+
Requires-Dist: mistralai; extra == "all"
|
|
23
|
+
Requires-Dist: chromadb; extra == "all"
|
|
24
|
+
Requires-Dist: mcp; extra == "all"
|
|
25
|
+
|
|
26
|
+
# llmkit
|
|
27
|
+
|
|
28
|
+
Run any LLM — local or via API — with one config file. No framework lock-in. Works on Windows, Mac, Linux.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
llmkit run "explain this codebase in one sentence"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Linux
|
|
38
|
+
bash install.sh
|
|
39
|
+
|
|
40
|
+
# Mac
|
|
41
|
+
bash install.mac.sh
|
|
42
|
+
|
|
43
|
+
# Windows (PowerShell as admin)
|
|
44
|
+
./install.ps1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each script installs deps, sets up Ollama if needed, and adds `llmkit` to your PATH.
|
|
48
|
+
|
|
49
|
+
## Configure
|
|
50
|
+
|
|
51
|
+
Edit `llm.yaml` (or run `llmkit init` for a guided setup):
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
provider: groq # local | openai | anthropic | groq | together | deepseek | mistral
|
|
55
|
+
model: llama-3.3-70b-versatile
|
|
56
|
+
mode: chat
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For API providers, copy `.env.example` to `.env` and add your key.
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# One-shot prompt
|
|
65
|
+
llmkit run "explain this codebase in one sentence"
|
|
66
|
+
|
|
67
|
+
# Validate config + check provider reachability
|
|
68
|
+
llmkit check
|
|
69
|
+
|
|
70
|
+
# Interactive wizard to create llm.yaml
|
|
71
|
+
llmkit init
|
|
72
|
+
|
|
73
|
+
# Pin current model to llm.lock (commit this file)
|
|
74
|
+
llmkit lock
|
|
75
|
+
|
|
76
|
+
# Generate a commit message from staged changes
|
|
77
|
+
llmkit commit
|
|
78
|
+
|
|
79
|
+
# Generate a PR title + body vs main/master
|
|
80
|
+
llmkit pr
|
|
81
|
+
|
|
82
|
+
# Review staged/unstaged diff for bugs
|
|
83
|
+
llmkit review
|
|
84
|
+
|
|
85
|
+
# Run a coding agent on a task
|
|
86
|
+
llmkit agent "refactor this module to use dataclasses"
|
|
87
|
+
|
|
88
|
+
# Plan only — no tools executed, just a numbered plan
|
|
89
|
+
llmkit agent --plan "add pagination to the API"
|
|
90
|
+
|
|
91
|
+
# Approve mode — confirm each shell command before it runs
|
|
92
|
+
llmkit agent --approve "run the test suite and fix any failures"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Examples
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Chat
|
|
99
|
+
python examples/chat.py
|
|
100
|
+
node examples/chat.js # all providers including Anthropic
|
|
101
|
+
|
|
102
|
+
# Streaming
|
|
103
|
+
python examples/stream.py
|
|
104
|
+
node examples/stream.js # all providers including Anthropic
|
|
105
|
+
|
|
106
|
+
# Function calling / tools
|
|
107
|
+
python examples/tools.py
|
|
108
|
+
|
|
109
|
+
# Vision (image input)
|
|
110
|
+
python examples/vision.py
|
|
111
|
+
|
|
112
|
+
# Multi-round conversation
|
|
113
|
+
python examples/multiround.py
|
|
114
|
+
|
|
115
|
+
# Embeddings + cosine similarity
|
|
116
|
+
python examples/embed.py
|
|
117
|
+
|
|
118
|
+
# Coding agent
|
|
119
|
+
python examples/agent.py
|
|
120
|
+
|
|
121
|
+
# MCP agent (connects to MCP servers defined in llm.yaml)
|
|
122
|
+
python examples/mcp_agent.py
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Local models (via Ollama)
|
|
126
|
+
|
|
127
|
+
| Model | Config |
|
|
128
|
+
|---|---|
|
|
129
|
+
| Llama 4 | `model: llama4` |
|
|
130
|
+
| Qwen 3 | `model: qwen3` |
|
|
131
|
+
| DeepSeek R1 | `model: deepseek-r1` |
|
|
132
|
+
| Mistral | `model: mistral` |
|
|
133
|
+
| Phi-4 | `model: phi4` |
|
|
134
|
+
| Gemma 3 | `model: gemma3` |
|
|
135
|
+
|
|
136
|
+
## API providers
|
|
137
|
+
|
|
138
|
+
| Provider | Env key | Fast cheap model |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| OpenAI | `OPENAI_API_KEY` | `gpt-4o-mini` |
|
|
141
|
+
| Anthropic | `ANTHROPIC_API_KEY` | `claude-3-5-haiku-20241022` |
|
|
142
|
+
| Groq | `GROQ_API_KEY` | `llama-3.1-8b-instant` |
|
|
143
|
+
| Together | `TOGETHER_API_KEY` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
|
144
|
+
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |
|
|
145
|
+
| Mistral | `MISTRAL_API_KEY` | `mistral-small-latest` |
|
|
146
|
+
|
|
147
|
+
## 5-minute team setup
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# 1 — clone
|
|
151
|
+
git clone https://github.com/your-org/llmkit
|
|
152
|
+
cd llmkit
|
|
153
|
+
|
|
154
|
+
# 2 — install deps + register llmkit command
|
|
155
|
+
bash install.sh # Mac: bash install.mac.sh | Windows: ./install.ps1
|
|
156
|
+
|
|
157
|
+
# 3 — set one API key (Groq free tier works)
|
|
158
|
+
echo "GROQ_API_KEY=your_key_here" > .env
|
|
159
|
+
|
|
160
|
+
# 4 — verify everything is wired up
|
|
161
|
+
llmkit check
|
|
162
|
+
|
|
163
|
+
# 5 — run your first prompt
|
|
164
|
+
llmkit run "explain this codebase in one sentence"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
No server. No IDE extension. No code to write. Switch providers by editing one line in `llm.yaml`.
|
|
168
|
+
|
|
169
|
+
## llm.lock
|
|
170
|
+
|
|
171
|
+
Run `llmkit lock` to pin your model runtime. Commit `llm.lock` alongside your code:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
# Auto-generated — commit this file to pin your model runtime
|
|
175
|
+
locked_at: "2026-06-26T12:00:00Z"
|
|
176
|
+
provider: groq
|
|
177
|
+
model: llama-3.3-70b-versatile
|
|
178
|
+
mode: chat
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Same idea as `package-lock.json` — reproducible environments, no surprise model swaps between teammates.
|
|
182
|
+
|
|
183
|
+
## Switch providers
|
|
184
|
+
|
|
185
|
+
Change one line in `llm.yaml`, run `llmkit check`, done:
|
|
186
|
+
|
|
187
|
+
```yaml
|
|
188
|
+
# was: provider: groq
|
|
189
|
+
provider: anthropic
|
|
190
|
+
model: claude-3-5-haiku-20241022
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
No code changes. No redeploy. Works in CI too.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# llmkit
|
|
2
|
+
|
|
3
|
+
Run any LLM — local or via API — with one config file. No framework lock-in. Works on Windows, Mac, Linux.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
llmkit run "explain this codebase in one sentence"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Linux
|
|
13
|
+
bash install.sh
|
|
14
|
+
|
|
15
|
+
# Mac
|
|
16
|
+
bash install.mac.sh
|
|
17
|
+
|
|
18
|
+
# Windows (PowerShell as admin)
|
|
19
|
+
./install.ps1
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Each script installs deps, sets up Ollama if needed, and adds `llmkit` to your PATH.
|
|
23
|
+
|
|
24
|
+
## Configure
|
|
25
|
+
|
|
26
|
+
Edit `llm.yaml` (or run `llmkit init` for a guided setup):
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
provider: groq # local | openai | anthropic | groq | together | deepseek | mistral
|
|
30
|
+
model: llama-3.3-70b-versatile
|
|
31
|
+
mode: chat
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For API providers, copy `.env.example` to `.env` and add your key.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# One-shot prompt
|
|
40
|
+
llmkit run "explain this codebase in one sentence"
|
|
41
|
+
|
|
42
|
+
# Validate config + check provider reachability
|
|
43
|
+
llmkit check
|
|
44
|
+
|
|
45
|
+
# Interactive wizard to create llm.yaml
|
|
46
|
+
llmkit init
|
|
47
|
+
|
|
48
|
+
# Pin current model to llm.lock (commit this file)
|
|
49
|
+
llmkit lock
|
|
50
|
+
|
|
51
|
+
# Generate a commit message from staged changes
|
|
52
|
+
llmkit commit
|
|
53
|
+
|
|
54
|
+
# Generate a PR title + body vs main/master
|
|
55
|
+
llmkit pr
|
|
56
|
+
|
|
57
|
+
# Review staged/unstaged diff for bugs
|
|
58
|
+
llmkit review
|
|
59
|
+
|
|
60
|
+
# Run a coding agent on a task
|
|
61
|
+
llmkit agent "refactor this module to use dataclasses"
|
|
62
|
+
|
|
63
|
+
# Plan only — no tools executed, just a numbered plan
|
|
64
|
+
llmkit agent --plan "add pagination to the API"
|
|
65
|
+
|
|
66
|
+
# Approve mode — confirm each shell command before it runs
|
|
67
|
+
llmkit agent --approve "run the test suite and fix any failures"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Examples
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Chat
|
|
74
|
+
python examples/chat.py
|
|
75
|
+
node examples/chat.js # all providers including Anthropic
|
|
76
|
+
|
|
77
|
+
# Streaming
|
|
78
|
+
python examples/stream.py
|
|
79
|
+
node examples/stream.js # all providers including Anthropic
|
|
80
|
+
|
|
81
|
+
# Function calling / tools
|
|
82
|
+
python examples/tools.py
|
|
83
|
+
|
|
84
|
+
# Vision (image input)
|
|
85
|
+
python examples/vision.py
|
|
86
|
+
|
|
87
|
+
# Multi-round conversation
|
|
88
|
+
python examples/multiround.py
|
|
89
|
+
|
|
90
|
+
# Embeddings + cosine similarity
|
|
91
|
+
python examples/embed.py
|
|
92
|
+
|
|
93
|
+
# Coding agent
|
|
94
|
+
python examples/agent.py
|
|
95
|
+
|
|
96
|
+
# MCP agent (connects to MCP servers defined in llm.yaml)
|
|
97
|
+
python examples/mcp_agent.py
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Local models (via Ollama)
|
|
101
|
+
|
|
102
|
+
| Model | Config |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Llama 4 | `model: llama4` |
|
|
105
|
+
| Qwen 3 | `model: qwen3` |
|
|
106
|
+
| DeepSeek R1 | `model: deepseek-r1` |
|
|
107
|
+
| Mistral | `model: mistral` |
|
|
108
|
+
| Phi-4 | `model: phi4` |
|
|
109
|
+
| Gemma 3 | `model: gemma3` |
|
|
110
|
+
|
|
111
|
+
## API providers
|
|
112
|
+
|
|
113
|
+
| Provider | Env key | Fast cheap model |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| OpenAI | `OPENAI_API_KEY` | `gpt-4o-mini` |
|
|
116
|
+
| Anthropic | `ANTHROPIC_API_KEY` | `claude-3-5-haiku-20241022` |
|
|
117
|
+
| Groq | `GROQ_API_KEY` | `llama-3.1-8b-instant` |
|
|
118
|
+
| Together | `TOGETHER_API_KEY` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
|
119
|
+
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |
|
|
120
|
+
| Mistral | `MISTRAL_API_KEY` | `mistral-small-latest` |
|
|
121
|
+
|
|
122
|
+
## 5-minute team setup
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# 1 — clone
|
|
126
|
+
git clone https://github.com/your-org/llmkit
|
|
127
|
+
cd llmkit
|
|
128
|
+
|
|
129
|
+
# 2 — install deps + register llmkit command
|
|
130
|
+
bash install.sh # Mac: bash install.mac.sh | Windows: ./install.ps1
|
|
131
|
+
|
|
132
|
+
# 3 — set one API key (Groq free tier works)
|
|
133
|
+
echo "GROQ_API_KEY=your_key_here" > .env
|
|
134
|
+
|
|
135
|
+
# 4 — verify everything is wired up
|
|
136
|
+
llmkit check
|
|
137
|
+
|
|
138
|
+
# 5 — run your first prompt
|
|
139
|
+
llmkit run "explain this codebase in one sentence"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
No server. No IDE extension. No code to write. Switch providers by editing one line in `llm.yaml`.
|
|
143
|
+
|
|
144
|
+
## llm.lock
|
|
145
|
+
|
|
146
|
+
Run `llmkit lock` to pin your model runtime. Commit `llm.lock` alongside your code:
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
# Auto-generated — commit this file to pin your model runtime
|
|
150
|
+
locked_at: "2026-06-26T12:00:00Z"
|
|
151
|
+
provider: groq
|
|
152
|
+
model: llama-3.3-70b-versatile
|
|
153
|
+
mode: chat
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Same idea as `package-lock.json` — reproducible environments, no surprise model swaps between teammates.
|
|
157
|
+
|
|
158
|
+
## Switch providers
|
|
159
|
+
|
|
160
|
+
Change one line in `llm.yaml`, run `llmkit check`, done:
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
# was: provider: groq
|
|
164
|
+
provider: anthropic
|
|
165
|
+
model: claude-3-5-haiku-20241022
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
No code changes. No redeploy. Works in CI too.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import yaml
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
ROOT = Path(__file__).parent
|
|
11
|
+
sys.path.insert(0, str(ROOT))
|
|
12
|
+
|
|
13
|
+
MAX_STEPS = 10
|
|
14
|
+
WORKSPACE = Path.cwd().resolve()
|
|
15
|
+
|
|
16
|
+
TOOLS_SCHEMA = [
|
|
17
|
+
{"type": "function", "function": {
|
|
18
|
+
"name": "read_file",
|
|
19
|
+
"description": "Read a file's contents",
|
|
20
|
+
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
|
|
21
|
+
}},
|
|
22
|
+
{"type": "function", "function": {
|
|
23
|
+
"name": "write_file",
|
|
24
|
+
"description": "Create or overwrite a file",
|
|
25
|
+
"parameters": {"type": "object", "properties": {
|
|
26
|
+
"path": {"type": "string"}, "content": {"type": "string"}
|
|
27
|
+
}, "required": ["path", "content"]},
|
|
28
|
+
}},
|
|
29
|
+
{"type": "function", "function": {
|
|
30
|
+
"name": "run_shell",
|
|
31
|
+
"description": "Run a shell command and return output",
|
|
32
|
+
"parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]},
|
|
33
|
+
}},
|
|
34
|
+
{"type": "function", "function": {
|
|
35
|
+
"name": "list_dir",
|
|
36
|
+
"description": "List files and folders in a directory",
|
|
37
|
+
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": []},
|
|
38
|
+
}},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
ALL_TOOL_NAMES = {t["function"]["name"] for t in TOOLS_SCHEMA}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _active_tools(config):
|
|
45
|
+
allowed = config.get("tools")
|
|
46
|
+
if allowed is None:
|
|
47
|
+
return TOOLS_SCHEMA
|
|
48
|
+
allowed_set = set(allowed)
|
|
49
|
+
return [t for t in TOOLS_SCHEMA if t["function"]["name"] in allowed_set]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _guard(path):
|
|
53
|
+
p = Path(path).resolve()
|
|
54
|
+
if not p.is_relative_to(WORKSPACE):
|
|
55
|
+
return None, f"Blocked: {p} is outside workspace"
|
|
56
|
+
return p, None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_file(path):
|
|
60
|
+
p, err = _guard(path)
|
|
61
|
+
if err:
|
|
62
|
+
return err
|
|
63
|
+
try:
|
|
64
|
+
return p.read_text(encoding="utf-8")
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return f"Error: {e}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_file(path, content):
|
|
70
|
+
p, err = _guard(path)
|
|
71
|
+
if err:
|
|
72
|
+
return err
|
|
73
|
+
try:
|
|
74
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
p.write_text(content, encoding="utf-8")
|
|
76
|
+
return f"Written: {p}"
|
|
77
|
+
except Exception as e:
|
|
78
|
+
return f"Error: {e}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run_shell(command, approve):
|
|
82
|
+
if approve:
|
|
83
|
+
print(f"\n [approve] run: {command}")
|
|
84
|
+
if input(" Allow? [y/N]: ").strip().lower() != "y":
|
|
85
|
+
return "Skipped."
|
|
86
|
+
try:
|
|
87
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
|
|
88
|
+
out = result.stdout.strip()
|
|
89
|
+
err = result.stderr.strip()
|
|
90
|
+
if result.returncode != 0:
|
|
91
|
+
return f"Exit {result.returncode}\n{err or out or '(no output)'}"
|
|
92
|
+
return out or err or "(no output)"
|
|
93
|
+
except subprocess.TimeoutExpired:
|
|
94
|
+
return "Error: timed out after 30s"
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return f"Error: {e}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _list_dir(path="."):
|
|
100
|
+
p, err = _guard(path or ".")
|
|
101
|
+
if err:
|
|
102
|
+
return err
|
|
103
|
+
try:
|
|
104
|
+
entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name))
|
|
105
|
+
return "\n".join(f"{'[dir] ' if e.is_dir() else ' '}{e.name}" for e in entries)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return f"Error: {e}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _call_tool(name, args, approve):
|
|
111
|
+
if name == "read_file":
|
|
112
|
+
return _read_file(args.get("path", ""))
|
|
113
|
+
if name == "write_file":
|
|
114
|
+
return _write_file(args.get("path", ""), args.get("content", ""))
|
|
115
|
+
if name == "run_shell":
|
|
116
|
+
return _run_shell(args.get("command", ""), approve)
|
|
117
|
+
if name == "list_dir":
|
|
118
|
+
return _list_dir(args.get("path", "."))
|
|
119
|
+
return f"Error: unknown tool '{name}'"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _check_budget(used, budget):
|
|
123
|
+
if budget is None:
|
|
124
|
+
return False
|
|
125
|
+
limit = budget.get("max_tokens_per_run")
|
|
126
|
+
warn_at = budget.get("warn_at")
|
|
127
|
+
if warn_at and used >= warn_at and (not limit or used < limit):
|
|
128
|
+
print(f" [budget] {used}/{limit or '?'} tokens used — approaching limit")
|
|
129
|
+
if limit is not None and used >= limit:
|
|
130
|
+
print(f"\nAgent: budget reached ({used}/{limit} tokens). Stopping.")
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_openai(task, config, plan_only, approve):
|
|
136
|
+
from providers.utils import openai_client
|
|
137
|
+
provider = config["provider"]
|
|
138
|
+
model = config["model"]
|
|
139
|
+
budget = config.get("budget")
|
|
140
|
+
tools = _active_tools(config)
|
|
141
|
+
client = openai_client(provider)
|
|
142
|
+
system = (
|
|
143
|
+
"Output a numbered step-by-step plan only. Do not call any tools. Be concise."
|
|
144
|
+
if plan_only else
|
|
145
|
+
"You are a coding agent. Complete the task using tools. Summarize when done."
|
|
146
|
+
)
|
|
147
|
+
messages = [{"role": "system", "content": system}, {"role": "user", "content": task}]
|
|
148
|
+
tokens_used = 0
|
|
149
|
+
for _ in range(MAX_STEPS):
|
|
150
|
+
kwargs = {"model": model, "messages": messages}
|
|
151
|
+
if not plan_only and tools:
|
|
152
|
+
kwargs["tools"] = tools
|
|
153
|
+
response = client.chat.completions.create(**kwargs)
|
|
154
|
+
tokens_used += getattr(response.usage, "total_tokens", 0)
|
|
155
|
+
msg = response.choices[0].message
|
|
156
|
+
assistant_msg = {"role": "assistant", "content": msg.content}
|
|
157
|
+
if msg.tool_calls:
|
|
158
|
+
assistant_msg["tool_calls"] = [
|
|
159
|
+
{"id": c.id, "type": "function",
|
|
160
|
+
"function": {"name": c.function.name, "arguments": c.function.arguments}}
|
|
161
|
+
for c in msg.tool_calls
|
|
162
|
+
]
|
|
163
|
+
messages.append(assistant_msg)
|
|
164
|
+
if _check_budget(tokens_used, budget):
|
|
165
|
+
return
|
|
166
|
+
if not getattr(msg, "tool_calls", None):
|
|
167
|
+
if msg.content:
|
|
168
|
+
print(f"\nAgent: {msg.content}")
|
|
169
|
+
return
|
|
170
|
+
for call in msg.tool_calls:
|
|
171
|
+
try:
|
|
172
|
+
args = json.loads(call.function.arguments)
|
|
173
|
+
except Exception:
|
|
174
|
+
args = {}
|
|
175
|
+
print(f" -> {call.function.name}({args})")
|
|
176
|
+
try:
|
|
177
|
+
result = _call_tool(call.function.name, args, approve)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
result = f"Error: {e}"
|
|
180
|
+
print(f" {str(result)[:200]}")
|
|
181
|
+
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
|
|
182
|
+
print("Agent: reached max steps.")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_anthropic(task, config, plan_only, approve):
|
|
186
|
+
import anthropic
|
|
187
|
+
key = os.getenv("ANTHROPIC_API_KEY")
|
|
188
|
+
if not key:
|
|
189
|
+
raise RuntimeError("API key not set for 'anthropic'. Add ANTHROPIC_API_KEY to your .env file.")
|
|
190
|
+
model = config["model"]
|
|
191
|
+
budget = config.get("budget")
|
|
192
|
+
tools = _active_tools(config)
|
|
193
|
+
client = anthropic.Anthropic(api_key=key)
|
|
194
|
+
system = (
|
|
195
|
+
"Output a numbered step-by-step plan only. Do not call any tools. Be concise."
|
|
196
|
+
if plan_only else
|
|
197
|
+
"You are a coding agent. Complete the task using tools. Summarize when done."
|
|
198
|
+
)
|
|
199
|
+
anthropic_tools = [
|
|
200
|
+
{"name": t["function"]["name"],
|
|
201
|
+
"description": t["function"]["description"],
|
|
202
|
+
"input_schema": t["function"]["parameters"]}
|
|
203
|
+
for t in tools
|
|
204
|
+
]
|
|
205
|
+
messages = [{"role": "user", "content": task}]
|
|
206
|
+
tokens_used = 0
|
|
207
|
+
for _ in range(MAX_STEPS):
|
|
208
|
+
kwargs = {"model": model, "max_tokens": 4096, "system": system, "messages": messages}
|
|
209
|
+
if not plan_only and anthropic_tools:
|
|
210
|
+
kwargs["tools"] = anthropic_tools
|
|
211
|
+
response = client.messages.create(**kwargs)
|
|
212
|
+
tokens_used += (getattr(response.usage, "input_tokens", 0)
|
|
213
|
+
+ getattr(response.usage, "output_tokens", 0))
|
|
214
|
+
if _check_budget(tokens_used, budget):
|
|
215
|
+
return
|
|
216
|
+
tool_results = []
|
|
217
|
+
for block in response.content:
|
|
218
|
+
if block.type == "tool_use":
|
|
219
|
+
print(f" -> {block.name}({block.input})")
|
|
220
|
+
result = _call_tool(block.name, block.input, approve)
|
|
221
|
+
print(f" {str(result)[:200]}")
|
|
222
|
+
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
|
|
223
|
+
elif block.type == "text" and block.text:
|
|
224
|
+
if response.stop_reason != "tool_use":
|
|
225
|
+
print(f"\nAgent: {block.text}")
|
|
226
|
+
if response.stop_reason == "end_turn" or not tool_results:
|
|
227
|
+
return
|
|
228
|
+
messages += [
|
|
229
|
+
{"role": "assistant", "content": response.content},
|
|
230
|
+
{"role": "user", "content": tool_results},
|
|
231
|
+
]
|
|
232
|
+
print("Agent: reached max steps.")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main():
|
|
236
|
+
argv = sys.argv[1:]
|
|
237
|
+
if argv and argv[0] == "agent":
|
|
238
|
+
argv = argv[1:]
|
|
239
|
+
plan_only = "--plan" in argv
|
|
240
|
+
approve = "--approve" in argv
|
|
241
|
+
task = " ".join(a for a in argv if not a.startswith("--")).strip()
|
|
242
|
+
|
|
243
|
+
if not task:
|
|
244
|
+
print("Usage: llmkit agent [--plan] [--approve] \"task\"")
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
|
|
247
|
+
with open(Path.cwd() / "llm.yaml") as f:
|
|
248
|
+
config = yaml.safe_load(f)
|
|
249
|
+
|
|
250
|
+
provider = config.get("provider", "")
|
|
251
|
+
|
|
252
|
+
if provider == "local":
|
|
253
|
+
print("Agent requires function calling. Use openai/groq/anthropic/deepseek/together/mistral in llm.yaml.")
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
|
|
256
|
+
mode_label = "plan" if plan_only else ("approve-agent" if approve else "agent")
|
|
257
|
+
print(f"llmkit {mode_label} | {provider} / {config['model']}")
|
|
258
|
+
budget = config.get("budget") or {}
|
|
259
|
+
if budget.get("max_tokens_per_run"):
|
|
260
|
+
parts = [f"max {budget['max_tokens_per_run']} tokens"]
|
|
261
|
+
if budget.get("warn_at"):
|
|
262
|
+
parts.append(f"warn at {budget['warn_at']}")
|
|
263
|
+
print(f"Budget: {', '.join(parts)}")
|
|
264
|
+
active = _active_tools(config)
|
|
265
|
+
unknown = set(config.get("tools") or []) - ALL_TOOL_NAMES
|
|
266
|
+
if unknown:
|
|
267
|
+
print(f"Warning: unknown tools in llm.yaml: {sorted(unknown)}")
|
|
268
|
+
if len(active) < len(TOOLS_SCHEMA):
|
|
269
|
+
print(f"Tools: {[t['function']['name'] for t in active]}")
|
|
270
|
+
print(f"Workspace: {WORKSPACE}\n")
|
|
271
|
+
|
|
272
|
+
def _run(cfg):
|
|
273
|
+
if cfg["provider"] == "anthropic":
|
|
274
|
+
_run_anthropic(task, cfg, plan_only, approve)
|
|
275
|
+
else:
|
|
276
|
+
_run_openai(task, cfg, plan_only, approve)
|
|
277
|
+
|
|
278
|
+
fallback_cfgs = [
|
|
279
|
+
{**config, "provider": fb["provider"], "model": fb["model"]}
|
|
280
|
+
for fb in config.get("fallback", [])
|
|
281
|
+
if fb.get("provider") and fb.get("model")
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
_run(config)
|
|
286
|
+
except Exception as primary_err:
|
|
287
|
+
if not fallback_cfgs:
|
|
288
|
+
raise
|
|
289
|
+
print(f" Primary provider failed: {primary_err}")
|
|
290
|
+
last_err = primary_err
|
|
291
|
+
for fb in fallback_cfgs:
|
|
292
|
+
print(f" Trying fallback: {fb['provider']} / {fb['model']}")
|
|
293
|
+
try:
|
|
294
|
+
_run(fb)
|
|
295
|
+
last_err = None
|
|
296
|
+
break
|
|
297
|
+
except Exception as err:
|
|
298
|
+
last_err = err
|
|
299
|
+
continue
|
|
300
|
+
if last_err:
|
|
301
|
+
raise last_err
|