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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ litellm
2
+ instructor[litellm]
3
+ pydantic
4
+ pyyaml
5
+ python-dotenv
@@ -0,0 +1,4 @@
1
+ from .router import SmartRouter
2
+ from .schemas import RoutingDecision
3
+
4
+ __all__ = ["SmartRouter", "RoutingDecision"]
@@ -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 == []