model-router-cli 1.0.0__py3-none-any.whl

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,119 @@
1
+ from typing import Dict, Any, List, Optional
2
+ from sqlalchemy import select, func
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from app.storage.models import RequestRecord, FeedbackRecord, ModelRecord
5
+ from app.config.settings import get_settings
6
+
7
+ settings = get_settings()
8
+
9
+
10
+ async def calculate_cost_savings(
11
+ session: AsyncSession,
12
+ baseline_model_id: Optional[str] = None,
13
+ ) -> Dict[str, Any]:
14
+ """
15
+ Computes accurate, non-invented cost savings:
16
+ Baseline Cost (if all requests were sent to baseline model) vs Actual Routed Cost.
17
+ """
18
+ base_id = baseline_model_id or settings.BASELINE_MODEL_ID
19
+
20
+ # Fetch baseline model pricing
21
+ res_base = await session.execute(select(ModelRecord).filter_by(id=base_id))
22
+ baseline_model = res_base.scalar_one_or_none()
23
+
24
+ base_in_rate = baseline_model.cost_per_input_token if baseline_model else 0.000005
25
+ base_out_rate = baseline_model.cost_per_output_token if baseline_model else 0.000015
26
+
27
+ # Fetch aggregate requests
28
+ res = await session.execute(
29
+ select(
30
+ func.count(RequestRecord.request_id),
31
+ func.sum(RequestRecord.input_tokens),
32
+ func.sum(RequestRecord.output_tokens),
33
+ func.sum(RequestRecord.estimated_cost),
34
+ )
35
+ )
36
+ total_reqs, total_in_tok, total_out_tok, total_routed_cost = res.one()
37
+
38
+ total_reqs = total_reqs or 0
39
+ total_in_tok = total_in_tok or 0
40
+ total_out_tok = total_out_tok or 0
41
+ total_routed_cost = round(total_routed_cost or 0.0, 6)
42
+
43
+ # Theoretical cost if all traffic ran on baseline
44
+ baseline_total_cost = round((total_in_tok * base_in_rate) + (total_out_tok * base_out_rate), 6)
45
+ saved_amount = max(0.0, round(baseline_total_cost - total_routed_cost, 6))
46
+ savings_percent = round((saved_amount / baseline_total_cost * 100.0), 2) if baseline_total_cost > 0 else 0.0
47
+
48
+ return {
49
+ "baseline_model": base_id,
50
+ "total_requests": total_reqs,
51
+ "total_input_tokens": total_in_tok,
52
+ "total_output_tokens": total_out_tok,
53
+ "baseline_total_cost": baseline_total_cost,
54
+ "routed_total_cost": total_routed_cost,
55
+ "cost_saved_usd": saved_amount,
56
+ "savings_percentage": savings_percent,
57
+ }
58
+
59
+
60
+ async def get_system_analytics(session: AsyncSession) -> Dict[str, Any]:
61
+ """
62
+ Collects full aggregate metrics for the dashboard.
63
+ """
64
+ # 1. Totals & averages
65
+ res = await session.execute(
66
+ select(
67
+ func.count(RequestRecord.request_id),
68
+ func.avg(RequestRecord.total_latency_ms),
69
+ func.avg(RequestRecord.routing_latency_ms),
70
+ func.avg(RequestRecord.estimated_cost),
71
+ func.sum(RequestRecord.estimated_cost),
72
+ )
73
+ )
74
+ count, avg_lat, avg_route_lat, avg_cost, sum_cost = res.one()
75
+
76
+ # 2. Distribution by model
77
+ model_dist_res = await session.execute(
78
+ select(RequestRecord.selected_model, func.count(RequestRecord.request_id))
79
+ .group_by(RequestRecord.selected_model)
80
+ )
81
+ model_distribution = {m: c for m, c in model_dist_res.all()}
82
+
83
+ # 3. Distribution by task type
84
+ task_dist_res = await session.execute(
85
+ select(RequestRecord.task_type, func.count(RequestRecord.request_id))
86
+ .group_by(RequestRecord.task_type)
87
+ )
88
+ task_distribution = {t: c for t, c in task_dist_res.all()}
89
+
90
+ # 4. Fallback rate
91
+ fb_res = await session.execute(
92
+ select(func.count(RequestRecord.request_id)).filter(RequestRecord.fallback_used == True)
93
+ )
94
+ fallback_count = fb_res.scalar() or 0
95
+ fallback_rate = round((fallback_count / count * 100.0), 2) if count and count > 0 else 0.0
96
+
97
+ # 5. User Feedback aggregate
98
+ fb_pos_res = await session.execute(select(func.count(FeedbackRecord.id)).filter(FeedbackRecord.rating == 1))
99
+ fb_neg_res = await session.execute(select(func.count(FeedbackRecord.id)).filter(FeedbackRecord.rating == -1))
100
+ pos_feedback = fb_pos_res.scalar() or 0
101
+ neg_feedback = fb_neg_res.scalar() or 0
102
+ total_feedback = pos_feedback + neg_feedback
103
+ quality_score = round((pos_feedback / total_feedback * 100.0), 1) if total_feedback > 0 else None
104
+
105
+ savings = await calculate_cost_savings(session)
106
+
107
+ return {
108
+ "total_requests": count or 0,
109
+ "avg_latency_ms": round(avg_lat or 0.0, 1),
110
+ "avg_routing_latency_ms": round(avg_route_lat or 0.0, 1),
111
+ "avg_cost_usd": round(avg_cost or 0.0, 6),
112
+ "total_cost_usd": round(sum_cost or 0.0, 6),
113
+ "quality_score_percent": quality_score,
114
+ "fallback_rate_percent": fallback_rate,
115
+ "fallback_count": fallback_count,
116
+ "model_distribution": model_distribution,
117
+ "task_distribution": task_distribution,
118
+ "savings": savings,
119
+ }
@@ -0,0 +1,67 @@
1
+ import json
2
+ import re
3
+ from typing import Optional
4
+ from app.models.schemas import RequestAnalysis, TaskType, PriorityLevel
5
+ from app.analyzer.heuristics import analyze_request_heuristics, estimate_tokens
6
+ from app.providers.registry import provider_registry
7
+ from app.config.settings import get_settings
8
+
9
+ settings = get_settings()
10
+
11
+ CLASSIFIER_PROMPT = """You are a real-time request classifier for an intelligent LLM model router.
12
+ Analyze the user prompt and return ONLY a JSON object matching this schema:
13
+ {
14
+ "task_type": "GENERAL_QA" | "CODING" | "DEBUGGING" | "REASONING" | "SUMMARIZATION" | "EXTRACTION" | "WRITING" | "TRANSLATION" | "ANALYSIS" | "MATH" | "LONG_CONTEXT" | "CREATIVE",
15
+ "complexity": 0.0 to 1.0,
16
+ "reasoning_required": true/false,
17
+ "coding_required": true/false,
18
+ "vision_required": true/false,
19
+ "latency_priority": "LOW" | "MEDIUM" | "HIGH",
20
+ "cost_sensitivity": "LOW" | "MEDIUM" | "HIGH",
21
+ "quality_requirement": "LOW" | "MEDIUM" | "HIGH"
22
+ }
23
+ Output valid JSON only with no markdown wrapping or additional text.
24
+ """
25
+
26
+
27
+ async def analyze_request(prompt: str, analyzer_mode: Optional[str] = None) -> RequestAnalysis:
28
+ mode = (analyzer_mode or settings.ROUTER_ANALYZER).lower()
29
+
30
+ if mode == "llm":
31
+ try:
32
+ # Attempt to use local or configured provider for classification
33
+ provider = provider_registry.get_provider("ollama")
34
+ if provider:
35
+ health = await provider.check_health()
36
+ if health.get("status") == "CONNECTED":
37
+ resp = await provider.generate(
38
+ prompt=f"Classify this request:\n\n{prompt[:1000]}",
39
+ model_id="qwen2.5-coder",
40
+ system_prompt=CLASSIFIER_PROMPT,
41
+ temperature=0.1,
42
+ )
43
+ clean_json = re.search(r"\{.*\}", resp.content, re.DOTALL)
44
+ if clean_json:
45
+ data = json.loads(clean_json.group(0))
46
+ comp = float(data.get("complexity", 0.5))
47
+ comp_label = PriorityLevel.HIGH if comp >= 0.75 else (PriorityLevel.MEDIUM if comp >= 0.45 else PriorityLevel.LOW)
48
+ return RequestAnalysis(
49
+ task_type=TaskType(data.get("task_type", "GENERAL_QA")),
50
+ complexity=comp,
51
+ complexity_label=comp_label,
52
+ reasoning_required=bool(data.get("reasoning_required", False)),
53
+ coding_required=bool(data.get("coding_required", False)),
54
+ vision_required=bool(data.get("vision_required", False)),
55
+ context_size=estimate_tokens(prompt),
56
+ latency_priority=PriorityLevel(data.get("latency_priority", "MEDIUM")),
57
+ cost_sensitivity=PriorityLevel(data.get("cost_sensitivity", "MEDIUM")),
58
+ quality_requirement=PriorityLevel(data.get("quality_requirement", "MEDIUM")),
59
+ keywords_detected=["llm_classified"],
60
+ analyzer_used="llm",
61
+ )
62
+ except Exception:
63
+ # Graceful fallback to heuristics if LLM classifier is unavailable or times out
64
+ pass
65
+
66
+ # Default to fast, deterministic heuristics
67
+ return analyze_request_heuristics(prompt)
@@ -0,0 +1,192 @@
1
+ import re
2
+ from typing import List, Tuple
3
+ from app.models.schemas import TaskType, PriorityLevel, RequestAnalysis
4
+
5
+ # Regex rules and indicator vocabularies
6
+ DEBUG_PATTERNS = [
7
+ r"\b(debug|bug|stacktrace|exception|error|fix this error|traceback|segfault|syntaxerror|nullpointer)\b",
8
+ r"\b(crash|panicked|failed test|reproduce|root cause|undefined is not a function)\b",
9
+ ]
10
+
11
+ CODING_PATTERNS = [
12
+ r"\b(write a python|write code|implement|function|class |def |const |async def|typescript|javascript|golang|rust|react|sql query|endpoint|api client)\b",
13
+ r"\b(refactor|algorithm|regex|unit test|dockerfile|kubernetes yaml|prisma|fastapi)\b",
14
+ r"```[a-zA-Z0-9_-]*\n",
15
+ ]
16
+
17
+ REASONING_PATTERNS = [
18
+ r"\b(explain why|reasoning|step by step|proof|derive|why did|compare and contrast|architectural decision|tradeoffs|trade-offs|consequences of)\b",
19
+ r"\b(game theory|logical fallacy|syllogism|first principles|hypothesize)\b",
20
+ ]
21
+
22
+ MATH_PATTERNS = [
23
+ r"\b(calculate|integral|derivative|matrix|eigenvalue|probability|equation|differential equation|solve for x|combinatorics|standard deviation)\b",
24
+ r"[\$\\\^\{\}\[\]\=]{3,}",
25
+ ]
26
+
27
+ SUMMARIZATION_PATTERNS = [
28
+ r"\b(summarize|summary|tldr|tl;dr|key points|bullet points of this|condense|digest|brief overview)\b",
29
+ ]
30
+
31
+ EXTRACTION_PATTERNS = [
32
+ r"\b(extract|extract json|parse this table|pull all emails|convert to json|output as csv|structured data from)\b",
33
+ ]
34
+
35
+ TRANSLATION_PATTERNS = [
36
+ r"\b(translate to|translate into|in french|in spanish|in german|in japanese|in chinese|in hindi|translate from)\b",
37
+ ]
38
+
39
+ CREATIVE_PATTERNS = [
40
+ r"\b(write a poem|write a story|screenplay|brainstorm catchy|creative writing|dialogue between|fiction|metaphor)\b",
41
+ ]
42
+
43
+ ANALYSIS_PATTERNS = [
44
+ r"\b(analyze|breakdown|evaluate|critique|audit|financial report|trend analysis|pros and cons)\b",
45
+ ]
46
+
47
+
48
+ def estimate_tokens(text: str) -> int:
49
+ """Fast, reliable token estimation (roughly 4 characters per token)."""
50
+ return max(1, len(text) // 4)
51
+
52
+
53
+ def analyze_request_heuristics(prompt: str) -> RequestAnalysis:
54
+ text_lower = prompt.lower()
55
+ tokens = estimate_tokens(prompt)
56
+
57
+ detected_keywords: List[str] = []
58
+ task_scores: dict[TaskType, float] = {t: 0.0 for t in TaskType}
59
+
60
+ # 1. Evaluate patterns
61
+ for p in DEBUG_PATTERNS:
62
+ matches = re.findall(p, text_lower)
63
+ if matches:
64
+ task_scores[TaskType.DEBUGGING] += len(matches) * 2.5
65
+ detected_keywords.extend(matches)
66
+
67
+ for p in CODING_PATTERNS:
68
+ matches = re.findall(p, text_lower)
69
+ if matches:
70
+ task_scores[TaskType.CODING] += len(matches) * 2.0
71
+ detected_keywords.extend(matches)
72
+
73
+ for p in REASONING_PATTERNS:
74
+ matches = re.findall(p, text_lower)
75
+ if matches:
76
+ task_scores[TaskType.REASONING] += len(matches) * 2.0
77
+ detected_keywords.extend(matches)
78
+
79
+ for p in MATH_PATTERNS:
80
+ matches = re.findall(p, text_lower)
81
+ if matches:
82
+ task_scores[TaskType.MATH] += len(matches) * 2.2
83
+ detected_keywords.extend(matches)
84
+
85
+ for p in SUMMARIZATION_PATTERNS:
86
+ matches = re.findall(p, text_lower)
87
+ if matches:
88
+ task_scores[TaskType.SUMMARIZATION] += len(matches) * 2.5
89
+ detected_keywords.extend(matches)
90
+
91
+ for p in EXTRACTION_PATTERNS:
92
+ matches = re.findall(p, text_lower)
93
+ if matches:
94
+ task_scores[TaskType.EXTRACTION] += len(matches) * 2.0
95
+ detected_keywords.extend(matches)
96
+
97
+ for p in TRANSLATION_PATTERNS:
98
+ matches = re.findall(p, text_lower)
99
+ if matches:
100
+ task_scores[TaskType.TRANSLATION] += len(matches) * 3.0
101
+ detected_keywords.extend(matches)
102
+
103
+ for p in CREATIVE_PATTERNS:
104
+ matches = re.findall(p, text_lower)
105
+ if matches:
106
+ task_scores[TaskType.CREATIVE] += len(matches) * 2.0
107
+ detected_keywords.extend(matches)
108
+
109
+ for p in ANALYSIS_PATTERNS:
110
+ matches = re.findall(p, text_lower)
111
+ if matches:
112
+ task_scores[TaskType.ANALYSIS] += len(matches) * 1.8
113
+ detected_keywords.extend(matches)
114
+
115
+ # Long context check
116
+ if tokens > 4000:
117
+ task_scores[TaskType.LONG_CONTEXT] += 3.0
118
+
119
+ # 2. Determine Primary Task
120
+ best_task = TaskType.GENERAL_QA
121
+ max_score = 0.0
122
+ for task, score in task_scores.items():
123
+ if score > max_score:
124
+ max_score = score
125
+ best_task = task
126
+
127
+ # 3. Derive Complexity (0.0 to 1.0)
128
+ complexity = 0.35 # Base complexity
129
+
130
+ # Token length factor
131
+ if tokens > 10000:
132
+ complexity += 0.30
133
+ elif tokens > 3000:
134
+ complexity += 0.20
135
+ elif tokens > 800:
136
+ complexity += 0.10
137
+ elif tokens < 30:
138
+ complexity -= 0.10
139
+
140
+ # Task inherent complexity
141
+ if best_task in (TaskType.DEBUGGING, TaskType.MATH, TaskType.REASONING):
142
+ complexity += 0.30
143
+ elif best_task in (TaskType.CODING, TaskType.ANALYSIS, TaskType.LONG_CONTEXT):
144
+ complexity += 0.20
145
+ elif best_task in (TaskType.TRANSLATION, TaskType.EXTRACTION, TaskType.SUMMARIZATION):
146
+ complexity += 0.05
147
+ elif best_task == TaskType.GENERAL_QA and tokens < 50:
148
+ complexity -= 0.15
149
+
150
+ # Multi-step keyword boosts
151
+ if any(k in text_lower for k in ["distributed", "async", "concurrency", "deadlock", "microservices", "architecture"]):
152
+ complexity += 0.25
153
+ if any(k in text_lower for k in ["simple", "quick", "one liner", "easy", "hello world"]):
154
+ complexity -= 0.20
155
+
156
+ complexity = max(0.05, min(0.99, round(complexity, 2)))
157
+
158
+ # 4. Priority and Capabilities
159
+ coding_required = best_task in (TaskType.CODING, TaskType.DEBUGGING) or task_scores[TaskType.CODING] > 0
160
+ reasoning_required = best_task in (TaskType.REASONING, TaskType.DEBUGGING, TaskType.MATH) or complexity >= 0.70
161
+
162
+ if complexity >= 0.75:
163
+ complexity_label = PriorityLevel.HIGH
164
+ quality_req = PriorityLevel.HIGH
165
+ latency_pri = PriorityLevel.LOW
166
+ cost_sens = PriorityLevel.LOW
167
+ elif complexity >= 0.45:
168
+ complexity_label = PriorityLevel.MEDIUM
169
+ quality_req = PriorityLevel.MEDIUM
170
+ latency_pri = PriorityLevel.MEDIUM
171
+ cost_sens = PriorityLevel.MEDIUM
172
+ else:
173
+ complexity_label = PriorityLevel.LOW
174
+ quality_req = PriorityLevel.LOW
175
+ latency_pri = PriorityLevel.HIGH
176
+ cost_sens = PriorityLevel.HIGH
177
+
178
+ return RequestAnalysis(
179
+ task_type=best_task,
180
+ complexity=complexity,
181
+ complexity_label=complexity_label,
182
+ reasoning_required=reasoning_required,
183
+ coding_required=coding_required,
184
+ vision_required=False,
185
+ tools_required=False,
186
+ context_size=tokens,
187
+ latency_priority=latency_pri,
188
+ cost_sensitivity=cost_sens,
189
+ quality_requirement=quality_req,
190
+ keywords_detected=list(set(detected_keywords))[:10],
191
+ analyzer_used="rules",
192
+ )