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 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
+ ]
@@ -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
+ )
@@ -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
+ [![CI](https://github.com/hemanpadvas2002/query-oracle/actions/workflows/ci.yml/badge.svg)](https://github.com/hemanpadvas2002/query-oracle/actions/workflows/ci.yml)
52
+ [![Live](https://img.shields.io/badge/API-live%20on%20Railway-brightgreen)](https://query-oracle-production.up.railway.app/health)
53
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ query-oracle-mcp = llm_router.mcp_server:main
@@ -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