llmkit-cli 0.1.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.
- agent_cmd.py +301 -0
- check.py +74 -0
- cli.py +98 -0
- env.py +65 -0
- git.py +2 -0
- git_cmds.py +140 -0
- init.py +63 -0
- llmkit_cli-0.1.0.dist-info/METADATA +193 -0
- llmkit_cli-0.1.0.dist-info/RECORD +22 -0
- llmkit_cli-0.1.0.dist-info/WHEEL +5 -0
- llmkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- llmkit_cli-0.1.0.dist-info/top_level.txt +9 -0
- lock.py +43 -0
- providers/__init__.py +0 -0
- providers/anthropic.py +14 -0
- providers/deepseek.py +13 -0
- providers/groq.py +13 -0
- providers/local.py +20 -0
- providers/mistral.py +9 -0
- providers/openai.py +13 -0
- providers/together.py +13 -0
- providers/utils.py +28 -0
agent_cmd.py
ADDED
|
@@ -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
|
check.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
load_dotenv()
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).parent
|
|
10
|
+
sys.path.insert(0, str(ROOT))
|
|
11
|
+
|
|
12
|
+
VALID_PROVIDERS = {"local", "openai", "anthropic", "groq", "together", "deepseek", "mistral"}
|
|
13
|
+
|
|
14
|
+
API_KEY_ENV = {
|
|
15
|
+
"openai": "OPENAI_API_KEY",
|
|
16
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
17
|
+
"groq": "GROQ_API_KEY",
|
|
18
|
+
"together": "TOGETHER_API_KEY",
|
|
19
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
20
|
+
"mistral": "MISTRAL_API_KEY",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_config():
|
|
25
|
+
path = Path.cwd() / "llm.yaml"
|
|
26
|
+
if not path.exists():
|
|
27
|
+
print("FAIL llm.yaml not found — run: llmkit init")
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
try:
|
|
30
|
+
with open(path) as f:
|
|
31
|
+
return yaml.safe_load(f)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"FAIL llm.yaml parse error: {e}")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_key(provider):
|
|
38
|
+
if provider == "local":
|
|
39
|
+
import requests
|
|
40
|
+
try:
|
|
41
|
+
res = requests.get("http://localhost:11434/api/tags", timeout=3)
|
|
42
|
+
if res.status_code != 200:
|
|
43
|
+
print(f"FAIL Port 11434 responded but not Ollama (status {res.status_code})")
|
|
44
|
+
return False
|
|
45
|
+
print("OK Ollama reachable at localhost:11434")
|
|
46
|
+
return True
|
|
47
|
+
except Exception:
|
|
48
|
+
print("FAIL Ollama not reachable — run: ollama serve")
|
|
49
|
+
return False
|
|
50
|
+
env_var = API_KEY_ENV.get(provider, "")
|
|
51
|
+
key = os.getenv(env_var) if env_var else None
|
|
52
|
+
if not key:
|
|
53
|
+
print(f"FAIL {env_var} not set — add it to your .env file")
|
|
54
|
+
return False
|
|
55
|
+
print(f"OK {env_var} set ({len(key)} chars)")
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main():
|
|
60
|
+
config = _load_config()
|
|
61
|
+
provider = config.get("provider")
|
|
62
|
+
model = config.get("model")
|
|
63
|
+
if not provider or not model:
|
|
64
|
+
print("FAIL llm.yaml missing required field (provider or model)")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
if provider not in VALID_PROVIDERS:
|
|
67
|
+
print(f"FAIL unknown provider '{provider}'")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
print(f" provider : {provider}")
|
|
70
|
+
print(f" model : {model}")
|
|
71
|
+
print(f" mode : {config.get('mode', 'chat')}")
|
|
72
|
+
if not _check_key(provider):
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
print("OK all checks passed")
|
cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import importlib
|
|
4
|
+
import yaml
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).parent
|
|
11
|
+
sys.path.insert(0, str(ROOT))
|
|
12
|
+
|
|
13
|
+
GIT_COMMANDS = {"commit", "pr", "review"}
|
|
14
|
+
PHASE_A_CMDS = {"check", "lock", "init", "env"}
|
|
15
|
+
KNOWN_COMMANDS = GIT_COMMANDS | PHASE_A_CMDS | {"agent", "run"}
|
|
16
|
+
|
|
17
|
+
providers_map = {
|
|
18
|
+
"local": "providers.local",
|
|
19
|
+
"openai": "providers.openai",
|
|
20
|
+
"anthropic": "providers.anthropic",
|
|
21
|
+
"groq": "providers.groq",
|
|
22
|
+
"together": "providers.together",
|
|
23
|
+
"deepseek": "providers.deepseek",
|
|
24
|
+
"mistral": "providers.mistral",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _usage():
|
|
29
|
+
print("Usage: llmkit run \"your question here\"")
|
|
30
|
+
print(" llmkit commit|pr|review|check|lock|init|env|agent [--plan|--approve]")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main():
|
|
34
|
+
if len(sys.argv) < 2:
|
|
35
|
+
_usage()
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
cmd = sys.argv[1]
|
|
39
|
+
|
|
40
|
+
if cmd not in KNOWN_COMMANDS:
|
|
41
|
+
print(f"Unknown command: {cmd}")
|
|
42
|
+
_usage()
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
if cmd in GIT_COMMANDS:
|
|
46
|
+
import git_cmds
|
|
47
|
+
ok = getattr(git_cmds, cmd)()
|
|
48
|
+
sys.exit(0 if ok is not False else 1)
|
|
49
|
+
|
|
50
|
+
if cmd in PHASE_A_CMDS:
|
|
51
|
+
importlib.import_module(cmd).main()
|
|
52
|
+
sys.exit(0)
|
|
53
|
+
|
|
54
|
+
if cmd == "agent":
|
|
55
|
+
import agent_cmd
|
|
56
|
+
agent_cmd.main()
|
|
57
|
+
sys.exit(0)
|
|
58
|
+
|
|
59
|
+
if cmd == "run":
|
|
60
|
+
if len(sys.argv) < 3:
|
|
61
|
+
print("Usage: llmkit run \"your question here\"")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
sys.argv = [sys.argv[0]] + sys.argv[2:]
|
|
64
|
+
|
|
65
|
+
prompt = " ".join(sys.argv[1:])
|
|
66
|
+
|
|
67
|
+
with open(Path.cwd() / "llm.yaml") as f:
|
|
68
|
+
config = yaml.safe_load(f)
|
|
69
|
+
|
|
70
|
+
provider = config["provider"]
|
|
71
|
+
model = config["model"]
|
|
72
|
+
|
|
73
|
+
if provider not in providers_map:
|
|
74
|
+
print(f"Unknown provider: {provider}")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
send_message = importlib.import_module(providers_map[provider]).send_message
|
|
78
|
+
try:
|
|
79
|
+
print(send_message(prompt, model))
|
|
80
|
+
except Exception as primary_err:
|
|
81
|
+
for fb in config.get("fallback", []):
|
|
82
|
+
fb_provider = fb.get("provider", "")
|
|
83
|
+
fb_model = fb.get("model", "")
|
|
84
|
+
if not fb_provider or fb_provider not in providers_map:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
fb_send = importlib.import_module(providers_map[fb_provider]).send_message
|
|
88
|
+
print(fb_send(prompt, fb_model))
|
|
89
|
+
raise SystemExit(0)
|
|
90
|
+
except SystemExit:
|
|
91
|
+
raise
|
|
92
|
+
except Exception:
|
|
93
|
+
continue
|
|
94
|
+
raise primary_err
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
env.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
ROOT = Path(__file__).parent
|
|
6
|
+
|
|
7
|
+
PROVIDERS = {
|
|
8
|
+
"openai": "OPENAI_API_KEY",
|
|
9
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
10
|
+
"groq": "GROQ_API_KEY",
|
|
11
|
+
"together": "TOGETHER_API_KEY",
|
|
12
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
13
|
+
"mistral": "MISTRAL_API_KEY",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_env():
|
|
18
|
+
env_path = Path.cwd() / ".env"
|
|
19
|
+
if not env_path.exists():
|
|
20
|
+
return {}
|
|
21
|
+
lines = env_path.read_text().splitlines()
|
|
22
|
+
result = {}
|
|
23
|
+
for line in lines:
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if line and not line.startswith("#") and "=" in line:
|
|
26
|
+
k, _, v = line.partition("=")
|
|
27
|
+
result[k.strip()] = v.strip()
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_env(data):
|
|
32
|
+
env_path = Path.cwd() / ".env"
|
|
33
|
+
lines = ["# llmkit environment — do not commit this file"]
|
|
34
|
+
for k, v in sorted(data.items()):
|
|
35
|
+
lines.append(f"{k}={v}")
|
|
36
|
+
env_path.write_text("\n".join(lines) + "\n")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main():
|
|
40
|
+
existing = _read_env()
|
|
41
|
+
print("llmkit env — set API keys for your providers")
|
|
42
|
+
print("Press Enter to keep existing value, '-' to clear.\n")
|
|
43
|
+
|
|
44
|
+
updated = dict(existing)
|
|
45
|
+
changed = False
|
|
46
|
+
|
|
47
|
+
for provider, var in PROVIDERS.items():
|
|
48
|
+
current = existing.get(var, "")
|
|
49
|
+
display = f"{current[:8]}..." if len(current) > 8 else (current or "not set")
|
|
50
|
+
val = input(f" {var} [{display}]: ").strip()
|
|
51
|
+
if val == "-":
|
|
52
|
+
updated.pop(var, None)
|
|
53
|
+
changed = True
|
|
54
|
+
elif val:
|
|
55
|
+
updated[var] = val
|
|
56
|
+
changed = True
|
|
57
|
+
|
|
58
|
+
if not changed:
|
|
59
|
+
print("\nNo changes.")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
_write_env(updated)
|
|
63
|
+
set_keys = [k for k in PROVIDERS.values() if updated.get(k)]
|
|
64
|
+
print(f"\nWritten .env ({len(set_keys)} keys set)")
|
|
65
|
+
print("Make sure .env is in your .gitignore.")
|
git.py
ADDED
git_cmds.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
import importlib
|
|
5
|
+
import yaml
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).parent
|
|
12
|
+
sys.path.insert(0, str(ROOT))
|
|
13
|
+
|
|
14
|
+
MAX_DIFF_CHARS = 12000
|
|
15
|
+
|
|
16
|
+
PROVIDERS_MAP = {
|
|
17
|
+
"local": "providers.local",
|
|
18
|
+
"openai": "providers.openai",
|
|
19
|
+
"anthropic": "providers.anthropic",
|
|
20
|
+
"groq": "providers.groq",
|
|
21
|
+
"together": "providers.together",
|
|
22
|
+
"deepseek": "providers.deepseek",
|
|
23
|
+
"mistral": "providers.mistral",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _config():
|
|
28
|
+
with open(Path.cwd() / "llm.yaml") as f:
|
|
29
|
+
return yaml.safe_load(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _send(prompt):
|
|
33
|
+
config = _config()
|
|
34
|
+
provider = config["provider"]
|
|
35
|
+
model = config["model"]
|
|
36
|
+
if provider not in PROVIDERS_MAP:
|
|
37
|
+
print(f"Unknown provider: {provider}")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
send_message = importlib.import_module(PROVIDERS_MAP[provider]).send_message
|
|
40
|
+
try:
|
|
41
|
+
return send_message(prompt, model)
|
|
42
|
+
except Exception as primary_err:
|
|
43
|
+
for fb in config.get("fallback", []):
|
|
44
|
+
fb_provider = fb.get("provider", "")
|
|
45
|
+
fb_model = fb.get("model", "")
|
|
46
|
+
if not fb_provider or fb_provider not in PROVIDERS_MAP:
|
|
47
|
+
continue
|
|
48
|
+
print(f" Fallback: {fb_provider} / {fb_model}")
|
|
49
|
+
try:
|
|
50
|
+
fb_send = importlib.import_module(PROVIDERS_MAP[fb_provider]).send_message
|
|
51
|
+
return fb_send(prompt, fb_model)
|
|
52
|
+
except Exception:
|
|
53
|
+
continue
|
|
54
|
+
raise primary_err
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _diff(cached=True):
|
|
58
|
+
args = ["git", "diff"] + (["--cached"] if cached else [])
|
|
59
|
+
result = subprocess.run(args, capture_output=True, text=True)
|
|
60
|
+
if result.returncode != 0:
|
|
61
|
+
err = result.stderr.strip() or f"git diff exited with code {result.returncode}"
|
|
62
|
+
return None, err
|
|
63
|
+
return result.stdout.strip(), None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _branch_diff():
|
|
67
|
+
# Try to detect default branch from origin/HEAD
|
|
68
|
+
sym = subprocess.run(
|
|
69
|
+
["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
70
|
+
capture_output=True, text=True
|
|
71
|
+
)
|
|
72
|
+
origin_base = sym.stdout.strip().split("/")[-1] if sym.returncode == 0 else None
|
|
73
|
+
|
|
74
|
+
bases = list(dict.fromkeys(filter(None, [origin_base, "main", "master"])))
|
|
75
|
+
result = None
|
|
76
|
+
for base in bases:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
["git", "diff", f"{base}...HEAD"],
|
|
79
|
+
capture_output=True, text=True
|
|
80
|
+
)
|
|
81
|
+
if result.returncode == 0:
|
|
82
|
+
return result.stdout.strip(), None
|
|
83
|
+
if result is None:
|
|
84
|
+
return None, "no base branch to diff against"
|
|
85
|
+
last_err = result.stderr.strip() if result.returncode != 0 else None
|
|
86
|
+
return None, last_err
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def commit():
|
|
90
|
+
diff, err = _diff(cached=True)
|
|
91
|
+
if err:
|
|
92
|
+
print(f"git error: {err}")
|
|
93
|
+
return False
|
|
94
|
+
if not diff:
|
|
95
|
+
print("Nothing staged. Run: git add <files>")
|
|
96
|
+
return False
|
|
97
|
+
prompt = (
|
|
98
|
+
"Write a concise git commit message for this diff. "
|
|
99
|
+
"Output only the message, no explanation.\n\n"
|
|
100
|
+
+ diff[:MAX_DIFF_CHARS]
|
|
101
|
+
)
|
|
102
|
+
print(_send(prompt))
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def pr():
|
|
107
|
+
diff, err = _branch_diff()
|
|
108
|
+
if err:
|
|
109
|
+
print(f"git error: {err}")
|
|
110
|
+
return False
|
|
111
|
+
if not diff:
|
|
112
|
+
print("No diff found vs default branch.")
|
|
113
|
+
return False
|
|
114
|
+
prompt = (
|
|
115
|
+
"Write a GitHub pull request title and body for this diff. Be concise.\n\n"
|
|
116
|
+
+ diff[:MAX_DIFF_CHARS]
|
|
117
|
+
)
|
|
118
|
+
print(_send(prompt))
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def review():
|
|
123
|
+
diff, err = _diff(cached=True)
|
|
124
|
+
if err:
|
|
125
|
+
print(f"git error: {err}")
|
|
126
|
+
return False
|
|
127
|
+
if not diff:
|
|
128
|
+
diff, err = _diff(cached=False)
|
|
129
|
+
if err:
|
|
130
|
+
print(f"git error: {err}")
|
|
131
|
+
return False
|
|
132
|
+
if not diff:
|
|
133
|
+
print("No changes to review.")
|
|
134
|
+
return False
|
|
135
|
+
prompt = (
|
|
136
|
+
"Review this code diff. Flag bugs, security issues, and improvements. Be concise.\n\n"
|
|
137
|
+
+ diff[:MAX_DIFF_CHARS]
|
|
138
|
+
)
|
|
139
|
+
print(_send(prompt))
|
|
140
|
+
return True
|
init.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import yaml
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
ROOT = Path(__file__).parent
|
|
6
|
+
sys.path.insert(0, str(ROOT))
|
|
7
|
+
|
|
8
|
+
PROVIDERS = ["local", "openai", "anthropic", "groq", "together", "deepseek", "mistral"]
|
|
9
|
+
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
"local": "llama3.2",
|
|
12
|
+
"openai": "gpt-4o-mini",
|
|
13
|
+
"anthropic": "claude-3-5-haiku-20241022",
|
|
14
|
+
"groq": "llama-3.3-70b-versatile",
|
|
15
|
+
"together": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
16
|
+
"deepseek": "deepseek-chat",
|
|
17
|
+
"mistral": "mistral-small-latest",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
API_KEY_ENV = {
|
|
21
|
+
"openai": "OPENAI_API_KEY",
|
|
22
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
23
|
+
"groq": "GROQ_API_KEY",
|
|
24
|
+
"together": "TOGETHER_API_KEY",
|
|
25
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
26
|
+
"mistral": "MISTRAL_API_KEY",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ask(label, default):
|
|
31
|
+
val = input(f" {label} [{default}]: ").strip()
|
|
32
|
+
return val if val else default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_config(provider, model, mode):
|
|
36
|
+
config = {"provider": provider, "model": model}
|
|
37
|
+
if mode != "chat":
|
|
38
|
+
config["mode"] = mode
|
|
39
|
+
with open(Path.cwd() / "llm.yaml", "w") as f:
|
|
40
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
41
|
+
print(f"\nCreated llm.yaml ({provider} / {model} mode={mode})")
|
|
42
|
+
env_var = API_KEY_ENV.get(provider, "")
|
|
43
|
+
if env_var:
|
|
44
|
+
print(f"Add {env_var} to your .env file before running.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
config_path = Path.cwd() / "llm.yaml"
|
|
49
|
+
if config_path.exists():
|
|
50
|
+
if input("llm.yaml already exists. Overwrite? [y/N]: ").strip().lower() != "y":
|
|
51
|
+
print("Aborted.")
|
|
52
|
+
return
|
|
53
|
+
print(f"Providers: {', '.join(PROVIDERS)}")
|
|
54
|
+
provider = _ask("provider", "groq")
|
|
55
|
+
if provider not in PROVIDERS:
|
|
56
|
+
print(f"Unknown provider '{provider}'. Choose from: {', '.join(PROVIDERS)}")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
model = _ask("model", DEFAULTS[provider])
|
|
59
|
+
mode = _ask("mode (chat / coding / autonomous)", "chat")
|
|
60
|
+
if mode not in ("chat", "coding", "autonomous"):
|
|
61
|
+
print(f"Unknown mode '{mode}'. Choose: chat, coding, autonomous")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
_write_config(provider, model, mode)
|
|
@@ -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,22 @@
|
|
|
1
|
+
agent_cmd.py,sha256=9aHCv4vwNq2momIciawNyZiFBAQbVRnEzMKn3Wn_8Ew,10679
|
|
2
|
+
check.py,sha256=Q43e1z6HKAvhwYV0JUmHgfOEOwjoDjG-rrrdeS1eXzk,2226
|
|
3
|
+
cli.py,sha256=NRigdwC3aseiE9MDNu5TNlyC3ul8FDOi6Hu5HLwrUBY,2543
|
|
4
|
+
env.py,sha256=eZLfIzrRKYmGsOk1oxErD5MBm7Hf2wCHmVgiazcDR1s,1799
|
|
5
|
+
git.py,sha256=5JwhUKT6sYDkcCthUL3tvwVLILog6accr0Wi8MiBOUA,103
|
|
6
|
+
git_cmds.py,sha256=9u9DOojmmJ2-Ao6jXDlpMPJZYaLPvgTbVR2Gk6Zq2NQ,3969
|
|
7
|
+
init.py,sha256=ZU_Obczr7iF4FppzXh5AMRIx5NXB5m5FBpHe-l73tqI,2062
|
|
8
|
+
lock.py,sha256=7YBj_3y8pA2ZTEQydkG9m03Yx-wDXaGZC5CGX2S4Mb8,1434
|
|
9
|
+
providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
providers/anthropic.py,sha256=ybADRUQHLpW7u07PcMdGeEqvFdcY0jf59DC_w_VRr40,476
|
|
11
|
+
providers/deepseek.py,sha256=3GX8Aaj_wAkdK0HOP7PJmEeP3w1-kzRrHy6cvVTWJ0k,493
|
|
12
|
+
providers/groq.py,sha256=Tl80ZEOjbJ7p6zV3Wjre5VrGWhf7xfYeao6DjVHvo1g,442
|
|
13
|
+
providers/local.py,sha256=Fe0GpSTnpNUqKbL6zbm9RgpMCSfkMpQuQ7lT8CApD8M,792
|
|
14
|
+
providers/mistral.py,sha256=PO-PzwmbNx3yvsJLmFaQ746ycceB354BJ4c6mcLuIR0,313
|
|
15
|
+
providers/openai.py,sha256=N8i4g2sXXJ4WiAzlkcetTDNT0EbQ2ZpRHfE5QPTFAD4,445
|
|
16
|
+
providers/together.py,sha256=2u1njEat6fDqGqWRmbzZt5AoiXOGgjX2RctWtLcseEo,485
|
|
17
|
+
providers/utils.py,sha256=NCJSgRdSctKZsv3eY6YsbUPNYSnxWIUoAs7buThnpwc,872
|
|
18
|
+
llmkit_cli-0.1.0.dist-info/METADATA,sha256=ALvlMLKmiqTKc-igCHw-BrB0VGV_4G6Rs2csetB5fvU,4856
|
|
19
|
+
llmkit_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
llmkit_cli-0.1.0.dist-info/entry_points.txt,sha256=6UgaPStSt-qiZ5aL1xRi-xoxSSqOlzF8zFsSxnGSWhA,36
|
|
21
|
+
llmkit_cli-0.1.0.dist-info/top_level.txt,sha256=7lMT6GaGf6jeGLt5kzJ4-yaJfDTY3E6yylLV5JnJjgI,57
|
|
22
|
+
llmkit_cli-0.1.0.dist-info/RECORD,,
|
lock.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import yaml
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ROOT = Path(__file__).parent
|
|
7
|
+
LLMKIT_VERSION = "1.1.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_config():
|
|
11
|
+
path = Path.cwd() / "llm.yaml"
|
|
12
|
+
if not path.exists():
|
|
13
|
+
print("FAIL llm.yaml not found — run: llmkit init")
|
|
14
|
+
sys.exit(1)
|
|
15
|
+
try:
|
|
16
|
+
with open(path) as f:
|
|
17
|
+
config = yaml.safe_load(f)
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print(f"FAIL llm.yaml parse error: {e}")
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
if not config.get("provider") or not config.get("model"):
|
|
22
|
+
print("FAIL llm.yaml missing provider or model")
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
return config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
config = _load_config()
|
|
29
|
+
data = {
|
|
30
|
+
"locked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
31
|
+
"provider": config["provider"],
|
|
32
|
+
"model": config["model"],
|
|
33
|
+
"mode": config.get("mode", "chat"),
|
|
34
|
+
"llmkit_version": LLMKIT_VERSION,
|
|
35
|
+
}
|
|
36
|
+
lock_path = Path.cwd() / "llm.lock"
|
|
37
|
+
with open(lock_path, "w") as f:
|
|
38
|
+
f.write("# Auto-generated by: llmkit lock\n")
|
|
39
|
+
f.write("# Commit this file to pin your model runtime.\n\n")
|
|
40
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
41
|
+
print(f"Locked {config['provider']} / {config['model']} ({data['mode']})")
|
|
42
|
+
print(f"Written {lock_path}")
|
|
43
|
+
print("Tip: commit llm.lock to pin your model runtime across the team.")
|
providers/__init__.py
ADDED
|
File without changes
|
providers/anthropic.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import anthropic
|
|
3
|
+
|
|
4
|
+
def send_message(prompt, model="claude-3-5-haiku-20241022"):
|
|
5
|
+
key = os.getenv("ANTHROPIC_API_KEY")
|
|
6
|
+
if not key:
|
|
7
|
+
raise RuntimeError("API key not set for 'anthropic'. Add ANTHROPIC_API_KEY to your .env file.")
|
|
8
|
+
client = anthropic.Anthropic(api_key=key)
|
|
9
|
+
message = client.messages.create(
|
|
10
|
+
model=model,
|
|
11
|
+
max_tokens=4096,
|
|
12
|
+
messages=[{"role": "user", "content": prompt}]
|
|
13
|
+
)
|
|
14
|
+
return message.content[0].text
|
providers/deepseek.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from openai import OpenAI
|
|
3
|
+
|
|
4
|
+
def send_message(prompt, model="deepseek-chat"):
|
|
5
|
+
key = os.getenv("DEEPSEEK_API_KEY")
|
|
6
|
+
if not key:
|
|
7
|
+
raise RuntimeError("API key not set for 'deepseek'. Add DEEPSEEK_API_KEY to your .env file.")
|
|
8
|
+
client = OpenAI(api_key=key, base_url="https://api.deepseek.com/v1")
|
|
9
|
+
response = client.chat.completions.create(
|
|
10
|
+
model=model,
|
|
11
|
+
messages=[{"role": "user", "content": prompt}]
|
|
12
|
+
)
|
|
13
|
+
return response.choices[0].message.content
|
providers/groq.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from groq import Groq
|
|
3
|
+
|
|
4
|
+
def send_message(prompt, model="llama-3.1-8b-instant"):
|
|
5
|
+
key = os.getenv("GROQ_API_KEY")
|
|
6
|
+
if not key:
|
|
7
|
+
raise RuntimeError("API key not set for 'groq'. Add GROQ_API_KEY to your .env file.")
|
|
8
|
+
client = Groq(api_key=key)
|
|
9
|
+
response = client.chat.completions.create(
|
|
10
|
+
model=model,
|
|
11
|
+
messages=[{"role": "user", "content": prompt}]
|
|
12
|
+
)
|
|
13
|
+
return response.choices[0].message.content
|
providers/local.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
def send_message(prompt, model="llama3.2"):
|
|
4
|
+
try:
|
|
5
|
+
response = requests.post(
|
|
6
|
+
"http://localhost:11434/api/generate",
|
|
7
|
+
json={"model": model, "prompt": prompt, "stream": False},
|
|
8
|
+
timeout=120
|
|
9
|
+
)
|
|
10
|
+
data = response.json()
|
|
11
|
+
if "error" in data:
|
|
12
|
+
raise RuntimeError(f"Ollama error: {data['error']}")
|
|
13
|
+
text = data.get("response")
|
|
14
|
+
if text is None:
|
|
15
|
+
raise RuntimeError(f"Unexpected Ollama response: {data}")
|
|
16
|
+
return text
|
|
17
|
+
except requests.exceptions.ConnectionError:
|
|
18
|
+
raise RuntimeError("Ollama is not running. Start it with: ollama serve")
|
|
19
|
+
except requests.exceptions.Timeout:
|
|
20
|
+
raise RuntimeError("Ollama timed out. Try a smaller model or increase timeout.")
|
providers/mistral.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from providers.utils import openai_client
|
|
2
|
+
|
|
3
|
+
def send_message(prompt, model="mistral-small-latest"):
|
|
4
|
+
client = openai_client("mistral")
|
|
5
|
+
response = client.chat.completions.create(
|
|
6
|
+
model=model,
|
|
7
|
+
messages=[{"role": "user", "content": prompt}]
|
|
8
|
+
)
|
|
9
|
+
return response.choices[0].message.content
|
providers/openai.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from openai import OpenAI
|
|
3
|
+
|
|
4
|
+
def send_message(prompt, model="gpt-4o-mini"):
|
|
5
|
+
key = os.getenv("OPENAI_API_KEY")
|
|
6
|
+
if not key:
|
|
7
|
+
raise RuntimeError("API key not set for 'openai'. Add OPENAI_API_KEY to your .env file.")
|
|
8
|
+
client = OpenAI(api_key=key)
|
|
9
|
+
response = client.chat.completions.create(
|
|
10
|
+
model=model,
|
|
11
|
+
messages=[{"role": "user", "content": prompt}]
|
|
12
|
+
)
|
|
13
|
+
return response.choices[0].message.content
|
providers/together.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from together import Together
|
|
3
|
+
|
|
4
|
+
def send_message(prompt, model="meta-llama/Llama-3.3-70B-Instruct-Turbo"):
|
|
5
|
+
key = os.getenv("TOGETHER_API_KEY")
|
|
6
|
+
if not key:
|
|
7
|
+
raise RuntimeError("API key not set for 'together'. Add TOGETHER_API_KEY to your .env file.")
|
|
8
|
+
client = Together(api_key=key)
|
|
9
|
+
response = client.chat.completions.create(
|
|
10
|
+
model=model,
|
|
11
|
+
messages=[{"role": "user", "content": prompt}]
|
|
12
|
+
)
|
|
13
|
+
return response.choices[0].message.content
|
providers/utils.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
BASE_URLS = {
|
|
4
|
+
"groq": "https://api.groq.com/openai/v1",
|
|
5
|
+
"deepseek": "https://api.deepseek.com/v1",
|
|
6
|
+
"together": "https://api.together.xyz/v1",
|
|
7
|
+
"mistral": "https://api.mistral.ai/v1",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
API_KEY_NAMES = {
|
|
11
|
+
"openai": "OPENAI_API_KEY",
|
|
12
|
+
"groq": "GROQ_API_KEY",
|
|
13
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
14
|
+
"together": "TOGETHER_API_KEY",
|
|
15
|
+
"mistral": "MISTRAL_API_KEY",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def openai_client(provider):
|
|
19
|
+
from openai import OpenAI
|
|
20
|
+
env_var = API_KEY_NAMES.get(provider, "")
|
|
21
|
+
key = os.getenv(env_var) if env_var else None
|
|
22
|
+
if not key:
|
|
23
|
+
hint = env_var if env_var else "unknown provider"
|
|
24
|
+
raise RuntimeError(f"API key not set for '{provider}'. Add {hint} to your .env file.")
|
|
25
|
+
kwargs = {"api_key": key}
|
|
26
|
+
if provider in BASE_URLS:
|
|
27
|
+
kwargs["base_url"] = BASE_URLS[provider]
|
|
28
|
+
return OpenAI(**kwargs)
|