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.
@@ -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