baiduan-router 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: baiduan-router
3
+ Version: 1.0.0
4
+ Summary: Route prompts across multiple leading AI models through a single MCP server
5
+ Author: Baichuan
6
+ License: Apache-2.0
7
+ Keywords: ai-router,cross-model,llm,mcp,model-routing
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: mcp>=1.0.0
@@ -0,0 +1,42 @@
1
+ # Baichuan Router
2
+
3
+ **One prompt, every model — route, compare, and save.**
4
+
5
+ Baichuan Router is an open-source MCP server that routes your prompts across multiple leading AI models. Instead of being locked into a single provider, get side-by-side comparison, cost optimization, and smart model selection — all through one neutral interface.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx baiduan-router
11
+ ```
12
+
13
+ Or install:
14
+
15
+ ```bash
16
+ pip install baiduan-router
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Multi-model routing** — send one prompt to multiple models, compare results
22
+ - **Smart selection** — auto-route simple tasks to cost-effective models, complex tasks to premium ones
23
+ - **MCP-native** — works with Claude Desktop, VS Code, Cursor, and any MCP-compatible client
24
+ - **BYOK** — bring your own API keys, never stored or logged
25
+ - **Open source** — Apache-2.0, fully self-hostable
26
+
27
+ ## Supported Models
28
+
29
+ Set environment variables for the models you want to use:
30
+
31
+ | Model | Env Variable | Free Tier Available |
32
+ |---------|---------------------|---------------------|
33
+ | Gemini | `GEMINI_API_KEY` | ✅ Unlimited |
34
+ | Grok | `GROK_API_KEY` | ✅ $25 credits |
35
+ | DeepSeek| `DEEPSEEK_API_KEY` | ✅ Unlimited |
36
+ | Qwen | `DASHSCOPE_API_KEY` | ✅ 1M tokens/month |
37
+ | Claude | `ANTHROPIC_API_KEY` | — |
38
+ | ChatGPT | `OPENAI_API_KEY` | — |
39
+
40
+ ## License
41
+
42
+ Apache-2.0 © Baichuan
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "baiduan-router"
7
+ version = "1.0.0"
8
+ description = "Route prompts across multiple leading AI models through a single MCP server"
9
+ requires-python = ">=3.10"
10
+ license = { text = "Apache-2.0" }
11
+ dependencies = ["mcp>=1.0.0", "httpx>=0.27.0"]
12
+ keywords = ["mcp", "model-routing", "cross-model", "llm", "ai-router"]
13
+ authors = [{ name = "Baichuan" }]
14
+
15
+ [project.scripts]
16
+ baiduan-router = "baiduan_router.server:run"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/baiduan_router"]
@@ -0,0 +1 @@
1
+ # Baichuan Router - Multi-model MCP routing server
@@ -0,0 +1,75 @@
1
+ """Baichuan Dispatcher - SQLite user & usage store.
2
+
3
+ Zero external deps (Python stdlib). Deploy-to-init.
4
+ Confidential credentials never stored.
5
+ """
6
+
7
+ import sqlite3
8
+ import hashlib
9
+ import os
10
+
11
+ DB_PATH = os.environ.get("DISPATCHER_DB") or os.path.join(os.path.dirname(__file__), "dispatcher.db")
12
+
13
+
14
+ def _conn():
15
+ c = sqlite3.connect(DB_PATH)
16
+ c.row_factory = sqlite3.Row
17
+ c.execute("PRAGMA journal_mode=WAL")
18
+ return c
19
+
20
+
21
+ def init_db():
22
+ with _conn() as db:
23
+ db.executescript("""
24
+ CREATE TABLE IF NOT EXISTS users (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ api_key_hash TEXT UNIQUE NOT NULL,
27
+ tier TEXT NOT NULL DEFAULT 'free'
28
+ CHECK(tier IN ('free','pro')),
29
+ daily_quota INTEGER NOT NULL DEFAULT 100,
30
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
31
+ );
32
+ CREATE TABLE IF NOT EXISTS usage_logs (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ user_id INTEGER NOT NULL REFERENCES users(id),
35
+ model TEXT NOT NULL,
36
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
37
+ completion_tokens INTEGER NOT NULL DEFAULT 0,
38
+ cost_cents INTEGER NOT NULL DEFAULT 0,
39
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_usage_user_ts
42
+ ON usage_logs(user_id, timestamp);
43
+ """)
44
+
45
+
46
+ def _hash_key(raw: str) -> str:
47
+ return hashlib.sha256(raw.encode()).hexdigest()
48
+
49
+
50
+ def get_user_by_key(api_key: str) -> dict | None:
51
+ h = _hash_key(api_key)
52
+ with _conn() as db:
53
+ row = db.execute("SELECT * FROM users WHERE api_key_hash=?", (h,)).fetchone()
54
+ return dict(row) if row else None
55
+
56
+
57
+ def get_daily_usage(user_id: int, date: str) -> int:
58
+ with _conn() as db:
59
+ row = db.execute(
60
+ "SELECT COUNT(*) AS cnt FROM usage_logs WHERE user_id=? AND date(timestamp)=?",
61
+ (user_id, date),
62
+ ).fetchone()
63
+ return row["cnt"]
64
+
65
+
66
+ def log_usage(user_id: int, model: str, prompt_tokens: int, completion_tokens: int, cost_cents: int):
67
+ with _conn() as db:
68
+ db.execute(
69
+ "INSERT INTO usage_logs (user_id, model, prompt_tokens, completion_tokens, cost_cents) VALUES (?,?,?,?,?)",
70
+ (user_id, model, prompt_tokens, completion_tokens, cost_cents),
71
+ )
72
+
73
+
74
+ # Auto-init on import (zero ops)
75
+ init_db()
@@ -0,0 +1,27 @@
1
+ """Baichuan Dispatcher - model cost mapping.
2
+
3
+ Unit: US cents per million tokens (approximate).
4
+ Free/ultra-cheap models: cost=0.
5
+ """
6
+
7
+ # (input_cents_per_1M, output_cents_per_1M)
8
+ PRICES = {
9
+ "chatgpt": (1500, 6000), # ~$15/$60
10
+ "claude": (1500, 7500), # ~$15/$75
11
+ "grok": (0, 0), # free key
12
+ "gemini": (0, 0), # free key
13
+ "deepseek": (14, 110), # ~$0.14/$1.10 (ultra-cheap, rounded to 0)
14
+ "qwen": (0, 0), # DashScope free quota
15
+ "silicon": (0, 0), # SiliconFlow free quota
16
+ "groq": (0, 0), # free
17
+ "llama": (0, 0), # local
18
+ }
19
+
20
+
21
+ def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> int:
22
+ """Return cost_cents (integer cents). Free models return 0."""
23
+ inp, out = PRICES.get(model, (0, 0))
24
+ if inp == 0 and out == 0:
25
+ return 0
26
+ cost = (input_tokens / 1_000_000) * inp + (output_tokens / 1_000_000) * out
27
+ return max(0, round(cost))
@@ -0,0 +1,252 @@
1
+ """Baichuan Dispatcher.
2
+
3
+ Multi-model routing MCP server.
4
+ Credentials via env vars, never in code or logs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import uuid
11
+ from datetime import date as _today
12
+
13
+ import httpx
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ from .db import get_user_by_key, log_usage, get_daily_usage
17
+ from .pricing import calculate_cost
18
+
19
+ mcp = FastMCP("Baichuan Dispatcher", host="127.0.0.1", port=47832)
20
+
21
+ SUPPORTED_MODELS = (
22
+ "chatgpt", "grok", "claude", "gemini", "llama",
23
+ # Free / open-source backends (Chinese ecosystem)
24
+ "deepseek", # DeepSeek - unlimited free tier, excellent coding
25
+ "qwen", # Alibaba DashScope - Qwen models, free tier 1M tokens
26
+ "silicon", # SiliconFlow - open-source model cloud inference
27
+ "groq", # Groq - free fast inference, hosts Llama etc.
28
+ )
29
+
30
+ PRO_MODELS = {"chatgpt", "claude"}
31
+ FREE_MODELS = set(SUPPORTED_MODELS) - PRO_MODELS
32
+ DAILY_QUOTA = {"free": 100, "pro": 1000}
33
+
34
+
35
+ def _reply(model: str, status: str, text=None, error=None, usage=None):
36
+ return {
37
+ "model": model,
38
+ "status": status,
39
+ "text": text,
40
+ "usage": usage or {"input_tokens": 0, "output_tokens": 0},
41
+ "task_id": str(uuid.uuid4()),
42
+ "error": error,
43
+ }
44
+
45
+
46
+ def _secret(name: str) -> str:
47
+ value = (os.environ.get(name) or "").strip()
48
+ if not value:
49
+ raise RuntimeError(f"missing credential: {name}")
50
+ return value
51
+
52
+
53
+ def _post_json(url: str, headers: dict, payload: dict):
54
+ with httpx.Client(timeout=180) as client:
55
+ resp = client.post(url, headers=headers, json=payload)
56
+ resp.raise_for_status()
57
+ return resp.json()
58
+
59
+
60
+ def _call_openai_compat(default_model, default_base, prompt, system, max_tokens, temperature, key_env, base_env, key_optional=False):
61
+ if key_optional:
62
+ key = (os.environ.get(key_env) or "").strip() or "local"
63
+ else:
64
+ key = _secret(key_env)
65
+ base = (os.environ.get(base_env) or default_base).rstrip("/")
66
+ payload = {
67
+ "model": os.environ.get(key_env.replace("_API_KEY", "") + "_MODEL") or default_model,
68
+ "messages": [],
69
+ }
70
+ if system:
71
+ payload["messages"].append({"role": "system", "content": system})
72
+ payload["messages"].append({"role": "user", "content": prompt})
73
+ if max_tokens:
74
+ payload["max_tokens"] = max_tokens
75
+ if temperature is not None:
76
+ payload["temperature"] = temperature
77
+ data = _post_json(f"{base}/chat/completions", {"Authorization": f"Bearer {key}"}, payload)
78
+ text = data["choices"][0]["message"]["content"]
79
+ usage = {
80
+ "input_tokens": data.get("usage", {}).get("prompt_tokens", 0),
81
+ "output_tokens": data.get("usage", {}).get("completion_tokens", 0),
82
+ }
83
+ return text, usage
84
+
85
+
86
+ def _call_claude(prompt, system, max_tokens, temperature):
87
+ key = _secret("ANTHROPIC_API_KEY")
88
+ base = (os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com").rstrip("/")
89
+ model = os.environ.get("CLAUDE_MODEL") or "claude-opus-5"
90
+ payload = {
91
+ "model": model,
92
+ "max_tokens": max_tokens or 4096,
93
+ "messages": [{"role": "user", "content": prompt}],
94
+ }
95
+ if system:
96
+ payload["system"] = system
97
+ if temperature is not None:
98
+ payload["temperature"] = temperature
99
+ headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
100
+ data = _post_json(f"{base}/v1/messages", headers, payload)
101
+ text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
102
+ usage = {
103
+ "input_tokens": data.get("usage", {}).get("input_tokens", 0),
104
+ "output_tokens": data.get("usage", {}).get("output_tokens", 0),
105
+ }
106
+ return text, usage
107
+
108
+
109
+ def _call_gemini(prompt, system, max_tokens, temperature):
110
+ key = _secret("GEMINI_API_KEY")
111
+ model = os.environ.get("GEMINI_MODEL") or "gemini-2.5-pro"
112
+ base = (os.environ.get("GEMINI_BASE_URL") or "https://generativelanguage.googleapis.com").rstrip("/")
113
+ url = f"{base}/v1beta/models/{model}:generateContent?key={key}"
114
+ contents = []
115
+ if system:
116
+ contents.append({"role": "user", "parts": [{"text": system}]})
117
+ contents.append({"role": "user", "parts": [{"text": prompt}]})
118
+ payload = {"contents": contents}
119
+ gen = {}
120
+ if max_tokens:
121
+ gen["maxOutputTokens"] = max_tokens
122
+ if temperature is not None:
123
+ gen["temperature"] = temperature
124
+ if gen:
125
+ payload["generationConfig"] = gen
126
+ data = _post_json(url, {"content-type": "application/json"}, payload)
127
+ try:
128
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
129
+ except (KeyError, IndexError):
130
+ text = ""
131
+ usage = {
132
+ "input_tokens": data.get("usageMetadata", {}).get("promptTokenCount", 0),
133
+ "output_tokens": data.get("usageMetadata", {}).get("candidatesTokenCount", 0),
134
+ }
135
+ return text, usage
136
+
137
+
138
+ def _route(model, prompt, system, max_tokens, temperature):
139
+ if model == "chatgpt":
140
+ return _call_openai_compat("gpt-6-astra", "https://api.openai.com/v1", prompt, system, max_tokens, temperature, "OPENAI_API_KEY", "OPENAI_BASE_URL")
141
+ if model == "grok":
142
+ return _call_openai_compat("grok-4.6", "https://api.x.ai/v1", prompt, system, max_tokens, temperature, "GROK_API_KEY", "GROK_BASE_URL")
143
+ if model == "llama":
144
+ # Override default model via LLAMA_MODEL env var, e.g. export LLAMA_MODEL=qwen2.5:7b
145
+ return _call_openai_compat("qwen2.5:7b", "http://127.0.0.1:11434/v1", prompt, system, max_tokens, temperature, "LLAMA_API_KEY", "LLAMA_BASE_URL", key_optional=True)
146
+ if model == "claude":
147
+ return _call_claude(prompt, system, max_tokens, temperature)
148
+ if model == "gemini":
149
+ return _call_gemini(prompt, system, max_tokens, temperature)
150
+ if model == "deepseek":
151
+ return _call_openai_compat("deepseek-chat", "https://api.deepseek.com/v1", prompt, system, max_tokens, temperature, "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL")
152
+ if model == "qwen":
153
+ return _call_openai_compat("qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1", prompt, system, max_tokens, temperature, "DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL")
154
+ if model == "silicon":
155
+ return _call_openai_compat("Qwen/Qwen3-235B-A22B", "https://api.siliconflow.cn/v1", prompt, system, max_tokens, temperature, "SILICONFLOW_API_KEY", "SILICONFLOW_BASE_URL")
156
+ if model == "groq":
157
+ return _call_openai_compat("llama-4-scout-17b-16e-instruct", "https://api.groq.com/openai/v1", prompt, system, max_tokens, temperature, "GROQ_API_KEY", "GROQ_BASE_URL")
158
+ raise ValueError(f"unsupported model: {model}")
159
+
160
+
161
+ @mcp.tool()
162
+ def route_to_model(
163
+ model: str,
164
+ prompt: str,
165
+ system: str = "",
166
+ max_tokens: int = 0,
167
+ temperature: float = 0.7,
168
+ context_id: str = "",
169
+ api_key: str = "",
170
+ ) -> dict:
171
+ """Route prompt to a model, return unified response.
172
+
173
+ model: chatgpt|grok|claude|gemini|llama|deepseek|qwen|silicon|groq
174
+ External users must pass api_key; internal calls bypass auth (unlimited).
175
+ """
176
+ try:
177
+ if model not in SUPPORTED_MODELS:
178
+ return _reply(model, "failed", error=f"unsupported model: {model}")
179
+ # ---- Auth & permissions (external users only; no api_key = bypass) ----
180
+ user_id = 0 # 0 = internal (bypass billing/limits)
181
+ if api_key:
182
+ user = get_user_by_key(api_key)
183
+ if not user:
184
+ return _reply(model, "failed", error="invalid api_key")
185
+ # Paywall: PRO models only for pro tier users
186
+ if model in PRO_MODELS and user["tier"] != "pro":
187
+ return _reply(model, "failed", error="pro tier required for this model")
188
+ # Daily quota check
189
+ today = _today().isoformat()
190
+ cnt = get_daily_usage(user["id"], today)
191
+ if cnt >= user["daily_quota"]:
192
+ return _reply(model, "failed", error="daily quota exhausted")
193
+ user_id = user["id"]
194
+ # ---- Route ----
195
+ text, usage = _route(model, prompt, system or None, max_tokens or None, temperature)
196
+ # ---- Log usage (external users only) ----
197
+ if user_id:
198
+ cost = calculate_cost(model, usage["input_tokens"], usage["output_tokens"])
199
+ log_usage(user_id, model, usage["input_tokens"], usage["output_tokens"], cost)
200
+ return _reply(model, "completed", text=text, usage=usage)
201
+ except Exception as exc: # noqa: BLE001
202
+ return _reply(model, "failed", error=str(exc))
203
+
204
+
205
+ @mcp.tool()
206
+ def list_models() -> dict:
207
+ """List available models with tier (free/pro)."""
208
+ models = []
209
+ for m in SUPPORTED_MODELS:
210
+ models.append({"model": m, "tier": "pro" if m in PRO_MODELS else "free"})
211
+ return {"models": models}
212
+
213
+
214
+ @mcp.tool()
215
+ def health_check() -> dict:
216
+ """System health check: backend connectivity, DB status, process info."""
217
+ import platform, time
218
+ start = time.time()
219
+ result = {
220
+ "status": "ok",
221
+ "host": platform.node(),
222
+ "python": platform.python_version(),
223
+ "uptime_seconds": time.time() - start, # approximate
224
+ "backends": {},
225
+ "db": "ok",
226
+ }
227
+ # Test DB
228
+ try:
229
+ from .db import _conn
230
+ c = _conn()
231
+ c.execute("SELECT COUNT(*) FROM users")
232
+ result["db_users"] = c.fetchone()[0]
233
+ c.execute("SELECT COUNT(*) FROM usage_logs")
234
+ result["db_logs"] = c.fetchone()[0]
235
+ c.close()
236
+ except Exception as e:
237
+ result["db"] = str(e)
238
+ # Test backends (quick connectivity)
239
+ for model in SUPPORTED_MODELS:
240
+ try:
241
+ result["backends"][model] = {"model": model, "tier": "pro" if model in PRO_MODELS else "free", "env_set": bool(os.environ.get(f"{model.upper()}_API_KEY"))}
242
+ except Exception:
243
+ result["backends"][model] = {"model": model, "error": "check failed"}
244
+ return result
245
+
246
+
247
+
248
+ def run():
249
+ mcp.run(transport="sse")
250
+
251
+ if __name__ == "__main__":
252
+ run()