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.
- agent_smart_router-0.1.0/PKG-INFO +94 -0
- agent_smart_router-0.1.0/README.md +80 -0
- agent_smart_router-0.1.0/agent_smart_router.egg-info/PKG-INFO +94 -0
- agent_smart_router-0.1.0/agent_smart_router.egg-info/SOURCES.txt +10 -0
- agent_smart_router-0.1.0/agent_smart_router.egg-info/dependency_links.txt +1 -0
- agent_smart_router-0.1.0/agent_smart_router.egg-info/entry_points.txt +2 -0
- agent_smart_router-0.1.0/agent_smart_router.egg-info/requires.txt +3 -0
- agent_smart_router-0.1.0/agent_smart_router.egg-info/top_level.txt +1 -0
- agent_smart_router-0.1.0/pyproject.toml +26 -0
- agent_smart_router-0.1.0/setup.cfg +4 -0
- agent_smart_router-0.1.0/smart_router.py +239 -0
- agent_smart_router-0.1.0/tests/test_circuit.py +48 -0
|
@@ -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
|
+

|
|
20
|
+

|
|
21
|
+

|
|
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
|
+

|
|
6
|
+

|
|
7
|
+

|
|
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
|
+

|
|
20
|
+

|
|
21
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smart_router
|
|
@@ -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,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
|