smart-router-ns 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.
- smart_router_ns-0.1.0/LICENSE +21 -0
- smart_router_ns-0.1.0/PKG-INFO +98 -0
- smart_router_ns-0.1.0/README.md +83 -0
- smart_router_ns-0.1.0/pyproject.toml +21 -0
- smart_router_ns-0.1.0/setup.cfg +4 -0
- smart_router_ns-0.1.0/smart_router_ns.egg-info/PKG-INFO +98 -0
- smart_router_ns-0.1.0/smart_router_ns.egg-info/SOURCES.txt +15 -0
- smart_router_ns-0.1.0/smart_router_ns.egg-info/dependency_links.txt +1 -0
- smart_router_ns-0.1.0/smart_router_ns.egg-info/requires.txt +5 -0
- smart_router_ns-0.1.0/smart_router_ns.egg-info/top_level.txt +1 -0
- smart_router_ns-0.1.0/src/__init__.py +4 -0
- smart_router_ns-0.1.0/src/config.py +32 -0
- smart_router_ns-0.1.0/src/prompts.py +34 -0
- smart_router_ns-0.1.0/src/router.py +129 -0
- smart_router_ns-0.1.0/src/schemas.py +47 -0
- smart_router_ns-0.1.0/src/telemetry.py +26 -0
- smart_router_ns-0.1.0/tests/test_router.py +193 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nishant Shinde
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smart-router-ns
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Plug-and-play LLM router for agents, MCP tools, and models
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: litellm
|
|
10
|
+
Requires-Dist: instructor[litellm]
|
|
11
|
+
Requires-Dist: pydantic
|
|
12
|
+
Requires-Dist: pyyaml
|
|
13
|
+
Requires-Dist: python-dotenv
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# Smart Router
|
|
17
|
+
|
|
18
|
+
Plug-and-play LLM router for agents, MCP tools, and models. The Python import
|
|
19
|
+
package is named `src`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install smart-router-ns
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Set the API key for the provider used by the model in your routes file. For the
|
|
28
|
+
Gemini model below:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export GEMINI_API_KEY="your-gemini-api-key"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Alternatively, create a `.env` file in your working directory with that setting.
|
|
35
|
+
Do not commit or share your `.env` file.
|
|
36
|
+
|
|
37
|
+
## Configure routes
|
|
38
|
+
|
|
39
|
+
Create a `routes.yaml` file in your working directory. This minimal example
|
|
40
|
+
shows the supported structure and route types:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
version: 1
|
|
44
|
+
default_route: general_chat
|
|
45
|
+
confidence_threshold: 0.70
|
|
46
|
+
max_history_messages: 10
|
|
47
|
+
model: "gemini/gemini-3.5-flash-lite"
|
|
48
|
+
temperature: 0.0
|
|
49
|
+
|
|
50
|
+
routes:
|
|
51
|
+
- id: general_chat
|
|
52
|
+
type: model
|
|
53
|
+
description: Handles general questions and conversation.
|
|
54
|
+
examples:
|
|
55
|
+
- "Explain vector databases simply."
|
|
56
|
+
|
|
57
|
+
- id: web_research
|
|
58
|
+
type: mcp
|
|
59
|
+
description: Finds current information on the public web.
|
|
60
|
+
examples:
|
|
61
|
+
- "Find the latest Azure AI Search pricing."
|
|
62
|
+
|
|
63
|
+
- id: clarify
|
|
64
|
+
type: fallback
|
|
65
|
+
description: Asks for clarification when a request is ambiguous.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Route `type` must be `agent`, `mcp`, `model`, or `fallback`. Use a LiteLLM
|
|
69
|
+
model identifier and configure the corresponding provider API key in your
|
|
70
|
+
environment.
|
|
71
|
+
|
|
72
|
+
## Use
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from src import SmartRouter
|
|
76
|
+
|
|
77
|
+
router = SmartRouter(routes_file="routes.yaml")
|
|
78
|
+
decision = router.route("What is the latest Azure AI Search pricing?")
|
|
79
|
+
print(decision.model_dump_json(indent=2))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The routes file path is relative to the current working directory. Decisions
|
|
83
|
+
are appended to `router_logs.jsonl` in that directory.
|
|
84
|
+
|
|
85
|
+
## Build and publish
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python -m pip install --upgrade build twine
|
|
89
|
+
python -m build
|
|
90
|
+
python -m twine check dist/*
|
|
91
|
+
python -m twine upload dist/*
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Create a PyPI API token in your PyPI account before uploading. When prompted by
|
|
95
|
+
Twine, use `__token__` as the username and paste the token as the password.
|
|
96
|
+
Never put the token in this README, source code, or a committed file. PyPI does
|
|
97
|
+
not allow reusing a published version number; increment `version` in
|
|
98
|
+
`pyproject.toml` for every new release.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Smart Router
|
|
2
|
+
|
|
3
|
+
Plug-and-play LLM router for agents, MCP tools, and models. The Python import
|
|
4
|
+
package is named `src`.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install smart-router-ns
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Set the API key for the provider used by the model in your routes file. For the
|
|
13
|
+
Gemini model below:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export GEMINI_API_KEY="your-gemini-api-key"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Alternatively, create a `.env` file in your working directory with that setting.
|
|
20
|
+
Do not commit or share your `.env` file.
|
|
21
|
+
|
|
22
|
+
## Configure routes
|
|
23
|
+
|
|
24
|
+
Create a `routes.yaml` file in your working directory. This minimal example
|
|
25
|
+
shows the supported structure and route types:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
version: 1
|
|
29
|
+
default_route: general_chat
|
|
30
|
+
confidence_threshold: 0.70
|
|
31
|
+
max_history_messages: 10
|
|
32
|
+
model: "gemini/gemini-3.5-flash-lite"
|
|
33
|
+
temperature: 0.0
|
|
34
|
+
|
|
35
|
+
routes:
|
|
36
|
+
- id: general_chat
|
|
37
|
+
type: model
|
|
38
|
+
description: Handles general questions and conversation.
|
|
39
|
+
examples:
|
|
40
|
+
- "Explain vector databases simply."
|
|
41
|
+
|
|
42
|
+
- id: web_research
|
|
43
|
+
type: mcp
|
|
44
|
+
description: Finds current information on the public web.
|
|
45
|
+
examples:
|
|
46
|
+
- "Find the latest Azure AI Search pricing."
|
|
47
|
+
|
|
48
|
+
- id: clarify
|
|
49
|
+
type: fallback
|
|
50
|
+
description: Asks for clarification when a request is ambiguous.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Route `type` must be `agent`, `mcp`, `model`, or `fallback`. Use a LiteLLM
|
|
54
|
+
model identifier and configure the corresponding provider API key in your
|
|
55
|
+
environment.
|
|
56
|
+
|
|
57
|
+
## Use
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from src import SmartRouter
|
|
61
|
+
|
|
62
|
+
router = SmartRouter(routes_file="routes.yaml")
|
|
63
|
+
decision = router.route("What is the latest Azure AI Search pricing?")
|
|
64
|
+
print(decision.model_dump_json(indent=2))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The routes file path is relative to the current working directory. Decisions
|
|
68
|
+
are appended to `router_logs.jsonl` in that directory.
|
|
69
|
+
|
|
70
|
+
## Build and publish
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python -m pip install --upgrade build twine
|
|
74
|
+
python -m build
|
|
75
|
+
python -m twine check dist/*
|
|
76
|
+
python -m twine upload dist/*
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Create a PyPI API token in your PyPI account before uploading. When prompted by
|
|
80
|
+
Twine, use `__token__` as the username and paste the token as the password.
|
|
81
|
+
Never put the token in this README, source code, or a committed file. PyPI does
|
|
82
|
+
not allow reusing a published version number; increment `version` in
|
|
83
|
+
`pyproject.toml` for every new release.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "smart-router-ns"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Plug-and-play LLM router for agents, MCP tools, and models"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"litellm",
|
|
10
|
+
"instructor[litellm]",
|
|
11
|
+
"pydantic",
|
|
12
|
+
"pyyaml",
|
|
13
|
+
"python-dotenv"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=77"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["src*"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smart-router-ns
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Plug-and-play LLM router for agents, MCP tools, and models
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: litellm
|
|
10
|
+
Requires-Dist: instructor[litellm]
|
|
11
|
+
Requires-Dist: pydantic
|
|
12
|
+
Requires-Dist: pyyaml
|
|
13
|
+
Requires-Dist: python-dotenv
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# Smart Router
|
|
17
|
+
|
|
18
|
+
Plug-and-play LLM router for agents, MCP tools, and models. The Python import
|
|
19
|
+
package is named `src`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install smart-router-ns
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Set the API key for the provider used by the model in your routes file. For the
|
|
28
|
+
Gemini model below:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export GEMINI_API_KEY="your-gemini-api-key"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Alternatively, create a `.env` file in your working directory with that setting.
|
|
35
|
+
Do not commit or share your `.env` file.
|
|
36
|
+
|
|
37
|
+
## Configure routes
|
|
38
|
+
|
|
39
|
+
Create a `routes.yaml` file in your working directory. This minimal example
|
|
40
|
+
shows the supported structure and route types:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
version: 1
|
|
44
|
+
default_route: general_chat
|
|
45
|
+
confidence_threshold: 0.70
|
|
46
|
+
max_history_messages: 10
|
|
47
|
+
model: "gemini/gemini-3.5-flash-lite"
|
|
48
|
+
temperature: 0.0
|
|
49
|
+
|
|
50
|
+
routes:
|
|
51
|
+
- id: general_chat
|
|
52
|
+
type: model
|
|
53
|
+
description: Handles general questions and conversation.
|
|
54
|
+
examples:
|
|
55
|
+
- "Explain vector databases simply."
|
|
56
|
+
|
|
57
|
+
- id: web_research
|
|
58
|
+
type: mcp
|
|
59
|
+
description: Finds current information on the public web.
|
|
60
|
+
examples:
|
|
61
|
+
- "Find the latest Azure AI Search pricing."
|
|
62
|
+
|
|
63
|
+
- id: clarify
|
|
64
|
+
type: fallback
|
|
65
|
+
description: Asks for clarification when a request is ambiguous.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Route `type` must be `agent`, `mcp`, `model`, or `fallback`. Use a LiteLLM
|
|
69
|
+
model identifier and configure the corresponding provider API key in your
|
|
70
|
+
environment.
|
|
71
|
+
|
|
72
|
+
## Use
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from src import SmartRouter
|
|
76
|
+
|
|
77
|
+
router = SmartRouter(routes_file="routes.yaml")
|
|
78
|
+
decision = router.route("What is the latest Azure AI Search pricing?")
|
|
79
|
+
print(decision.model_dump_json(indent=2))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The routes file path is relative to the current working directory. Decisions
|
|
83
|
+
are appended to `router_logs.jsonl` in that directory.
|
|
84
|
+
|
|
85
|
+
## Build and publish
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python -m pip install --upgrade build twine
|
|
89
|
+
python -m build
|
|
90
|
+
python -m twine check dist/*
|
|
91
|
+
python -m twine upload dist/*
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Create a PyPI API token in your PyPI account before uploading. When prompted by
|
|
95
|
+
Twine, use `__token__` as the username and paste the token as the password.
|
|
96
|
+
Never put the token in this README, source code, or a committed file. PyPI does
|
|
97
|
+
not allow reusing a published version number; increment `version` in
|
|
98
|
+
`pyproject.toml` for every new release.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
smart_router_ns.egg-info/PKG-INFO
|
|
5
|
+
smart_router_ns.egg-info/SOURCES.txt
|
|
6
|
+
smart_router_ns.egg-info/dependency_links.txt
|
|
7
|
+
smart_router_ns.egg-info/requires.txt
|
|
8
|
+
smart_router_ns.egg-info/top_level.txt
|
|
9
|
+
src/__init__.py
|
|
10
|
+
src/config.py
|
|
11
|
+
src/prompts.py
|
|
12
|
+
src/router.py
|
|
13
|
+
src/schemas.py
|
|
14
|
+
src/telemetry.py
|
|
15
|
+
tests/test_router.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
src
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import yaml
|
|
3
|
+
|
|
4
|
+
from .schemas import RouterConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_router_config(path:str | Path) -> RouterConfig:
|
|
8
|
+
path = Path(path)
|
|
9
|
+
|
|
10
|
+
if not path.exists():
|
|
11
|
+
raise FileNotFoundError(f"Routes file not found: {path}")
|
|
12
|
+
|
|
13
|
+
with path.open("r", encoding="utf-8") as f:
|
|
14
|
+
raw = yaml.safe_load(f)
|
|
15
|
+
|
|
16
|
+
config = RouterConfig(**raw)
|
|
17
|
+
validate_config(config)
|
|
18
|
+
return config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_config(config: RouterConfig) -> None:
|
|
22
|
+
route_ids = [route.id for route in config.routes]
|
|
23
|
+
|
|
24
|
+
if len(route_ids) != len(set(route_ids)):
|
|
25
|
+
raise ValueError("Duplicates route IDs found in routes.yaml")
|
|
26
|
+
|
|
27
|
+
enabled_route_ids = {route.id for route in config.routes if route.enabled}
|
|
28
|
+
|
|
29
|
+
if config.default_route not in enabled_route_ids:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"default_route '{config.default_route}' is not an enabled route"
|
|
32
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .schemas import RouterConfig
|
|
2
|
+
|
|
3
|
+
def build_route_catalog(config: RouterConfig) -> str:
|
|
4
|
+
lines = []
|
|
5
|
+
|
|
6
|
+
for route in config.routes:
|
|
7
|
+
if not route.enabled:
|
|
8
|
+
continue
|
|
9
|
+
|
|
10
|
+
examples = "\n".join(f" - {example}" for example in route.examples)
|
|
11
|
+
lines.append(
|
|
12
|
+
f"- id: {route.id}\n"
|
|
13
|
+
f" type: {route.type.value}\n"
|
|
14
|
+
f" description: {route.description.strip()}\n"
|
|
15
|
+
f" examples:\n{examples}"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
return "\n".join(lines)
|
|
19
|
+
|
|
20
|
+
SYSTEM_PROMPT = """You are a precise router for an AI chat system
|
|
21
|
+
|
|
22
|
+
Your only job is to choose the best destination for the user's latest request.
|
|
23
|
+
|
|
24
|
+
Rules:
|
|
25
|
+
1. Use conversation history only to resolve references and follow-ups.
|
|
26
|
+
2. Return a self-contained resolved_query for the selected destination.
|
|
27
|
+
3. Select exactly one primary route.
|
|
28
|
+
4. If the latest request contains multiple independent tasks, set multi_step=true and return ordered steps.
|
|
29
|
+
5. Confidence must reflect genuine uncertainty. Use 1.0 only for an unambiguous request, 0.7–0.9 for a clear but paraphrased request, and below 0.7 when intent is unclear.
|
|
30
|
+
6. If intent is unclear or confidence is below the configured threshold, select the clarify route when available and set fallback=true; otherwise use the default route.
|
|
31
|
+
7. Never invent a route ID.
|
|
32
|
+
8. Do not answer the user's question.
|
|
33
|
+
9. Return only the required structured routing decision.
|
|
34
|
+
"""
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import instructor
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
from litellm import completion
|
|
8
|
+
|
|
9
|
+
from .config import load_router_config
|
|
10
|
+
from .prompts import SYSTEM_PROMPT, build_route_catalog
|
|
11
|
+
from .schemas import RouterConfig, RoutingDecision
|
|
12
|
+
from .telemetry import log_decision
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SmartRouter:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
routes_file: str = "routes.yaml",
|
|
19
|
+
api_key: Optional[str] = None,
|
|
20
|
+
max_retries: int = 2,
|
|
21
|
+
):
|
|
22
|
+
load_dotenv()
|
|
23
|
+
self.routes_file = str(routes_file)
|
|
24
|
+
self.config: RouterConfig = load_router_config(routes_file)
|
|
25
|
+
self.client = instructor.from_litellm(completion)
|
|
26
|
+
self.max_retries = max_retries
|
|
27
|
+
|
|
28
|
+
if api_key:
|
|
29
|
+
os.environ["GEMINI_API_KEY"] = api_key
|
|
30
|
+
|
|
31
|
+
def route(
|
|
32
|
+
self,
|
|
33
|
+
query: str,
|
|
34
|
+
history: Optional[list[dict]] = None,
|
|
35
|
+
) -> RoutingDecision:
|
|
36
|
+
started = time.perf_counter()
|
|
37
|
+
history = self._normalize_history(history)
|
|
38
|
+
|
|
39
|
+
messages = self._build_messages(query, history)
|
|
40
|
+
|
|
41
|
+
decision = self.client.chat.completions.create(
|
|
42
|
+
model=self.config.model,
|
|
43
|
+
response_model=RoutingDecision,
|
|
44
|
+
messages=messages,
|
|
45
|
+
temperature=self.config.temperature,
|
|
46
|
+
max_retries=self.max_retries,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
decision = self._apply_guardrails(decision)
|
|
50
|
+
decision.router_latency_ms = int((time.perf_counter() - started) * 1000)
|
|
51
|
+
|
|
52
|
+
log_decision(
|
|
53
|
+
query=query,
|
|
54
|
+
history=history,
|
|
55
|
+
decision=decision,
|
|
56
|
+
model=self.config.model,
|
|
57
|
+
routes_file=self.routes_file,
|
|
58
|
+
)
|
|
59
|
+
return decision
|
|
60
|
+
|
|
61
|
+
def _build_messages(self, query: str, history: list[dict]) -> list[dict]:
|
|
62
|
+
route_catalog = build_route_catalog(self.config)
|
|
63
|
+
|
|
64
|
+
system_content = (
|
|
65
|
+
f"{SYSTEM_PROMPT}\n\n"
|
|
66
|
+
f"Default route: {self.config.default_route}\n"
|
|
67
|
+
f"Confidence threshold: {self.config.confidence_threshold}\n\n"
|
|
68
|
+
f"Available routes:\n{route_catalog}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
messages = [{"role": "system", "content": system_content}]
|
|
72
|
+
messages.extend(history)
|
|
73
|
+
messages.append({"role": "user", "content": query})
|
|
74
|
+
return messages
|
|
75
|
+
|
|
76
|
+
def _normalize_history(
|
|
77
|
+
self,
|
|
78
|
+
history: Optional[list[dict]],
|
|
79
|
+
) -> list[dict]:
|
|
80
|
+
if not history:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
allowed_roles = {"user", "assistant", "system"}
|
|
84
|
+
normalized = []
|
|
85
|
+
|
|
86
|
+
for message in history:
|
|
87
|
+
role = message.get("role")
|
|
88
|
+
content = message.get("content")
|
|
89
|
+
|
|
90
|
+
if role not in allowed_roles or not isinstance(content, str):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"History messages must contain role and content"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
normalized.append({"role": role, "content": content})
|
|
96
|
+
|
|
97
|
+
limit = self.config.max_history_messages
|
|
98
|
+
return normalized[-limit:] if limit else []
|
|
99
|
+
|
|
100
|
+
def _apply_guardrails(self, decision: RoutingDecision) -> RoutingDecision:
|
|
101
|
+
valid_routes = {route.id for route in self.config.routes if route.enabled}
|
|
102
|
+
clarification_route = "clarify" if "clarify" in valid_routes else self.config.default_route
|
|
103
|
+
|
|
104
|
+
if decision.selected_route not in valid_routes:
|
|
105
|
+
decision.selected_route = self.config.default_route
|
|
106
|
+
decision.fallback = True
|
|
107
|
+
decision.confidence = min(decision.confidence, 0.5)
|
|
108
|
+
decision.reasoning = (
|
|
109
|
+
"Model selected an unknown route; fallback applied. "
|
|
110
|
+
+ decision.reasoning
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if decision.confidence < self.config.confidence_threshold:
|
|
114
|
+
decision.selected_route = clarification_route
|
|
115
|
+
decision.fallback = True
|
|
116
|
+
|
|
117
|
+
for step in decision.steps:
|
|
118
|
+
if step.route not in valid_routes:
|
|
119
|
+
step.route = self.config.default_route
|
|
120
|
+
|
|
121
|
+
expected_orders = list(range(1, len(decision.steps) + 1))
|
|
122
|
+
actual_orders = [step.order for step in decision.steps]
|
|
123
|
+
if decision.multi_step and (not decision.steps or actual_orders != expected_orders):
|
|
124
|
+
decision.multi_step = False
|
|
125
|
+
decision.steps = []
|
|
126
|
+
elif not decision.multi_step:
|
|
127
|
+
decision.steps = []
|
|
128
|
+
|
|
129
|
+
return decision
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RouterType(str, Enum):
|
|
7
|
+
AGENT = "agent"
|
|
8
|
+
MCP = "mcp"
|
|
9
|
+
MODEL = "model"
|
|
10
|
+
FALLBACK = "fallback"
|
|
11
|
+
|
|
12
|
+
class RouteStep(BaseModel):
|
|
13
|
+
order: int
|
|
14
|
+
route: str
|
|
15
|
+
resolved_query: str
|
|
16
|
+
|
|
17
|
+
class RoutingDecision(BaseModel):
|
|
18
|
+
selected_route: str
|
|
19
|
+
confidence: float = Field(ge=0, le=1)
|
|
20
|
+
reasoning: str
|
|
21
|
+
resolved_query: str
|
|
22
|
+
requires_history: bool
|
|
23
|
+
multi_step: bool
|
|
24
|
+
steps: list[RouteStep] = Field(default_factory=list)
|
|
25
|
+
fallback: bool = False
|
|
26
|
+
router_latency_ms: int = 0
|
|
27
|
+
|
|
28
|
+
class RouteDefinition(BaseModel):
|
|
29
|
+
id: str
|
|
30
|
+
type: RouterType
|
|
31
|
+
description: str
|
|
32
|
+
examples: list[str] = Field(default_factory=list)
|
|
33
|
+
keywords: list[str] = Field(default_factory=list)
|
|
34
|
+
enabled: bool = True
|
|
35
|
+
|
|
36
|
+
class RouterConfig(BaseModel):
|
|
37
|
+
version: int = 1
|
|
38
|
+
default_route: str
|
|
39
|
+
confidence_threshold: float = Field(default=0.70, ge=0, le=1)
|
|
40
|
+
max_history_messages: int = Field(default=10, ge=0)
|
|
41
|
+
model: str
|
|
42
|
+
temperature: float = Field(default=0.0, ge=0, le=2)
|
|
43
|
+
routes: list[RouteDefinition]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .schemas import RoutingDecision
|
|
6
|
+
|
|
7
|
+
LOG_FILE = Path("router_logs.jsonl")
|
|
8
|
+
|
|
9
|
+
def log_decision(
|
|
10
|
+
query: str,
|
|
11
|
+
history: list[dict],
|
|
12
|
+
decision: RoutingDecision,
|
|
13
|
+
model: str,
|
|
14
|
+
routes_file: str,
|
|
15
|
+
):
|
|
16
|
+
record = {
|
|
17
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
18
|
+
"query": query,
|
|
19
|
+
"history_message_count": len(history),
|
|
20
|
+
"model": model,
|
|
21
|
+
"routes_file": routes_file,
|
|
22
|
+
**decision.model_dump(),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
with LOG_FILE.open("a", encoding="utf-8") as f:
|
|
26
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from src.config import load_router_config
|
|
6
|
+
from src.router import SmartRouter
|
|
7
|
+
from src.schemas import RouteStep, RoutingDecision
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _make_router(monkeypatch, decision):
|
|
11
|
+
captured = {}
|
|
12
|
+
|
|
13
|
+
def create(**kwargs):
|
|
14
|
+
captured["request"] = kwargs
|
|
15
|
+
return decision
|
|
16
|
+
|
|
17
|
+
fake_client = SimpleNamespace(
|
|
18
|
+
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
|
19
|
+
)
|
|
20
|
+
monkeypatch.setattr("src.router.instructor.from_litellm", lambda _: fake_client)
|
|
21
|
+
monkeypatch.setattr(
|
|
22
|
+
"src.router.log_decision",
|
|
23
|
+
lambda **kwargs: captured.setdefault("log", kwargs),
|
|
24
|
+
)
|
|
25
|
+
return SmartRouter(routes_file="routes.yaml"), captured
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _decision(
|
|
29
|
+
route,
|
|
30
|
+
query,
|
|
31
|
+
*,
|
|
32
|
+
confidence=0.9,
|
|
33
|
+
requires_history=False,
|
|
34
|
+
multi_step=False,
|
|
35
|
+
steps=None,
|
|
36
|
+
fallback=False,
|
|
37
|
+
):
|
|
38
|
+
return RoutingDecision(
|
|
39
|
+
selected_route=route,
|
|
40
|
+
confidence=confidence,
|
|
41
|
+
reasoning="Test fixture decision",
|
|
42
|
+
resolved_query=query,
|
|
43
|
+
requires_history=requires_history,
|
|
44
|
+
multi_step=multi_step,
|
|
45
|
+
steps=steps or [],
|
|
46
|
+
fallback=fallback,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_yaml_loads_with_clarification_route():
|
|
51
|
+
config = load_router_config("routes.yaml")
|
|
52
|
+
|
|
53
|
+
assert config.default_route == "general_chat"
|
|
54
|
+
assert {route.id for route in config.routes} >= {
|
|
55
|
+
"calendar_mcp",
|
|
56
|
+
"clarify",
|
|
57
|
+
"web_research",
|
|
58
|
+
}
|
|
59
|
+
assert next(route for route in config.routes if route.id == "clarify").type.value == "fallback"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_history_trimming(monkeypatch):
|
|
63
|
+
router, _ = _make_router(monkeypatch, _decision("general_chat", "Hello"))
|
|
64
|
+
history = [{"role": "user", "content": f"message {i}"} for i in range(20)]
|
|
65
|
+
|
|
66
|
+
trimmed = router._normalize_history(history)
|
|
67
|
+
|
|
68
|
+
assert len(trimmed) == router.config.max_history_messages
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_explicit_calendar_request(monkeypatch):
|
|
72
|
+
router, captured = _make_router(
|
|
73
|
+
monkeypatch,
|
|
74
|
+
_decision("calendar_mcp", "Schedule a meeting with Rahul tomorrow at 4 PM"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
result = router.route("Schedule a meeting with Rahul tomorrow at 4 PM")
|
|
78
|
+
|
|
79
|
+
assert result.selected_route == "calendar_mcp"
|
|
80
|
+
assert result.requires_history is False
|
|
81
|
+
assert "Rahul" in result.resolved_query
|
|
82
|
+
assert captured["log"]["model"] == router.config.model
|
|
83
|
+
assert captured["log"]["routes_file"] == "routes.yaml"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_history_dependent_yes_resolves_booking(monkeypatch):
|
|
87
|
+
history = [
|
|
88
|
+
{"role": "user", "content": "Can you help me book a meeting with Rahul?"},
|
|
89
|
+
{"role": "assistant", "content": "What time should I schedule it?"},
|
|
90
|
+
]
|
|
91
|
+
router, captured = _make_router(
|
|
92
|
+
monkeypatch,
|
|
93
|
+
_decision(
|
|
94
|
+
"calendar_mcp",
|
|
95
|
+
"Schedule a meeting with Rahul tomorrow at 4 PM",
|
|
96
|
+
requires_history=True,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
result = router.route("yes, do that", history=history)
|
|
101
|
+
|
|
102
|
+
assert result.selected_route == "calendar_mcp"
|
|
103
|
+
assert result.requires_history is True
|
|
104
|
+
assert "Rahul" in result.resolved_query
|
|
105
|
+
assert captured["request"]["messages"][1:3] == history
|
|
106
|
+
assert captured["request"]["messages"][-1]["content"] == "yes, do that"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_multi_step_pricing_and_document_request(monkeypatch):
|
|
110
|
+
router, _ = _make_router(
|
|
111
|
+
monkeypatch,
|
|
112
|
+
_decision(
|
|
113
|
+
"web_research",
|
|
114
|
+
"Find current Azure AI Search pricing and summarize the product PDF",
|
|
115
|
+
multi_step=True,
|
|
116
|
+
steps=[
|
|
117
|
+
RouteStep(
|
|
118
|
+
order=1,
|
|
119
|
+
route="web_research",
|
|
120
|
+
resolved_query="Find the latest Azure AI Search pricing",
|
|
121
|
+
),
|
|
122
|
+
RouteStep(
|
|
123
|
+
order=2,
|
|
124
|
+
route="document_qa",
|
|
125
|
+
resolved_query="Summarize the uploaded product PDF",
|
|
126
|
+
),
|
|
127
|
+
],
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
result = router.route("Find the latest Azure AI Search pricing and summarize my product PDF")
|
|
132
|
+
|
|
133
|
+
assert result.selected_route == "web_research"
|
|
134
|
+
assert result.multi_step is True
|
|
135
|
+
assert [step.order for step in result.steps] == [1, 2]
|
|
136
|
+
assert [step.route for step in result.steps] == ["web_research", "document_qa"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_unclear_request_uses_clarification_fallback(monkeypatch):
|
|
140
|
+
router, _ = _make_router(
|
|
141
|
+
monkeypatch,
|
|
142
|
+
_decision("general_chat", "asdf qwerty thing", confidence=0.2),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
result = router.route("asdf qwerty thing")
|
|
146
|
+
|
|
147
|
+
assert result.selected_route == "clarify"
|
|
148
|
+
assert result.fallback is True
|
|
149
|
+
assert result.confidence < router.config.confidence_threshold
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_cancel_request_keeps_calendar_route_and_changes_action(monkeypatch):
|
|
153
|
+
history = [
|
|
154
|
+
{"role": "user", "content": "Schedule a meeting with Rahul tomorrow at 4 PM"},
|
|
155
|
+
{"role": "assistant", "content": "I can help schedule that meeting."},
|
|
156
|
+
]
|
|
157
|
+
router, _ = _make_router(
|
|
158
|
+
monkeypatch,
|
|
159
|
+
_decision(
|
|
160
|
+
"calendar_mcp",
|
|
161
|
+
"Cancel the meeting with Rahul scheduled for tomorrow at 4 PM",
|
|
162
|
+
requires_history=True,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
result = router.route("actually, cancel that meeting", history=history)
|
|
167
|
+
|
|
168
|
+
assert result.selected_route == "calendar_mcp"
|
|
169
|
+
assert "cancel" in result.resolved_query.lower()
|
|
170
|
+
assert "Rahul" in result.resolved_query
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize(
|
|
174
|
+
"steps",
|
|
175
|
+
[
|
|
176
|
+
[],
|
|
177
|
+
[RouteStep(order=2, route="web_research", resolved_query="Step")],
|
|
178
|
+
[
|
|
179
|
+
RouteStep(order=1, route="web_research", resolved_query="First"),
|
|
180
|
+
RouteStep(order=3, route="document_qa", resolved_query="Third"),
|
|
181
|
+
],
|
|
182
|
+
],
|
|
183
|
+
)
|
|
184
|
+
def test_invalid_multi_step_payload_is_disabled(monkeypatch, steps):
|
|
185
|
+
router, _ = _make_router(
|
|
186
|
+
monkeypatch,
|
|
187
|
+
_decision("web_research", "combined request", multi_step=True, steps=steps),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
result = router.route("combined request")
|
|
191
|
+
|
|
192
|
+
assert result.multi_step is False
|
|
193
|
+
assert result.steps == []
|