agent-smart-router 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,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-smart-router
3
+ Version: 0.1.0
4
+ Summary: A lightweight CLI tool for delegating LLM tasks to expert models across multiple providers.
5
+ Author-email: Kerem Barbaros Karnabat <kbarbaros@hotmail.com>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: openai>=1.0.0
12
+ Requires-Dist: filelock>=3.12.0
13
+ Requires-Dist: anthropic>=0.30.0
14
+
15
+ # Smart Router (CLI Delegation Tool)
16
+
17
+ > A lightweight, fault-tolerant CLI tool for delegating LLM tasks to expert models across multiple providers (Nvidia NIM, Groq, OpenAI, Anthropic Claude, Gemini).
18
+
19
+ ![Python](https://img.shields.io/badge/Python-3.8%2B-blue)
20
+ ![License](https://img.shields.io/badge/License-MIT-green)
21
+ ![CI](https://github.com/cadakerem/smart-router/actions/workflows/ci.yml/badge.svg)
22
+
23
+ ## ⚡ Features
24
+
25
+ - **Multi-Provider Support:** seamlessly route requests to `nvidia`, `groq`, `openai`, `anthropic`, or `gemini`.
26
+ - **Automatic Fallbacks:** Provide a comma-separated list of models. If one fails, it instantly falls back to the next.
27
+ - **Circuit Breaker:** Built-in health tracking and cooldowns to prevent spamming dead endpoints.
28
+ - **Reasoning Extraction:** Automatically extracts and formats hidden `<thought>` or `reasoning` blocks (e.g., from Nemotron or DeepSeek).
29
+ - **Streaming Native:** Built on the official OpenAI SDK for fast and reliable streaming chunks.
30
+
31
+ ## 📦 Installation
32
+
33
+ ```bash
34
+ # Clone the repository
35
+ git clone https://github.com/cadakerem/smart-router.git
36
+ cd smart-router
37
+
38
+ # Install dependencies
39
+ pip install agent-smart-router
40
+ ```
41
+
42
+ ### Setup API Keys
43
+ The router checks for a `keys.json` file in the same directory, or falls back to system environment variables.
44
+
45
+ **Option 1: keys.json**
46
+ Create a `keys.json` file in the root directory:
47
+ ```json
48
+ {
49
+ "NVIDIA_API_KEY": "nvapi-...",
50
+ "GROQ_API_KEY": "gsk_...",
51
+ "OPENAI_API_KEY": "sk-...",
52
+ "ANTHROPIC_API_KEY": "sk-ant-...",
53
+ "GEMINI_API_KEY": "AIza..."
54
+ }
55
+ ```
56
+
57
+ **Option 2: Environment Variables**
58
+ ```bash
59
+ export OPENAI_API_KEY="sk-..."
60
+ export ANTHROPIC_API_KEY="sk-ant-..."
61
+ export GEMINI_API_KEY="AIza..."
62
+ export NVIDIA_API_KEY="nvapi-..."
63
+ export GROQ_API_KEY="gsk_..."
64
+ ```
65
+
66
+ ## 💻 Usage
67
+
68
+ ```bash
69
+ python smart_router.py -m "<provider:model1>,<provider:model2>" -p "<your prompt>"
70
+ ```
71
+
72
+ You can also read prompts from a file or via standard input:
73
+ ```bash
74
+ python smart_router.py -m "nvidia:nemotron,groq:llama3" -f prompt.txt
75
+ cat logs.txt | python smart_router.py -m "groq:llama3"
76
+ ```
77
+
78
+ ### Examples
79
+
80
+ **Heavy Coding Task (Nvidia Laguna -> Groq Fallback):**
81
+ ```bash
82
+ python smart_router.py -m "nvidia:poolside/laguna-xs-2.1,groq:groq/compound" -p "Write a python script to parse logs."
83
+ ```
84
+
85
+ **Custom Cooldown and Project ID:**
86
+ ```bash
87
+ python smart_router.py -m "groq:llama3" -p "Hello" --project "agent-core" --max-failures 3 --cooldown 300
88
+ ```
89
+
90
+ ## 🏗️ Architecture Overview
91
+
92
+ The router uses a `FileLock`-backed JSON state (`circuit_breaker.json`) to track failures across concurrent runs.
93
+ If an endpoint times out or returns a 5xx error more than `MAX_FAILURES` times, the circuit trips and forces the router to skip that endpoint for the next 120 seconds, immediately trying the next fallback model.
94
+
@@ -0,0 +1,80 @@
1
+ # Smart Router (CLI Delegation Tool)
2
+
3
+ > A lightweight, fault-tolerant CLI tool for delegating LLM tasks to expert models across multiple providers (Nvidia NIM, Groq, OpenAI, Anthropic Claude, Gemini).
4
+
5
+ ![Python](https://img.shields.io/badge/Python-3.8%2B-blue)
6
+ ![License](https://img.shields.io/badge/License-MIT-green)
7
+ ![CI](https://github.com/cadakerem/smart-router/actions/workflows/ci.yml/badge.svg)
8
+
9
+ ## ⚡ Features
10
+
11
+ - **Multi-Provider Support:** seamlessly route requests to `nvidia`, `groq`, `openai`, `anthropic`, or `gemini`.
12
+ - **Automatic Fallbacks:** Provide a comma-separated list of models. If one fails, it instantly falls back to the next.
13
+ - **Circuit Breaker:** Built-in health tracking and cooldowns to prevent spamming dead endpoints.
14
+ - **Reasoning Extraction:** Automatically extracts and formats hidden `<thought>` or `reasoning` blocks (e.g., from Nemotron or DeepSeek).
15
+ - **Streaming Native:** Built on the official OpenAI SDK for fast and reliable streaming chunks.
16
+
17
+ ## 📦 Installation
18
+
19
+ ```bash
20
+ # Clone the repository
21
+ git clone https://github.com/cadakerem/smart-router.git
22
+ cd smart-router
23
+
24
+ # Install dependencies
25
+ pip install agent-smart-router
26
+ ```
27
+
28
+ ### Setup API Keys
29
+ The router checks for a `keys.json` file in the same directory, or falls back to system environment variables.
30
+
31
+ **Option 1: keys.json**
32
+ Create a `keys.json` file in the root directory:
33
+ ```json
34
+ {
35
+ "NVIDIA_API_KEY": "nvapi-...",
36
+ "GROQ_API_KEY": "gsk_...",
37
+ "OPENAI_API_KEY": "sk-...",
38
+ "ANTHROPIC_API_KEY": "sk-ant-...",
39
+ "GEMINI_API_KEY": "AIza..."
40
+ }
41
+ ```
42
+
43
+ **Option 2: Environment Variables**
44
+ ```bash
45
+ export OPENAI_API_KEY="sk-..."
46
+ export ANTHROPIC_API_KEY="sk-ant-..."
47
+ export GEMINI_API_KEY="AIza..."
48
+ export NVIDIA_API_KEY="nvapi-..."
49
+ export GROQ_API_KEY="gsk_..."
50
+ ```
51
+
52
+ ## 💻 Usage
53
+
54
+ ```bash
55
+ python smart_router.py -m "<provider:model1>,<provider:model2>" -p "<your prompt>"
56
+ ```
57
+
58
+ You can also read prompts from a file or via standard input:
59
+ ```bash
60
+ python smart_router.py -m "nvidia:nemotron,groq:llama3" -f prompt.txt
61
+ cat logs.txt | python smart_router.py -m "groq:llama3"
62
+ ```
63
+
64
+ ### Examples
65
+
66
+ **Heavy Coding Task (Nvidia Laguna -> Groq Fallback):**
67
+ ```bash
68
+ python smart_router.py -m "nvidia:poolside/laguna-xs-2.1,groq:groq/compound" -p "Write a python script to parse logs."
69
+ ```
70
+
71
+ **Custom Cooldown and Project ID:**
72
+ ```bash
73
+ python smart_router.py -m "groq:llama3" -p "Hello" --project "agent-core" --max-failures 3 --cooldown 300
74
+ ```
75
+
76
+ ## 🏗️ Architecture Overview
77
+
78
+ The router uses a `FileLock`-backed JSON state (`circuit_breaker.json`) to track failures across concurrent runs.
79
+ If an endpoint times out or returns a 5xx error more than `MAX_FAILURES` times, the circuit trips and forces the router to skip that endpoint for the next 120 seconds, immediately trying the next fallback model.
80
+
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-smart-router
3
+ Version: 0.1.0
4
+ Summary: A lightweight CLI tool for delegating LLM tasks to expert models across multiple providers.
5
+ Author-email: Kerem Barbaros Karnabat <kbarbaros@hotmail.com>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: openai>=1.0.0
12
+ Requires-Dist: filelock>=3.12.0
13
+ Requires-Dist: anthropic>=0.30.0
14
+
15
+ # Smart Router (CLI Delegation Tool)
16
+
17
+ > A lightweight, fault-tolerant CLI tool for delegating LLM tasks to expert models across multiple providers (Nvidia NIM, Groq, OpenAI, Anthropic Claude, Gemini).
18
+
19
+ ![Python](https://img.shields.io/badge/Python-3.8%2B-blue)
20
+ ![License](https://img.shields.io/badge/License-MIT-green)
21
+ ![CI](https://github.com/cadakerem/smart-router/actions/workflows/ci.yml/badge.svg)
22
+
23
+ ## ⚡ Features
24
+
25
+ - **Multi-Provider Support:** seamlessly route requests to `nvidia`, `groq`, `openai`, `anthropic`, or `gemini`.
26
+ - **Automatic Fallbacks:** Provide a comma-separated list of models. If one fails, it instantly falls back to the next.
27
+ - **Circuit Breaker:** Built-in health tracking and cooldowns to prevent spamming dead endpoints.
28
+ - **Reasoning Extraction:** Automatically extracts and formats hidden `<thought>` or `reasoning` blocks (e.g., from Nemotron or DeepSeek).
29
+ - **Streaming Native:** Built on the official OpenAI SDK for fast and reliable streaming chunks.
30
+
31
+ ## 📦 Installation
32
+
33
+ ```bash
34
+ # Clone the repository
35
+ git clone https://github.com/cadakerem/smart-router.git
36
+ cd smart-router
37
+
38
+ # Install dependencies
39
+ pip install agent-smart-router
40
+ ```
41
+
42
+ ### Setup API Keys
43
+ The router checks for a `keys.json` file in the same directory, or falls back to system environment variables.
44
+
45
+ **Option 1: keys.json**
46
+ Create a `keys.json` file in the root directory:
47
+ ```json
48
+ {
49
+ "NVIDIA_API_KEY": "nvapi-...",
50
+ "GROQ_API_KEY": "gsk_...",
51
+ "OPENAI_API_KEY": "sk-...",
52
+ "ANTHROPIC_API_KEY": "sk-ant-...",
53
+ "GEMINI_API_KEY": "AIza..."
54
+ }
55
+ ```
56
+
57
+ **Option 2: Environment Variables**
58
+ ```bash
59
+ export OPENAI_API_KEY="sk-..."
60
+ export ANTHROPIC_API_KEY="sk-ant-..."
61
+ export GEMINI_API_KEY="AIza..."
62
+ export NVIDIA_API_KEY="nvapi-..."
63
+ export GROQ_API_KEY="gsk_..."
64
+ ```
65
+
66
+ ## 💻 Usage
67
+
68
+ ```bash
69
+ python smart_router.py -m "<provider:model1>,<provider:model2>" -p "<your prompt>"
70
+ ```
71
+
72
+ You can also read prompts from a file or via standard input:
73
+ ```bash
74
+ python smart_router.py -m "nvidia:nemotron,groq:llama3" -f prompt.txt
75
+ cat logs.txt | python smart_router.py -m "groq:llama3"
76
+ ```
77
+
78
+ ### Examples
79
+
80
+ **Heavy Coding Task (Nvidia Laguna -> Groq Fallback):**
81
+ ```bash
82
+ python smart_router.py -m "nvidia:poolside/laguna-xs-2.1,groq:groq/compound" -p "Write a python script to parse logs."
83
+ ```
84
+
85
+ **Custom Cooldown and Project ID:**
86
+ ```bash
87
+ python smart_router.py -m "groq:llama3" -p "Hello" --project "agent-core" --max-failures 3 --cooldown 300
88
+ ```
89
+
90
+ ## 🏗️ Architecture Overview
91
+
92
+ The router uses a `FileLock`-backed JSON state (`circuit_breaker.json`) to track failures across concurrent runs.
93
+ If an endpoint times out or returns a 5xx error more than `MAX_FAILURES` times, the circuit trips and forces the router to skip that endpoint for the next 120 seconds, immediately trying the next fallback model.
94
+
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ smart_router.py
4
+ agent_smart_router.egg-info/PKG-INFO
5
+ agent_smart_router.egg-info/SOURCES.txt
6
+ agent_smart_router.egg-info/dependency_links.txt
7
+ agent_smart_router.egg-info/entry_points.txt
8
+ agent_smart_router.egg-info/requires.txt
9
+ agent_smart_router.egg-info/top_level.txt
10
+ tests/test_circuit.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ smart-router = smart_router:main
@@ -0,0 +1,3 @@
1
+ openai>=1.0.0
2
+ filelock>=3.12.0
3
+ anthropic>=0.30.0
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agent-smart-router"
7
+ version = "0.1.0"
8
+ authors = [
9
+ { name="Kerem Barbaros Karnabat", email="kbarbaros@hotmail.com" }
10
+ ]
11
+ description = "A lightweight CLI tool for delegating LLM tasks to expert models across multiple providers."
12
+ readme = "README.md"
13
+ requires-python = ">=3.8"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "openai>=1.0.0",
21
+ "filelock>=3.12.0",
22
+ "anthropic>=0.30.0"
23
+ ]
24
+
25
+ [project.scripts]
26
+ smart-router = "smart_router:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,239 @@
1
+ import sys
2
+ import os
3
+ import json
4
+ import time
5
+ import random
6
+ import argparse
7
+ import logging
8
+ from openai import OpenAI
9
+ from filelock import FileLock, Timeout
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ # Optional import for anthropic
14
+ try:
15
+ import anthropic
16
+ HAS_ANTHROPIC = True
17
+ except ImportError:
18
+ HAS_ANTHROPIC = False
19
+
20
+ # Setup Logging
21
+ logger = logging.getLogger("smart_router")
22
+ handler = logging.StreamHandler(sys.stderr)
23
+ formatter = logging.Formatter("[%(levelname)s] %(message)s")
24
+ handler.setFormatter(formatter)
25
+ logger.addHandler(handler)
26
+ logger.setLevel(logging.INFO)
27
+
28
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
29
+
30
+ def get_api_key(provider):
31
+ keys_file = os.path.join(SCRIPT_DIR, "keys.json")
32
+ if os.path.exists(keys_file):
33
+ try:
34
+ with open(keys_file, 'r', encoding='utf-8') as f:
35
+ keys = json.load(f)
36
+ env_name = f"{provider.upper()}_API_KEY"
37
+ if keys.get(env_name):
38
+ return keys[env_name]
39
+ except Exception:
40
+ pass
41
+ return os.environ.get(f"{provider.upper()}_API_KEY")
42
+
43
+ PROVIDERS = {
44
+ "nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "api_key": get_api_key("NVIDIA")},
45
+ "openai": {"base_url": "https://api.openai.com/v1", "api_key": get_api_key("OPENAI")},
46
+ "groq": {"base_url": "https://api.groq.com/openai/v1", "api_key": get_api_key("GROQ")},
47
+ "gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", "api_key": get_api_key("GEMINI")},
48
+ "anthropic": {"api_key": get_api_key("ANTHROPIC")}
49
+ }
50
+
51
+ class CircuitBreaker:
52
+ def __init__(self, project_id, max_failures=2, cooldown_seconds=120):
53
+ self.max_failures = max_failures
54
+ self.cooldown_seconds = cooldown_seconds
55
+
56
+ safe_proj = "".join([c if c.isalnum() else "_" for c in project_id])
57
+ self.circuit_file = os.path.join(SCRIPT_DIR, f"circuit_breaker_{safe_proj}.json")
58
+ self.lock_file = os.path.join(SCRIPT_DIR, f"circuit_breaker_{safe_proj}.json.lock")
59
+
60
+ def load(self):
61
+ for _ in range(3):
62
+ if os.path.exists(self.circuit_file):
63
+ try:
64
+ with open(self.circuit_file, 'r', encoding='utf-8') as f:
65
+ return json.load(f)
66
+ except Exception:
67
+ time.sleep(random.uniform(0.01, 0.05))
68
+ return {}
69
+
70
+ def save(self, data):
71
+ for _ in range(3):
72
+ try:
73
+ current_time = time.time()
74
+ active_data = {
75
+ k: v for k, v in data.items()
76
+ if v.get('failures', 0) > 0 or v.get('cooldown_until', 0) > current_time
77
+ }
78
+ if not active_data:
79
+ if os.path.exists(self.circuit_file):
80
+ os.remove(self.circuit_file)
81
+ return
82
+ with open(self.circuit_file, 'w', encoding='utf-8') as f:
83
+ json.dump(active_data, f)
84
+ break
85
+ except Exception:
86
+ time.sleep(random.uniform(0.01, 0.05))
87
+
88
+ def check_health(self, model_id):
89
+ try:
90
+ with FileLock(self.lock_file, timeout=5):
91
+ circuit = self.load()
92
+ if model_id in circuit:
93
+ stats = circuit[model_id]
94
+ if stats.get('cooldown_until', 0) > time.time():
95
+ return False
96
+ return True
97
+ except Timeout:
98
+ logger.debug(f"Timeout acquiring lock for {model_id} health check. Assuming healthy.")
99
+ return True
100
+
101
+ def record_failure(self, model_id):
102
+ try:
103
+ with FileLock(self.lock_file, timeout=5):
104
+ circuit = self.load()
105
+ if model_id not in circuit:
106
+ circuit[model_id] = {'failures': 0, 'cooldown_until': 0}
107
+
108
+ circuit[model_id]['failures'] += 1
109
+
110
+ if circuit[model_id]['failures'] >= self.max_failures:
111
+ circuit[model_id]['cooldown_until'] = time.time() + self.cooldown_seconds
112
+ circuit[model_id]['failures'] = 0
113
+ logger.warning(f"CIRCUIT BREAKER: {model_id} tripped! Cooldown: {self.cooldown_seconds}s.")
114
+ self.save(circuit)
115
+ except Timeout:
116
+ logger.debug(f"Timeout acquiring lock. Could not record failure for {model_id}.")
117
+
118
+ def record_success(self, model_id):
119
+ try:
120
+ with FileLock(self.lock_file, timeout=5):
121
+ circuit = self.load()
122
+ if model_id in circuit and circuit[model_id]['failures'] > 0:
123
+ circuit[model_id]['failures'] = 0
124
+ self.save(circuit)
125
+ except Timeout:
126
+ pass
127
+
128
+ def parse_model(model_string):
129
+ if ":" in model_string:
130
+ provider, model = model_string.split(":", 1)
131
+ return provider.strip().lower(), model.strip()
132
+ return "nvidia", model_string.strip()
133
+
134
+ def query_ai(models_list, prompt, cb: CircuitBreaker, max_retries=2, base_timeout=30):
135
+ if isinstance(models_list, str):
136
+ models_list = [m.strip() for m in models_list.split(',')]
137
+
138
+ for current_model_str in models_list:
139
+ provider, current_model = parse_model(current_model_str)
140
+
141
+ if not cb.check_health(current_model_str):
142
+ logger.info(f"Health Check Failed: {current_model_str} is in cooldown. Skipping...")
143
+ continue
144
+
145
+ provider_config = PROVIDERS.get(provider)
146
+ if not provider_config or not provider_config.get("api_key"):
147
+ logger.error(f"Provider '{provider}' not configured or missing API key. Skipping...")
148
+ continue
149
+
150
+ is_nemotron = "nemotron" in current_model.lower()
151
+ model_timeout = 90 if is_nemotron else base_timeout
152
+
153
+ for attempt in range(max_retries):
154
+ try:
155
+ full_reasoning = ""
156
+ full_content = ""
157
+
158
+ if provider == "anthropic":
159
+ if not HAS_ANTHROPIC:
160
+ raise ImportError("Anthropic package is missing. 'pip install anthropic' required.")
161
+ client = anthropic.Anthropic(api_key=provider_config["api_key"], timeout=model_timeout)
162
+ with client.messages.stream(
163
+ model=current_model, max_tokens=4096,
164
+ messages=[{"role": "user", "content": prompt}], temperature=0.7
165
+ ) as stream:
166
+ for text in stream.text_stream:
167
+ full_content += text
168
+ else:
169
+ client = OpenAI(
170
+ base_url=provider_config["base_url"], api_key=provider_config["api_key"], timeout=model_timeout
171
+ )
172
+ extra_body = {"chat_template_kwargs": {"enable_thinking": True}} if (provider == "nvidia" and "nemotron" in current_model.lower()) else {}
173
+ completion = client.chat.completions.create(
174
+ model=current_model, messages=[{"role": "user", "content": prompt}],
175
+ temperature=0.7, max_tokens=4096, extra_body=extra_body if extra_body else None, stream=True
176
+ )
177
+ for chunk in completion:
178
+ if not chunk.choices: continue
179
+ reasoning = getattr(chunk.choices[0].delta, "reasoning_content", None)
180
+ if reasoning: full_reasoning += reasoning
181
+ content = chunk.choices[0].delta.content
182
+ if content: full_content += content
183
+
184
+ cb.record_success(current_model_str)
185
+ output = ""
186
+ if full_reasoning: output += f"--- REASONING ({current_model_str}) ---\n{full_reasoning}\n--- END REASONING ---\n\n"
187
+ output += full_content
188
+ return output
189
+
190
+ except Exception as e:
191
+ error_msg = str(e).lower()
192
+ logger.error(f"Attempt {attempt+1} failed for {current_model_str}: {str(e)}")
193
+ cb.record_failure(current_model_str)
194
+ if "404" in error_msg or "not found" in error_msg or "auth" in error_msg: break
195
+ if attempt == max_retries - 1: break
196
+ time.sleep((2 ** attempt) + random.uniform(0.1, 1.5))
197
+
198
+ logger.error("All fallback models failed, timed out, or are in cooldown.")
199
+ sys.exit(1)
200
+
201
+ def main():
202
+ parser = argparse.ArgumentParser(description="Smart Router: A fault-tolerant CLI tool for LLM delegation.")
203
+ parser.add_argument("-v", "--version", action="version", version=f"Smart Router v{__version__}")
204
+ parser.add_argument("-m", "--models", required=True, help="Comma-separated list of provider:model fallbacks (e.g. nvidia:nemotron,groq:llama3).")
205
+ parser.add_argument("-p", "--prompt", help="The prompt text to send to the model.")
206
+ parser.add_argument("-f", "--file", help="Path to a text file containing the prompt.")
207
+ parser.add_argument("--project", default="default", help="Project ID for isolating circuit breaker state.")
208
+ parser.add_argument("--max-failures", type=int, default=2, help="Failures before tripping the circuit breaker.")
209
+ parser.add_argument("--cooldown", type=int, default=120, help="Cooldown in seconds when circuit is tripped.")
210
+
211
+ args = parser.parse_args()
212
+
213
+ prompt_text = ""
214
+ if args.prompt:
215
+ prompt_text = args.prompt
216
+ elif args.file:
217
+ try:
218
+ with open(args.file, "r", encoding="utf-8") as f:
219
+ prompt_text = f.read()
220
+ except Exception as e:
221
+ logger.error(f"Error reading file: {e}")
222
+ sys.exit(1)
223
+ elif not sys.stdin.isatty():
224
+ prompt_text = sys.stdin.read()
225
+ else:
226
+ parser.error("You must provide a prompt via -p, -f, or stdin (piped input).")
227
+
228
+ if not prompt_text.strip():
229
+ logger.error("Prompt cannot be empty.")
230
+ sys.exit(1)
231
+
232
+ cb = CircuitBreaker(args.project, args.max_failures, args.cooldown)
233
+ response = query_ai(args.models, prompt_text, cb)
234
+
235
+ # Print the final LLM response to stdout so it can be piped properly
236
+ print(response)
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -0,0 +1,48 @@
1
+ import os
2
+ import time
3
+ import pytest
4
+ from smart_router import CircuitBreaker
5
+
6
+ def test_circuit_breaker_isolation(tmp_path):
7
+ # Test that different projects don't share state
8
+ cb1 = CircuitBreaker("proj1", max_failures=2, cooldown_seconds=10)
9
+ cb2 = CircuitBreaker("proj2", max_failures=2, cooldown_seconds=10)
10
+
11
+ cb1.circuit_file = str(tmp_path / "cb1.json")
12
+ cb1.lock_file = str(tmp_path / "cb1.json.lock")
13
+ cb2.circuit_file = str(tmp_path / "cb2.json")
14
+ cb2.lock_file = str(tmp_path / "cb2.json.lock")
15
+
16
+ cb1.record_failure("modelA")
17
+ cb1.record_failure("modelA")
18
+
19
+ # cb1 should be tripped
20
+ assert cb1.check_health("modelA") == False
21
+ # cb2 should be unaffected
22
+ assert cb2.check_health("modelA") == True
23
+
24
+ def test_circuit_breaker_cooldown(tmp_path):
25
+ cb = CircuitBreaker("test", max_failures=1, cooldown_seconds=1)
26
+ cb.circuit_file = str(tmp_path / "cb.json")
27
+ cb.lock_file = str(tmp_path / "cb.json.lock")
28
+
29
+ assert cb.check_health("modelB") == True
30
+ cb.record_failure("modelB")
31
+
32
+ assert cb.check_health("modelB") == False
33
+ time.sleep(1.1)
34
+ # After cooldown, should be healthy again
35
+ assert cb.check_health("modelB") == True
36
+
37
+ def test_circuit_breaker_success_reset(tmp_path):
38
+ cb = CircuitBreaker("test2", max_failures=2, cooldown_seconds=10)
39
+ cb.circuit_file = str(tmp_path / "cb.json")
40
+ cb.lock_file = str(tmp_path / "cb.json.lock")
41
+
42
+ cb.record_failure("modelC")
43
+ circuit = cb.load()
44
+ assert circuit["modelC"]["failures"] == 1
45
+
46
+ cb.record_success("modelC")
47
+ circuit = cb.load()
48
+ assert "modelC" not in circuit or circuit["modelC"]["failures"] == 0