query-oracle 0.3.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.
- llm_router/__init__.py +14 -0
- llm_router/classifier.py +301 -0
- llm_router/mcp_server.py +103 -0
- llm_router/models.py +159 -0
- llm_router/providers/__init__.py +9 -0
- llm_router/providers/anthropic_provider.py +50 -0
- llm_router/providers/base.py +24 -0
- llm_router/providers/gemini_provider.py +53 -0
- llm_router/providers/openai_provider.py +55 -0
- llm_router/router.py +77 -0
- query_oracle-0.3.0.dist-info/METADATA +299 -0
- query_oracle-0.3.0.dist-info/RECORD +16 -0
- query_oracle-0.3.0.dist-info/WHEEL +5 -0
- query_oracle-0.3.0.dist-info/entry_points.txt +2 -0
- query_oracle-0.3.0.dist-info/licenses/LICENSE +21 -0
- query_oracle-0.3.0.dist-info/top_level.txt +1 -0
llm_router/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
ClassificationResult, EffortLevel, ProviderType,
|
|
3
|
+
QueryTier, RouterConfig, RouterResponse,
|
|
4
|
+
MODEL_PRICING, estimate_cost,
|
|
5
|
+
)
|
|
6
|
+
from .router import QueryRouter
|
|
7
|
+
from .classifier import PromptClassifier, DistilBERTClassifier
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"QueryRouter", "RouterConfig", "RouterResponse",
|
|
11
|
+
"ClassificationResult", "QueryTier", "EffortLevel", "ProviderType",
|
|
12
|
+
"PromptClassifier", "DistilBERTClassifier",
|
|
13
|
+
"MODEL_PRICING", "estimate_cost",
|
|
14
|
+
]
|
llm_router/classifier.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query classifier.
|
|
3
|
+
|
|
4
|
+
Two backends, same interface:
|
|
5
|
+
PromptClassifier — calls a small LLM (default, works immediately)
|
|
6
|
+
DistilBERTClassifier — loads a fine-tuned local model (zero API cost,
|
|
7
|
+
~10ms inference; requires a trained model directory)
|
|
8
|
+
|
|
9
|
+
Swap backends by passing a different classifier instance to QueryRouter.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
|
|
17
|
+
from .models import ClassificationResult, EffortLevel, QueryTier, RouterConfig, estimate_cost
|
|
18
|
+
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
# Abstract base
|
|
21
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
class BaseClassifier(ABC):
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def classify(self, query: str) -> ClassificationResult: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# Option A: Prompt-based (works out of the box, any provider)
|
|
30
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
CLASSIFICATION_PROMPT = """\
|
|
33
|
+
You are a query classification engine. Analyse the user query and return JSON:
|
|
34
|
+
|
|
35
|
+
{{
|
|
36
|
+
"tier": "fast" | "balanced" | "deep",
|
|
37
|
+
"effort": "low" | "medium" | "high",
|
|
38
|
+
"facts_ratio": <float 0.0-1.0>,
|
|
39
|
+
"judgment_ratio": <float 0.0-1.0>,
|
|
40
|
+
"confidence": <float 0.0-1.0>,
|
|
41
|
+
"reasoning": "<one sentence>"
|
|
42
|
+
}}
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- facts_ratio + judgment_ratio = 1.0
|
|
46
|
+
- fast -> factual lookups, news, nutrition, simple maths
|
|
47
|
+
- balanced -> moderate analysis, explanations with nuance
|
|
48
|
+
- deep -> ideation, strategy, ethics, complex design
|
|
49
|
+
- effort follows tier by default but may be raised one level for unusually nuanced queries.
|
|
50
|
+
|
|
51
|
+
Return ONLY the JSON. No text outside it.
|
|
52
|
+
|
|
53
|
+
Query: {query}"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PromptClassifier(BaseClassifier):
|
|
57
|
+
"""
|
|
58
|
+
Classifies queries via a prompt call to a small LLM.
|
|
59
|
+
Works immediately with any API key. Logs every result for future fine-tuning.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: RouterConfig, client=None):
|
|
63
|
+
self.config = config
|
|
64
|
+
self._client = client
|
|
65
|
+
if config.log_classifications:
|
|
66
|
+
Path(config.log_path).parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
def classify(self, query: str) -> ClassificationResult:
|
|
69
|
+
raw, in_tok, out_tok = self._call_model(query)
|
|
70
|
+
result = self._parse(raw)
|
|
71
|
+
result.classifier_input_tokens = in_tok
|
|
72
|
+
result.classifier_output_tokens = out_tok
|
|
73
|
+
result.classifier_cost_usd = estimate_cost(
|
|
74
|
+
self.config.classifier_model, in_tok, out_tok
|
|
75
|
+
)
|
|
76
|
+
if self.config.log_classifications:
|
|
77
|
+
self._log(query, result)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def _call_model(self, query: str) -> tuple[str, int, int]:
|
|
81
|
+
"""Returns (text, input_tokens, output_tokens)."""
|
|
82
|
+
from .models import ProviderType
|
|
83
|
+
provider = self.config.classifier_provider
|
|
84
|
+
prompt = CLASSIFICATION_PROMPT.format(query=query)
|
|
85
|
+
|
|
86
|
+
if provider == ProviderType.ANTHROPIC:
|
|
87
|
+
import anthropic
|
|
88
|
+
client = self._client or anthropic.Anthropic()
|
|
89
|
+
r = client.messages.create(
|
|
90
|
+
model=self.config.classifier_model,
|
|
91
|
+
max_tokens=512,
|
|
92
|
+
messages=[{"role": "user", "content": prompt}],
|
|
93
|
+
)
|
|
94
|
+
return (
|
|
95
|
+
r.content[0].text.strip(),
|
|
96
|
+
r.usage.input_tokens,
|
|
97
|
+
r.usage.output_tokens,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
elif provider == ProviderType.OPENAI:
|
|
101
|
+
from openai import OpenAI
|
|
102
|
+
client = self._client or OpenAI()
|
|
103
|
+
r = client.chat.completions.create(
|
|
104
|
+
model=self.config.classifier_model,
|
|
105
|
+
max_tokens=512,
|
|
106
|
+
messages=[{"role": "user", "content": prompt}],
|
|
107
|
+
)
|
|
108
|
+
return (
|
|
109
|
+
r.choices[0].message.content.strip(),
|
|
110
|
+
r.usage.prompt_tokens,
|
|
111
|
+
r.usage.completion_tokens,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
elif provider == ProviderType.GEMINI:
|
|
115
|
+
# BUG 9 fix: honour self._client when injected; otherwise configure from env.
|
|
116
|
+
if self._client:
|
|
117
|
+
model_instance = self._client
|
|
118
|
+
else:
|
|
119
|
+
import os
|
|
120
|
+
import google.generativeai as genai
|
|
121
|
+
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
122
|
+
if api_key:
|
|
123
|
+
genai.configure(api_key=api_key)
|
|
124
|
+
model_instance = genai.GenerativeModel(self.config.classifier_model)
|
|
125
|
+
r = model_instance.generate_content(prompt)
|
|
126
|
+
in_tok = (
|
|
127
|
+
getattr(r.usage_metadata, "prompt_token_count", 0)
|
|
128
|
+
if hasattr(r, "usage_metadata") else 0
|
|
129
|
+
)
|
|
130
|
+
out_tok = (
|
|
131
|
+
getattr(r.usage_metadata, "candidates_token_count", 0)
|
|
132
|
+
if hasattr(r, "usage_metadata") else 0
|
|
133
|
+
)
|
|
134
|
+
return r.text.strip(), in_tok, out_tok
|
|
135
|
+
|
|
136
|
+
raise ValueError(f"Unknown classifier provider: {provider}")
|
|
137
|
+
|
|
138
|
+
# ── BUG 7 fix: coerce helpers tolerate uppercase / unexpected values ──────
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _coerce_tier(raw: str) -> QueryTier:
|
|
142
|
+
try:
|
|
143
|
+
return QueryTier(raw.strip().lower())
|
|
144
|
+
except ValueError:
|
|
145
|
+
return QueryTier.BALANCED
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _coerce_effort(raw: str) -> EffortLevel:
|
|
149
|
+
try:
|
|
150
|
+
return EffortLevel(raw.strip().lower())
|
|
151
|
+
except ValueError:
|
|
152
|
+
return EffortLevel.MEDIUM
|
|
153
|
+
|
|
154
|
+
def _parse(self, raw: str) -> ClassificationResult:
|
|
155
|
+
if raw.startswith("```"):
|
|
156
|
+
lines = raw.splitlines()
|
|
157
|
+
raw = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:])
|
|
158
|
+
try:
|
|
159
|
+
d = json.loads(raw)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
return ClassificationResult(
|
|
162
|
+
tier=QueryTier.BALANCED, effort=EffortLevel.MEDIUM,
|
|
163
|
+
facts_ratio=0.5, judgment_ratio=0.5,
|
|
164
|
+
reasoning=f"Parse error — defaulted. Raw: {raw[:80]}",
|
|
165
|
+
confidence=0.0,
|
|
166
|
+
)
|
|
167
|
+
return ClassificationResult(
|
|
168
|
+
tier=self._coerce_tier(d.get("tier", "balanced")),
|
|
169
|
+
effort=self._coerce_effort(d.get("effort", "medium")),
|
|
170
|
+
facts_ratio=float(d.get("facts_ratio", 0.5)),
|
|
171
|
+
judgment_ratio=float(d.get("judgment_ratio", 0.5)),
|
|
172
|
+
reasoning=d.get("reasoning", ""),
|
|
173
|
+
confidence=float(d.get("confidence", 0.8)),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _log(self, query: str, result: ClassificationResult) -> None:
|
|
177
|
+
entry = {
|
|
178
|
+
"timestamp": time.time(),
|
|
179
|
+
"query": query,
|
|
180
|
+
"tier": result.tier.value,
|
|
181
|
+
"effort": result.effort.value,
|
|
182
|
+
"facts_ratio": result.facts_ratio,
|
|
183
|
+
"judgment_ratio": result.judgment_ratio,
|
|
184
|
+
"confidence": result.confidence,
|
|
185
|
+
"reasoning": result.reasoning,
|
|
186
|
+
}
|
|
187
|
+
with open(self.config.log_path, "a", encoding="utf-8") as f:
|
|
188
|
+
f.write(json.dumps(entry) + "\n")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
# Option B: Fine-tuned DistilBERT (zero API cost, ~10ms, runs locally)
|
|
193
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
LABEL2TIER = {0: QueryTier.FAST, 1: QueryTier.BALANCED, 2: QueryTier.DEEP}
|
|
196
|
+
TIER2EFFORT = {
|
|
197
|
+
QueryTier.FAST: EffortLevel.LOW,
|
|
198
|
+
QueryTier.BALANCED: EffortLevel.MEDIUM,
|
|
199
|
+
QueryTier.DEEP: EffortLevel.HIGH,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class DistilBERTClassifier(BaseClassifier):
|
|
204
|
+
"""
|
|
205
|
+
Zero-cost local classifier using a fine-tuned DistilBERT model.
|
|
206
|
+
|
|
207
|
+
Usage:
|
|
208
|
+
clf = DistilBERTClassifier("./training/query-classifier-final")
|
|
209
|
+
router = QueryRouter(classifier=clf)
|
|
210
|
+
|
|
211
|
+
Train your model first using the scripts in /training/.
|
|
212
|
+
Requires: pip install transformers torch (or onnxruntime for ONNX)
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def __init__(self, model_dir: str, use_onnx: bool = False):
|
|
216
|
+
self.model_dir = model_dir
|
|
217
|
+
self.use_onnx = use_onnx
|
|
218
|
+
self._model = None
|
|
219
|
+
self._tokenizer = None
|
|
220
|
+
self._ort_session = None
|
|
221
|
+
self._load()
|
|
222
|
+
|
|
223
|
+
def _load(self):
|
|
224
|
+
if self.use_onnx:
|
|
225
|
+
self._load_onnx()
|
|
226
|
+
else:
|
|
227
|
+
self._load_pytorch()
|
|
228
|
+
|
|
229
|
+
def _load_pytorch(self):
|
|
230
|
+
try:
|
|
231
|
+
from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
|
|
232
|
+
import torch
|
|
233
|
+
self._tokenizer = DistilBertTokenizerFast.from_pretrained(self.model_dir)
|
|
234
|
+
self._model = DistilBertForSequenceClassification.from_pretrained(self.model_dir)
|
|
235
|
+
self._model.eval()
|
|
236
|
+
self._torch = torch
|
|
237
|
+
except ImportError:
|
|
238
|
+
raise ImportError(
|
|
239
|
+
"Install pytorch + transformers: pip install transformers torch"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _load_onnx(self):
|
|
243
|
+
try:
|
|
244
|
+
import onnxruntime as ort
|
|
245
|
+
from transformers import DistilBertTokenizerFast
|
|
246
|
+
self._tokenizer = DistilBertTokenizerFast.from_pretrained(self.model_dir)
|
|
247
|
+
onnx_path = f"{self.model_dir}/model.onnx"
|
|
248
|
+
self._ort_session = ort.InferenceSession(onnx_path)
|
|
249
|
+
except ImportError:
|
|
250
|
+
raise ImportError(
|
|
251
|
+
"Install onnxruntime: pip install onnxruntime"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def classify(self, query: str) -> ClassificationResult:
|
|
255
|
+
if self.use_onnx:
|
|
256
|
+
return self._classify_onnx(query)
|
|
257
|
+
return self._classify_pytorch(query)
|
|
258
|
+
|
|
259
|
+
def _classify_pytorch(self, query: str) -> ClassificationResult:
|
|
260
|
+
import torch
|
|
261
|
+
inputs = self._tokenizer(
|
|
262
|
+
query, return_tensors="pt", truncation=True,
|
|
263
|
+
padding="max_length", max_length=128,
|
|
264
|
+
)
|
|
265
|
+
with torch.no_grad():
|
|
266
|
+
logits = self._model(**inputs).logits
|
|
267
|
+
probs = torch.softmax(logits, dim=-1)[0]
|
|
268
|
+
label_id = int(probs.argmax())
|
|
269
|
+
confidence = float(probs[label_id])
|
|
270
|
+
tier = LABEL2TIER[label_id]
|
|
271
|
+
return ClassificationResult(
|
|
272
|
+
tier=tier, effort=TIER2EFFORT[tier],
|
|
273
|
+
facts_ratio=float(probs[0]),
|
|
274
|
+
judgment_ratio=float(probs[2]),
|
|
275
|
+
reasoning="DistilBERT local classifier",
|
|
276
|
+
confidence=confidence,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _classify_onnx(self, query: str) -> ClassificationResult:
|
|
280
|
+
import numpy as np
|
|
281
|
+
inputs = self._tokenizer(
|
|
282
|
+
query, return_tensors="np", truncation=True,
|
|
283
|
+
padding="max_length", max_length=128,
|
|
284
|
+
)
|
|
285
|
+
logits = self._ort_session.run(
|
|
286
|
+
None,
|
|
287
|
+
{"input_ids": inputs["input_ids"], "attention_mask": inputs["attention_mask"]},
|
|
288
|
+
)[0]
|
|
289
|
+
# BUG 8 fix: numerically stable softmax (subtract max before exp, keep axes)
|
|
290
|
+
logits_shifted = logits - logits.max(axis=-1, keepdims=True)
|
|
291
|
+
probs = np.exp(logits_shifted) / np.exp(logits_shifted).sum(axis=-1, keepdims=True)
|
|
292
|
+
label_id = int(probs.argmax())
|
|
293
|
+
confidence = float(probs[0][label_id])
|
|
294
|
+
tier = LABEL2TIER[label_id]
|
|
295
|
+
return ClassificationResult(
|
|
296
|
+
tier=tier, effort=TIER2EFFORT[tier],
|
|
297
|
+
facts_ratio=float(probs[0][0]),
|
|
298
|
+
judgment_ratio=float(probs[0][2]),
|
|
299
|
+
reasoning="DistilBERT ONNX local classifier",
|
|
300
|
+
confidence=confidence,
|
|
301
|
+
)
|
llm_router/mcp_server.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP server — package-level module.
|
|
3
|
+
|
|
4
|
+
Installed entry point: query-oracle-mcp
|
|
5
|
+
(runs via: python -m llm_router.mcp_server OR query-oracle-mcp)
|
|
6
|
+
|
|
7
|
+
Claude Code / Claude Desktop config (~/.claude/claude_desktop_config.json):
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"mcpServers": {
|
|
11
|
+
"query-oracle": {
|
|
12
|
+
"command": "query-oracle-mcp",
|
|
13
|
+
"env": {
|
|
14
|
+
"ANTHROPIC_API_KEY": "sk-ant-...",
|
|
15
|
+
"OPENAI_API_KEY": "sk-...",
|
|
16
|
+
"GEMINI_API_KEY": "AIza..."
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Or via uvx (no install needed):
|
|
23
|
+
"command": "uvx",
|
|
24
|
+
"args": ["--from", "query-oracle[mcp]", "query-oracle-mcp"]
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from mcp.server.fastmcp import FastMCP
|
|
30
|
+
except ImportError:
|
|
31
|
+
try:
|
|
32
|
+
from mcp.server import FastMCP # type: ignore[no-redef]
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError("Install the MCP SDK: pip install 'query-oracle[mcp]'")
|
|
35
|
+
|
|
36
|
+
from .models import RouterConfig
|
|
37
|
+
from .router import QueryRouter
|
|
38
|
+
from .providers import AnthropicProvider, OpenAIProvider, GeminiProvider
|
|
39
|
+
|
|
40
|
+
mcp = FastMCP("query-oracle")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _provider(name: str, config: RouterConfig):
|
|
44
|
+
n = name.lower()
|
|
45
|
+
if n == "openai":
|
|
46
|
+
return OpenAIProvider(config)
|
|
47
|
+
if n == "gemini":
|
|
48
|
+
return GeminiProvider(config)
|
|
49
|
+
return AnthropicProvider(config)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@mcp.tool()
|
|
53
|
+
def route(query: str, provider: str = "anthropic") -> str:
|
|
54
|
+
"""
|
|
55
|
+
Route a query to the optimal LLM model automatically.
|
|
56
|
+
|
|
57
|
+
Classifies the query on a facts-to-judgment spectrum and dispatches it to:
|
|
58
|
+
- FAST (Haiku / GPT-4o-mini / Gemini Flash) for factual lookups
|
|
59
|
+
- BALANCED (Sonnet / GPT-4o / Gemini Pro) for analysis
|
|
60
|
+
- DEEP (Opus+thinking / o1-high / Gemini Thinking) for strategy & design
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
query: The user question or task.
|
|
64
|
+
provider: LLM provider — anthropic | openai | gemini (default: anthropic)
|
|
65
|
+
"""
|
|
66
|
+
config = RouterConfig()
|
|
67
|
+
router = QueryRouter(config=config, provider=_provider(provider, config))
|
|
68
|
+
r = router.route(query)
|
|
69
|
+
meta = (
|
|
70
|
+
f"[query-oracle] tier={r.tier.value} | model={r.model_used} | "
|
|
71
|
+
f"thinking={r.extended_thinking_used} | {r.latency_ms:.0f}ms | "
|
|
72
|
+
f"cost=${r.cost_usd:.6f}\n\n"
|
|
73
|
+
)
|
|
74
|
+
return meta + r.content
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@mcp.tool()
|
|
78
|
+
def classify(query: str) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Classify a query without executing it — returns the routing decision only.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
query: The query to classify.
|
|
84
|
+
"""
|
|
85
|
+
config = RouterConfig()
|
|
86
|
+
router = QueryRouter(config=config)
|
|
87
|
+
c = router.classifier.classify(query)
|
|
88
|
+
return (
|
|
89
|
+
f"Tier: {c.tier.value}\n"
|
|
90
|
+
f"Effort: {c.effort.value}\n"
|
|
91
|
+
f"Facts ratio: {c.facts_ratio:.0%}\n"
|
|
92
|
+
f"Judgment ratio: {c.judgment_ratio:.0%}\n"
|
|
93
|
+
f"Confidence: {c.confidence:.0%}\n"
|
|
94
|
+
f"Reasoning: {c.reasoning}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main() -> None:
|
|
99
|
+
mcp.run()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
llm_router/models.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data models for query-oracle.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QueryTier(str, Enum):
|
|
11
|
+
FAST = "fast" # Factual, simple, low latency
|
|
12
|
+
BALANCED = "balanced" # Moderate reasoning
|
|
13
|
+
DEEP = "deep" # Heavy ideation, judgment, creativity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EffortLevel(str, Enum):
|
|
17
|
+
LOW = "low"
|
|
18
|
+
MEDIUM = "medium"
|
|
19
|
+
HIGH = "high"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProviderType(str, Enum):
|
|
23
|
+
ANTHROPIC = "anthropic"
|
|
24
|
+
OPENAI = "openai"
|
|
25
|
+
GEMINI = "gemini"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Pricing per 1 M tokens (input, output) in USD — update as providers change rates.
|
|
29
|
+
MODEL_PRICING: dict[str, tuple[float, float]] = {
|
|
30
|
+
# Anthropic
|
|
31
|
+
"claude-haiku-4-5": (0.80, 4.00),
|
|
32
|
+
"claude-sonnet-4-5": (3.00, 15.00),
|
|
33
|
+
"claude-opus-4-5": (15.00, 75.00),
|
|
34
|
+
# OpenAI
|
|
35
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
36
|
+
"gpt-4o": (2.50, 10.00),
|
|
37
|
+
"o1": (15.00, 60.00),
|
|
38
|
+
# Gemini
|
|
39
|
+
"gemini-1.5-flash": (0.075, 0.30),
|
|
40
|
+
"gemini-1.5-pro": (1.25, 5.00),
|
|
41
|
+
"gemini-2.0-flash-thinking-exp": (0.00, 0.00),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
|
46
|
+
"""Return estimated cost in USD for a single call."""
|
|
47
|
+
if model not in MODEL_PRICING:
|
|
48
|
+
return 0.0
|
|
49
|
+
in_rate, out_rate = MODEL_PRICING[model]
|
|
50
|
+
return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ClassificationResult:
|
|
55
|
+
tier: QueryTier
|
|
56
|
+
effort: EffortLevel
|
|
57
|
+
facts_ratio: float # 0.0 = pure judgment → 1.0 = pure facts
|
|
58
|
+
judgment_ratio: float
|
|
59
|
+
reasoning: str
|
|
60
|
+
confidence: float
|
|
61
|
+
# Classifier's own token usage (0 for local DistilBERT)
|
|
62
|
+
classifier_input_tokens: int = 0
|
|
63
|
+
classifier_output_tokens: int = 0
|
|
64
|
+
classifier_cost_usd: float = 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class RouterConfig:
|
|
69
|
+
"""
|
|
70
|
+
All routing behaviour is driven from here.
|
|
71
|
+
Swap model IDs, enable thinking, change token budgets —
|
|
72
|
+
without touching provider or classifier code.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# ── Anthropic model IDs ──────────────────────────────────────────────
|
|
76
|
+
anthropic_models: dict[QueryTier, str] = field(default_factory=lambda: {
|
|
77
|
+
QueryTier.FAST: "claude-haiku-4-5",
|
|
78
|
+
QueryTier.BALANCED: "claude-sonnet-4-5",
|
|
79
|
+
QueryTier.DEEP: "claude-opus-4-5",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
# ── OpenAI model IDs ─────────────────────────────────────────────────
|
|
83
|
+
openai_models: dict[QueryTier, str] = field(default_factory=lambda: {
|
|
84
|
+
QueryTier.FAST: "gpt-4o-mini",
|
|
85
|
+
QueryTier.BALANCED: "gpt-4o",
|
|
86
|
+
QueryTier.DEEP: "o1",
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
# ── Gemini model IDs ─────────────────────────────────────────────────
|
|
90
|
+
gemini_models: dict[QueryTier, str] = field(default_factory=lambda: {
|
|
91
|
+
QueryTier.FAST: "gemini-1.5-flash",
|
|
92
|
+
QueryTier.BALANCED: "gemini-1.5-pro",
|
|
93
|
+
QueryTier.DEEP: "gemini-2.0-flash-thinking-exp",
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
# ── Extended thinking (Anthropic only) ───────────────────────────────
|
|
97
|
+
extended_thinking_map: dict[QueryTier, bool] = field(default_factory=lambda: {
|
|
98
|
+
QueryTier.FAST: False,
|
|
99
|
+
QueryTier.BALANCED: False,
|
|
100
|
+
QueryTier.DEEP: True,
|
|
101
|
+
})
|
|
102
|
+
thinking_budget_map: dict[QueryTier, int] = field(default_factory=lambda: {
|
|
103
|
+
QueryTier.FAST: 0,
|
|
104
|
+
QueryTier.BALANCED: 0,
|
|
105
|
+
QueryTier.DEEP: 8000,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
# ── Reasoning effort (OpenAI o-series) ───────────────────────────────
|
|
109
|
+
openai_reasoning_effort: dict[QueryTier, Optional[str]] = field(default_factory=lambda: {
|
|
110
|
+
QueryTier.FAST: None,
|
|
111
|
+
QueryTier.BALANCED: None,
|
|
112
|
+
QueryTier.DEEP: "high",
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
# ── Max output tokens ────────────────────────────────────────────────
|
|
116
|
+
# DEEP must be > thinking_budget_map[DEEP] + answer headroom.
|
|
117
|
+
# Default: 8000 thinking + 8000 answer = 16000.
|
|
118
|
+
max_tokens_map: dict[QueryTier, int] = field(default_factory=lambda: {
|
|
119
|
+
QueryTier.FAST: 1024,
|
|
120
|
+
QueryTier.BALANCED: 4096,
|
|
121
|
+
QueryTier.DEEP: 16000,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
# ── Classifier settings ──────────────────────────────────────────────
|
|
125
|
+
classifier_provider: ProviderType = ProviderType.ANTHROPIC
|
|
126
|
+
classifier_model: str = "claude-haiku-4-5"
|
|
127
|
+
|
|
128
|
+
# ── Logging (builds your fine-tuning dataset passively) ─────────────
|
|
129
|
+
log_classifications: bool = True
|
|
130
|
+
log_path: str = "logs/classifications.jsonl"
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
for tier in QueryTier:
|
|
134
|
+
budget = self.thinking_budget_map.get(tier, 0)
|
|
135
|
+
max_t = self.max_tokens_map.get(tier, 0)
|
|
136
|
+
if budget > 0 and max_t <= budget:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"RouterConfig: max_tokens_map[{tier.value}]={max_t} must be "
|
|
139
|
+
f"greater than thinking_budget_map[{tier.value}]={budget}. "
|
|
140
|
+
f"Thinking tokens count against max_tokens, leaving only "
|
|
141
|
+
f"{max_t - budget} tokens for the actual answer. "
|
|
142
|
+
f"Set max_tokens_map[{tier.value}] to at least {budget + 1024}."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class RouterResponse:
|
|
148
|
+
content: str
|
|
149
|
+
tier: QueryTier
|
|
150
|
+
effort: EffortLevel
|
|
151
|
+
provider: ProviderType
|
|
152
|
+
model_used: str
|
|
153
|
+
extended_thinking_used: bool
|
|
154
|
+
classification: ClassificationResult
|
|
155
|
+
input_tokens: int = 0
|
|
156
|
+
output_tokens: int = 0
|
|
157
|
+
latency_ms: float = 0.0
|
|
158
|
+
cost_usd: float = 0.0 # completion-only cost
|
|
159
|
+
total_cost_usd: float = 0.0 # completion + classifier cost
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .base import BaseProvider, CompletionResult
|
|
2
|
+
from .anthropic_provider import AnthropicProvider
|
|
3
|
+
from .openai_provider import OpenAIProvider
|
|
4
|
+
from .gemini_provider import GeminiProvider
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BaseProvider", "CompletionResult",
|
|
8
|
+
"AnthropicProvider", "OpenAIProvider", "GeminiProvider",
|
|
9
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Anthropic provider — Haiku / Sonnet / Opus with optional extended thinking."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import anthropic
|
|
4
|
+
from ..models import EffortLevel, ProviderType, QueryTier, RouterConfig
|
|
5
|
+
from .base import BaseProvider, CompletionResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnthropicProvider(BaseProvider):
|
|
9
|
+
def __init__(self, config: RouterConfig, api_key: str | None = None):
|
|
10
|
+
self.config = config
|
|
11
|
+
self.client = anthropic.Anthropic(api_key=api_key) # falls back to env var
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def provider_type(self) -> ProviderType:
|
|
15
|
+
return ProviderType.ANTHROPIC
|
|
16
|
+
|
|
17
|
+
def complete(self, query: str, tier: QueryTier, effort: EffortLevel) -> CompletionResult:
|
|
18
|
+
model = self.config.anthropic_models[tier]
|
|
19
|
+
thinking = self.config.extended_thinking_map[tier]
|
|
20
|
+
budget = self.config.thinking_budget_map[tier]
|
|
21
|
+
max_tokens = self.config.max_tokens_map[tier]
|
|
22
|
+
|
|
23
|
+
if thinking and budget > 0:
|
|
24
|
+
return self._with_thinking(query, model, budget, max_tokens)
|
|
25
|
+
return self._standard(query, model, max_tokens)
|
|
26
|
+
|
|
27
|
+
def _standard(self, query: str, model: str, max_tokens: int) -> CompletionResult:
|
|
28
|
+
r = self.client.messages.create(
|
|
29
|
+
model=model, max_tokens=max_tokens,
|
|
30
|
+
messages=[{"role": "user", "content": query}],
|
|
31
|
+
)
|
|
32
|
+
text = "".join(b.text for b in r.content if hasattr(b, "text"))
|
|
33
|
+
return CompletionResult(
|
|
34
|
+
content=text, model_used=model, provider=ProviderType.ANTHROPIC,
|
|
35
|
+
extended_thinking_used=False,
|
|
36
|
+
input_tokens=r.usage.input_tokens, output_tokens=r.usage.output_tokens,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def _with_thinking(self, query: str, model: str, budget: int, max_tokens: int) -> CompletionResult:
|
|
40
|
+
r = self.client.messages.create(
|
|
41
|
+
model=model, max_tokens=max_tokens,
|
|
42
|
+
thinking={"type": "enabled", "budget_tokens": budget},
|
|
43
|
+
messages=[{"role": "user", "content": query}],
|
|
44
|
+
)
|
|
45
|
+
text = "".join(b.text for b in r.content if b.type == "text")
|
|
46
|
+
return CompletionResult(
|
|
47
|
+
content=text, model_used=model, provider=ProviderType.ANTHROPIC,
|
|
48
|
+
extended_thinking_used=True,
|
|
49
|
+
input_tokens=r.usage.input_tokens, output_tokens=r.usage.output_tokens,
|
|
50
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Abstract provider interface — implement this to add any LLM backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from ..models import EffortLevel, ProviderType, QueryTier
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CompletionResult:
|
|
10
|
+
content: str
|
|
11
|
+
model_used: str
|
|
12
|
+
provider: ProviderType
|
|
13
|
+
extended_thinking_used: bool
|
|
14
|
+
input_tokens: int
|
|
15
|
+
output_tokens: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseProvider(ABC):
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def complete(self, query: str, tier: QueryTier, effort: EffortLevel) -> CompletionResult: ...
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def provider_type(self) -> ProviderType: ...
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Gemini provider — gemini-flash / gemini-pro / gemini-thinking.
|
|
3
|
+
|
|
4
|
+
Requires: pip install google-generativeai
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from ..models import EffortLevel, ProviderType, QueryTier, RouterConfig
|
|
8
|
+
from .base import BaseProvider, CompletionResult
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import google.generativeai as genai
|
|
12
|
+
except ImportError:
|
|
13
|
+
genai = None # type: ignore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GeminiProvider(BaseProvider):
|
|
17
|
+
def __init__(self, config: RouterConfig, api_key: str | None = None):
|
|
18
|
+
if genai is None:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"Install the Gemini SDK: pip install google-generativeai"
|
|
21
|
+
)
|
|
22
|
+
import os
|
|
23
|
+
key = api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
24
|
+
if not key:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"Set GEMINI_API_KEY (or GOOGLE_API_KEY) environment variable."
|
|
27
|
+
)
|
|
28
|
+
genai.configure(api_key=key)
|
|
29
|
+
self.config = config
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def provider_type(self) -> ProviderType:
|
|
33
|
+
return ProviderType.GEMINI
|
|
34
|
+
|
|
35
|
+
def complete(self, query: str, tier: QueryTier, effort: EffortLevel) -> CompletionResult:
|
|
36
|
+
model_id = self.config.gemini_models[tier]
|
|
37
|
+
max_tokens = self.config.max_tokens_map[tier]
|
|
38
|
+
|
|
39
|
+
model = genai.GenerativeModel(model_id)
|
|
40
|
+
generation_config = genai.types.GenerationConfig(max_output_tokens=max_tokens)
|
|
41
|
+
|
|
42
|
+
response = model.generate_content(query, generation_config=generation_config)
|
|
43
|
+
text = response.text or ""
|
|
44
|
+
|
|
45
|
+
usage = getattr(response, "usage_metadata", None)
|
|
46
|
+
return CompletionResult(
|
|
47
|
+
content=text,
|
|
48
|
+
model_used=model_id,
|
|
49
|
+
provider=ProviderType.GEMINI,
|
|
50
|
+
extended_thinking_used=("thinking" in model_id),
|
|
51
|
+
input_tokens=getattr(usage, "prompt_token_count", 0),
|
|
52
|
+
output_tokens=getattr(usage, "candidates_token_count", 0),
|
|
53
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI provider — gpt-4o-mini / gpt-4o / o1.
|
|
3
|
+
|
|
4
|
+
Requires: pip install openai
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from ..models import EffortLevel, ProviderType, QueryTier, RouterConfig
|
|
8
|
+
from .base import BaseProvider, CompletionResult
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from openai import OpenAI
|
|
12
|
+
except ImportError:
|
|
13
|
+
OpenAI = None # type: ignore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenAIProvider(BaseProvider):
|
|
17
|
+
def __init__(self, config: RouterConfig, api_key: str | None = None):
|
|
18
|
+
if OpenAI is None:
|
|
19
|
+
raise ImportError("Install the openai package: pip install openai")
|
|
20
|
+
self.config = config
|
|
21
|
+
self.client = OpenAI(api_key=api_key) # falls back to OPENAI_API_KEY
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def provider_type(self) -> ProviderType:
|
|
25
|
+
return ProviderType.OPENAI
|
|
26
|
+
|
|
27
|
+
def complete(self, query: str, tier: QueryTier, effort: EffortLevel) -> CompletionResult:
|
|
28
|
+
model = self.config.openai_models[tier]
|
|
29
|
+
max_tokens = self.config.max_tokens_map[tier]
|
|
30
|
+
reasoning = self.config.openai_reasoning_effort.get(tier)
|
|
31
|
+
|
|
32
|
+
kwargs: dict = {
|
|
33
|
+
"model": model,
|
|
34
|
+
"messages": [{"role": "user", "content": query}],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# o-series models use max_completion_tokens and support reasoning_effort
|
|
38
|
+
if model.startswith("o"):
|
|
39
|
+
kwargs["max_completion_tokens"] = max_tokens
|
|
40
|
+
if reasoning:
|
|
41
|
+
kwargs["reasoning_effort"] = reasoning
|
|
42
|
+
else:
|
|
43
|
+
kwargs["max_tokens"] = max_tokens
|
|
44
|
+
|
|
45
|
+
r = self.client.chat.completions.create(**kwargs)
|
|
46
|
+
text = r.choices[0].message.content or ""
|
|
47
|
+
|
|
48
|
+
return CompletionResult(
|
|
49
|
+
content=text,
|
|
50
|
+
model_used=model,
|
|
51
|
+
provider=ProviderType.OPENAI,
|
|
52
|
+
extended_thinking_used=(model.startswith("o") and reasoning == "high"),
|
|
53
|
+
input_tokens=r.usage.prompt_tokens if r.usage else 0,
|
|
54
|
+
output_tokens=r.usage.completion_tokens if r.usage else 0,
|
|
55
|
+
)
|
llm_router/router.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QueryRouter — the single entry point.
|
|
3
|
+
|
|
4
|
+
from llm_router import QueryRouter
|
|
5
|
+
from llm_router.providers import OpenAIProvider
|
|
6
|
+
|
|
7
|
+
router = QueryRouter(provider=OpenAIProvider(config))
|
|
8
|
+
response = router.route("Design an intent-driven AI OS for non-technical users.")
|
|
9
|
+
print(response.content, response.tier, response.model_used)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import asyncio
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from .classifier import BaseClassifier, PromptClassifier
|
|
16
|
+
from .models import RouterConfig, RouterResponse, estimate_cost
|
|
17
|
+
from .providers.base import BaseProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QueryRouter:
|
|
21
|
+
"""
|
|
22
|
+
Classify → Route → Execute.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
config : RouterConfig, optional — override model IDs, budgets, etc.
|
|
27
|
+
provider : BaseProvider — which LLM backend to execute on
|
|
28
|
+
(AnthropicProvider / OpenAIProvider / GeminiProvider)
|
|
29
|
+
classifier : BaseClassifier, optional — swap in DistilBERTClassifier once trained
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
config: RouterConfig | None = None,
|
|
35
|
+
provider: BaseProvider | None = None,
|
|
36
|
+
classifier: BaseClassifier| None = None,
|
|
37
|
+
):
|
|
38
|
+
self.config = config or RouterConfig()
|
|
39
|
+
|
|
40
|
+
# Default provider: Anthropic (reads ANTHROPIC_API_KEY from env)
|
|
41
|
+
if provider is None:
|
|
42
|
+
from .providers.anthropic_provider import AnthropicProvider
|
|
43
|
+
provider = AnthropicProvider(self.config)
|
|
44
|
+
self.provider = provider
|
|
45
|
+
|
|
46
|
+
# Default classifier: prompt-based (uses same provider as classifier_provider in config)
|
|
47
|
+
self.classifier = classifier or PromptClassifier(self.config)
|
|
48
|
+
|
|
49
|
+
def route(self, query: str) -> RouterResponse:
|
|
50
|
+
t0 = time.monotonic()
|
|
51
|
+
|
|
52
|
+
classification = self.classifier.classify(query)
|
|
53
|
+
completion = self.provider.complete(
|
|
54
|
+
query, tier=classification.tier, effort=classification.effort
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
cost_usd = estimate_cost(
|
|
58
|
+
completion.model_used, completion.input_tokens, completion.output_tokens
|
|
59
|
+
)
|
|
60
|
+
return RouterResponse(
|
|
61
|
+
content=completion.content,
|
|
62
|
+
tier=classification.tier,
|
|
63
|
+
effort=classification.effort,
|
|
64
|
+
provider=completion.provider,
|
|
65
|
+
model_used=completion.model_used,
|
|
66
|
+
extended_thinking_used=completion.extended_thinking_used,
|
|
67
|
+
classification=classification,
|
|
68
|
+
input_tokens=completion.input_tokens,
|
|
69
|
+
output_tokens=completion.output_tokens,
|
|
70
|
+
latency_ms=(time.monotonic() - t0) * 1000,
|
|
71
|
+
cost_usd=cost_usd,
|
|
72
|
+
total_cost_usd=cost_usd + classification.classifier_cost_usd,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def async_route(self, query: str) -> RouterResponse:
|
|
76
|
+
"""Non-blocking version of route() — runs the synchronous call in a thread pool."""
|
|
77
|
+
return await asyncio.to_thread(self.route, query)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: query-oracle
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Automatic LLM model and effort-level routing based on query classification.
|
|
5
|
+
Author-email: Heman Phadvas <tailung.hp@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hemanpadvas2002/query-oracle
|
|
8
|
+
Project-URL: Repository, https://github.com/hemanpadvas2002/query-oracle
|
|
9
|
+
Project-URL: Issues, https://github.com/hemanpadvas2002/query-oracle/issues
|
|
10
|
+
Project-URL: Live API, https://query-oracle-production.up.railway.app
|
|
11
|
+
Keywords: llm,routing,anthropic,openai,gemini,claude,mcp,model-selection,cost-optimization
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: anthropic>=0.40.0
|
|
26
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
27
|
+
Provides-Extra: server
|
|
28
|
+
Requires-Dist: fastapi>=0.110.0; extra == "server"
|
|
29
|
+
Requires-Dist: uvicorn[standard]>=0.27.0; extra == "server"
|
|
30
|
+
Provides-Extra: mcp
|
|
31
|
+
Requires-Dist: mcp<2.0.0,>=1.0.0; extra == "mcp"
|
|
32
|
+
Provides-Extra: openai
|
|
33
|
+
Requires-Dist: openai>=1.30.0; extra == "openai"
|
|
34
|
+
Provides-Extra: gemini
|
|
35
|
+
Requires-Dist: google-generativeai>=0.7.0; extra == "gemini"
|
|
36
|
+
Provides-Extra: train
|
|
37
|
+
Requires-Dist: transformers>=4.40.0; extra == "train"
|
|
38
|
+
Requires-Dist: datasets>=2.19.0; extra == "train"
|
|
39
|
+
Requires-Dist: torch>=2.2.0; extra == "train"
|
|
40
|
+
Requires-Dist: scikit-learn>=1.4.0; extra == "train"
|
|
41
|
+
Requires-Dist: optimum>=1.19.0; extra == "train"
|
|
42
|
+
Requires-Dist: onnxruntime>=1.17.0; extra == "train"
|
|
43
|
+
Provides-Extra: all
|
|
44
|
+
Requires-Dist: query-oracle[gemini,mcp,openai,server]; extra == "all"
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# query-oracle
|
|
48
|
+
|
|
49
|
+
> Automatic LLM routing — the right model, the right effort, zero manual selection.
|
|
50
|
+
|
|
51
|
+
[](https://github.com/hemanpadvas2002/query-oracle/actions/workflows/ci.yml)
|
|
52
|
+
[](https://query-oracle-production.up.railway.app/health)
|
|
53
|
+
[](https://www.python.org/)
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Live demo
|
|
58
|
+
|
|
59
|
+
> **Illustrative** — actual output will vary by provider, model, and query.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from llm_router import QueryRouter
|
|
63
|
+
|
|
64
|
+
r = QueryRouter().route("Design a fault-tolerant event streaming architecture.")
|
|
65
|
+
print(f"tier={r.tier.value} model={r.model_used} thinking={r.extended_thinking_used}")
|
|
66
|
+
print(f"cost=${r.cost_usd:.5f} total=${r.total_cost_usd:.5f} latency={r.latency_ms:.0f}ms")
|
|
67
|
+
print(r.classification.reasoning)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## What it does
|
|
73
|
+
|
|
74
|
+
query-oracle sits in front of your LLM calls and automatically decides which model deserves the query. Factual lookups go to Haiku or GPT-4o-mini in under a second; open-ended design problems get routed to Opus with extended thinking or o1 with high reasoning effort. The classification itself costs a fraction of a cent and the routing decision is logged so you can fine-tune a local DistilBERT classifier later — eventually dropping the classification API cost to zero.
|
|
75
|
+
|
|
76
|
+
It ships as a Python library, a live REST API, a Claude Code MCP plugin, a VS Code / Cursor extension, an OpenAI Custom GPT Action, and a reusable GitHub Actions workflow. Pick whichever integration fits your stack.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Quick install — pick your platform
|
|
81
|
+
|
|
82
|
+
### Python library
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install "query-oracle"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
With optional extras:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install "query-oracle[server]" # FastAPI REST server
|
|
92
|
+
pip install "query-oracle[mcp]" # Claude Code / Desktop MCP plugin
|
|
93
|
+
pip install "query-oracle[openai]" # OpenAI provider
|
|
94
|
+
pip install "query-oracle[gemini]" # Gemini provider
|
|
95
|
+
pip install "query-oracle[all]" # everything
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Claude Code / Claude Desktop (MCP)
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install "query-oracle[mcp]"
|
|
102
|
+
claude mcp add query-oracle -- query-oracle-mcp
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then in any Claude conversation:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
route "Design a real-time fraud detection pipeline."
|
|
109
|
+
classify "What is the capital of France?"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Or add to `~/.claude/claude_desktop_config.json` manually:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"mcpServers": {
|
|
117
|
+
"query-oracle": {
|
|
118
|
+
"command": "query-oracle-mcp",
|
|
119
|
+
"env": { "ANTHROPIC_API_KEY": "sk-ant-..." }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### ChatGPT — Custom GPT Action
|
|
128
|
+
|
|
129
|
+
Paste this URL into **GPT builder → Configure → Actions → Import from URL**:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
https://query-oracle-production.up.railway.app/openapi.json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The server is live — no setup required. See [`openai-plugin/README.md`](openai-plugin/README.md) for how to self-host and add authentication.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### Cursor / VS Code Extension
|
|
140
|
+
|
|
141
|
+
The extension starts the REST server automatically — no manual uvicorn command.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd vscode-extension
|
|
145
|
+
npm install
|
|
146
|
+
npm run package # → query-oracle-1.0.0.vsix
|
|
147
|
+
code --install-extension query-oracle-1.0.0.vsix
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Press `Cmd/Ctrl+Shift+L` to open the query input. Responses appear in the **LLM Query Router** output panel with tier, model, latency, and cost.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### GitHub Copilot / Codex
|
|
155
|
+
|
|
156
|
+
Copy `.github/copilot-instructions.md` into your own repo's `.github/` folder. Copilot will read it automatically in VS Code and JetBrains and stop suggesting hard-coded model names.
|
|
157
|
+
|
|
158
|
+
For GitHub Actions / Copilot Workspace tasks:
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
jobs:
|
|
162
|
+
design:
|
|
163
|
+
uses: hemanpadvas2002/query-oracle/.github/workflows/copilot-router.yml@main
|
|
164
|
+
with:
|
|
165
|
+
query: "Design a zero-downtime database migration strategy."
|
|
166
|
+
secrets:
|
|
167
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
168
|
+
# outputs: response, tier, model_used, cost_usd
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## How routing works
|
|
174
|
+
|
|
175
|
+
| Tier | When | Anthropic | OpenAI | Gemini |
|
|
176
|
+
|---|---|---|---|---|
|
|
177
|
+
| `fast` | Factual lookups, maths, news, nutrition | claude-haiku-4-5 | gpt-4o-mini | gemini-1.5-flash |
|
|
178
|
+
| `balanced` | Analysis, explanations, moderate reasoning | claude-sonnet-4-5 | gpt-4o | gemini-1.5-pro |
|
|
179
|
+
| `deep` | Strategy, design, ethics, complex ideation | claude-opus-4-5 + thinking | o1 (high) | gemini-2.0-flash-thinking |
|
|
180
|
+
|
|
181
|
+
The classifier sends the query to a small model (Haiku by default) with a structured prompt that returns `tier`, `effort`, `facts_ratio`, `judgment_ratio`, `confidence`, and a one-sentence `reasoning`. Every result is logged to `logs/classifications.jsonl` — this passively builds the labelled dataset for local DistilBERT fine-tuning.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Response fields
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
RouterResponse(
|
|
189
|
+
content = "...",
|
|
190
|
+
tier = QueryTier.DEEP,
|
|
191
|
+
effort = EffortLevel.HIGH,
|
|
192
|
+
provider = ProviderType.ANTHROPIC,
|
|
193
|
+
model_used = "claude-opus-4-5",
|
|
194
|
+
extended_thinking_used = True,
|
|
195
|
+
input_tokens = 312,
|
|
196
|
+
output_tokens = 891,
|
|
197
|
+
latency_ms = 3241.4,
|
|
198
|
+
cost_usd = 0.02184, # completion cost only
|
|
199
|
+
total_cost_usd = 0.02188, # completion + classifier cost
|
|
200
|
+
classification = ClassificationResult(
|
|
201
|
+
tier = QueryTier.DEEP,
|
|
202
|
+
effort = EffortLevel.HIGH,
|
|
203
|
+
facts_ratio = 0.12,
|
|
204
|
+
judgment_ratio = 0.88,
|
|
205
|
+
confidence = 0.94,
|
|
206
|
+
reasoning = "Complex distributed systems design — strategy tier warranted",
|
|
207
|
+
classifier_input_tokens = 85,
|
|
208
|
+
classifier_output_tokens = 47,
|
|
209
|
+
classifier_cost_usd = 0.0000456,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Live REST API
|
|
217
|
+
|
|
218
|
+
**Base URL:** `https://query-oracle-production.up.railway.app`
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
# Health check (no auth required)
|
|
222
|
+
curl https://query-oracle-production.up.railway.app/health
|
|
223
|
+
# {"status":"ok"}
|
|
224
|
+
|
|
225
|
+
# Classify only (auth required when QUERY_ORACLE_API_KEY is set)
|
|
226
|
+
curl -s -X POST https://query-oracle-production.up.railway.app/classify \
|
|
227
|
+
-H "Authorization: Bearer $QUERY_ORACLE_API_KEY" \
|
|
228
|
+
-H "Content-Type: application/json" \
|
|
229
|
+
-d '{"query": "How many calories in a banana?"}' | jq .
|
|
230
|
+
|
|
231
|
+
# Route and get a full response
|
|
232
|
+
curl -s -X POST https://query-oracle-production.up.railway.app/route \
|
|
233
|
+
-H "Authorization: Bearer $QUERY_ORACLE_API_KEY" \
|
|
234
|
+
-H "Content-Type: application/json" \
|
|
235
|
+
-d '{"query": "Explain backpressure in reactive systems.", "provider": "anthropic"}' \
|
|
236
|
+
| jq '{tier, model_used, cost_usd, total_cost_usd, latency_ms}'
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Interactive docs: [`/docs`](https://query-oracle-production.up.railway.app/docs)
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Securing your deployment
|
|
244
|
+
|
|
245
|
+
By default the server runs in open mode (dev-only). Before exposing it publicly:
|
|
246
|
+
|
|
247
|
+
**1. Set an API key**
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
export QUERY_ORACLE_API_KEY="your-secret-key"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
All requests to `/route` and `/classify` then require:
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
Authorization: Bearer your-secret-key
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The `/health` endpoint stays open. The server logs a warning at startup if no key is set.
|
|
260
|
+
|
|
261
|
+
**2. Restrict CORS origins**
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
export CORS_ORIGINS="https://yourdomain.com,https://app.yourdomain.com"
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
When unset, CORS defaults to `*` (any origin). Set it to your specific domains in production.
|
|
268
|
+
|
|
269
|
+
**3. Rate limiting**
|
|
270
|
+
|
|
271
|
+
The `/route` endpoint enforces 30 requests per minute per client IP in-process. For heavier traffic, put an API gateway (e.g. Nginx, Cloudflare, Railway gateway) in front.
|
|
272
|
+
|
|
273
|
+
**Railway environment variables:**
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
QUERY_ORACLE_API_KEY → your secret key
|
|
277
|
+
ANTHROPIC_API_KEY → sk-ant-...
|
|
278
|
+
OPENAI_API_KEY → sk-... (optional)
|
|
279
|
+
GEMINI_API_KEY → AIza... (optional)
|
|
280
|
+
CORS_ORIGINS → https://yourdomain.com
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Train your own classifier (coming soon)
|
|
286
|
+
|
|
287
|
+
Once `logs/classifications.jsonl` accumulates ~500 entries, run `python training/train.py --data training/data/queries.csv` to fine-tune a local DistilBERT model. Swap it in with `QueryRouter(classifier=DistilBERTClassifier("training/query-classifier-final"))` and classification drops to ~10 ms with zero API cost.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Contributing
|
|
292
|
+
|
|
293
|
+
Open an issue or PR — the codebase is intentionally small. Adding a new provider means subclassing `BaseProvider` and implementing one method; the routing logic, classifier, and all integrations stay unchanged.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Licence
|
|
298
|
+
|
|
299
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
llm_router/__init__.py,sha256=VSDa8-gl7rDuFSmdhMiU4jJtDA6aNz_bhk-2vMUAjZw,477
|
|
2
|
+
llm_router/classifier.py,sha256=nJMaAdB-xRaeScbgmEmiFl2FgTOmuMhCTWyIeNguevs,12320
|
|
3
|
+
llm_router/mcp_server.py,sha256=0XpkHiNEGh6YyFpLEnNwr_kMeICjmI7nxh0t8py4o8Y,2912
|
|
4
|
+
llm_router/models.py,sha256=xb-GjPank1rWXNeiFnhVoH3L7-BRDTEemq_PuAt1Rvw,6393
|
|
5
|
+
llm_router/router.py,sha256=n4F2aY0yBevvdBZnOebxnEBtZK6THGZQFOIJnS7kPSE,2880
|
|
6
|
+
llm_router/providers/__init__.py,sha256=iZ3zMXUMYRaMpNPkSkgbqHQWGvGosFMW5pSCQhjPNLw,303
|
|
7
|
+
llm_router/providers/anthropic_provider.py,sha256=l2sV3N2dbcsTeslqc7pXgXxDBpZlz9fMjUlIHDuwjXI,2282
|
|
8
|
+
llm_router/providers/base.py,sha256=AEcukPfowcc8Fd45WCLfcj3EQreIQ2GpzrdotI0VFbk,708
|
|
9
|
+
llm_router/providers/gemini_provider.py,sha256=4FFZHE74vd18wuJjze1sphUSlwz4Yvi1hHUe0EU5peM,1912
|
|
10
|
+
llm_router/providers/openai_provider.py,sha256=LAQc9bG4e6Ve2H-jJbvnuBuy_B9crak4kavCrdRlSjg,1933
|
|
11
|
+
query_oracle-0.3.0.dist-info/licenses/LICENSE,sha256=Odee6ZHHkgDBoj11Z4LLnTeO84n-HqR-OMNSpn5SW-o,1070
|
|
12
|
+
query_oracle-0.3.0.dist-info/METADATA,sha256=R7CrmWfbqm44XcTpdKUrjyHnRRtCtIkIp8nCiLxpyo4,10642
|
|
13
|
+
query_oracle-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
query_oracle-0.3.0.dist-info/entry_points.txt,sha256=-C5SFkPBsUIPoRTjRtLS5FW1EWYOd_NPMIVS5kC5WsY,64
|
|
15
|
+
query_oracle-0.3.0.dist-info/top_level.txt,sha256=BJJqmvn9i4ijWlUe0K4GgX1wTgjOnnwRfXfbEKwn5Ao,11
|
|
16
|
+
query_oracle-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Heman Phadvas
|
|
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 @@
|
|
|
1
|
+
llm_router
|