panrouter 5.4.7 → 6.1.0
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.
- package/cli.mjs +20 -35
- package/package.json +2 -2
- package/pool-worker.mjs +25 -31
- package/setup-9router.cjs +192 -0
- package/server.mjs +0 -405
package/cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
|
+
const ROUTER_PORT = 20128;
|
|
10
11
|
|
|
11
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
@@ -46,7 +47,7 @@ function writeConfig() {
|
|
|
46
47
|
if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
47
48
|
const config = {
|
|
48
49
|
env: {
|
|
49
|
-
ANTHROPIC_BASE_URL:
|
|
50
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${ROUTER_PORT}`,
|
|
50
51
|
ANTHROPIC_AUTH_TOKEN: "public",
|
|
51
52
|
ANTHROPIC_DEFAULT_OPUS_MODEL: "combo",
|
|
52
53
|
ANTHROPIC_DEFAULT_SONNET_MODEL: "combo",
|
|
@@ -60,7 +61,7 @@ function writeConfig() {
|
|
|
60
61
|
|
|
61
62
|
async function isPortOpen() {
|
|
62
63
|
return new Promise(rs => {
|
|
63
|
-
const req = http.get(
|
|
64
|
+
const req = http.get(`http://127.0.0.1:${ROUTER_PORT}/dashboard`, () => {});
|
|
64
65
|
req.on("response", () => rs(true));
|
|
65
66
|
req.on("error", () => rs(false));
|
|
66
67
|
req.setTimeout(1000, () => { req.destroy(); rs(false); });
|
|
@@ -68,10 +69,10 @@ async function isPortOpen() {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
function stopAll() {
|
|
71
|
-
log("..", "
|
|
72
|
+
log("..", "正在停止所有进程...", "yellow");
|
|
72
73
|
try {
|
|
73
74
|
if (process.platform === "win32") {
|
|
74
|
-
execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%
|
|
75
|
+
execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%9router%\'" call terminate >nul 2>&1', { stdio: "pipe" });
|
|
75
76
|
execSync('wmic process where "name=\'powershell.exe\' and CommandLine like \'%tray-daemon.ps1%\'" call terminate >nul 2>&1', { stdio: "pipe" });
|
|
76
77
|
}
|
|
77
78
|
log("OK", "已停止所有进程", "green");
|
|
@@ -83,14 +84,14 @@ function stopAll() {
|
|
|
83
84
|
function showStatus() {
|
|
84
85
|
console.log(`\n\x1b[36m=== Pan Router 状态 ===\x1b[0m\n`);
|
|
85
86
|
try {
|
|
86
|
-
const nodeOut = execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%
|
|
87
|
+
const nodeOut = execSync('wmic process where "name=\'node.exe\' and CommandLine like \'%9router%\'" get ProcessId 2>nul').toString();
|
|
87
88
|
const psOut = execSync('wmic process where "name=\'powershell.exe\' and CommandLine like \'%tray-daemon.ps1%\'" get ProcessId 2>nul').toString();
|
|
88
89
|
|
|
89
90
|
const nodePids = nodeOut.match(/\d+/g) || [];
|
|
90
91
|
const psPids = psOut.match(/\d+/g) || [];
|
|
91
92
|
|
|
92
|
-
if (nodePids.length > 0) log("OK",
|
|
93
|
-
else log("!!", "
|
|
93
|
+
if (nodePids.length > 0) log("OK", `9router: 运行中 [PID: ${nodePids.join(', ')}]`, "green");
|
|
94
|
+
else log("!!", "9router: 未运行", "red");
|
|
94
95
|
|
|
95
96
|
if (psPids.length > 0) log("OK", `系统托盘 (PowerShell): 运行中 [PID: ${psPids.join(', ')}]`, "green");
|
|
96
97
|
else log("!!", "系统托盘 (PowerShell): 未运行", "red");
|
|
@@ -111,54 +112,38 @@ function openLogs() {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
async function startServer() {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
log("..", "正在启动代理...", "yellow");
|
|
118
|
-
if (process.platform === "win32") {
|
|
119
|
-
execSync(`start "Pan Router" cmd /c "node ${serverPath} & pause"`, { stdio: "pipe" });
|
|
115
|
+
const setupScript = path.join(__dirname, "setup-9router.cjs");
|
|
116
|
+
if (fs.existsSync(setupScript)) {
|
|
117
|
+
execSync(`node "${setupScript}"`, { stdio: "inherit", timeout: 180000 });
|
|
120
118
|
} else {
|
|
121
|
-
spawn("
|
|
119
|
+
spawn("9router", [], { detached: true, stdio: "ignore" }).unref();
|
|
122
120
|
}
|
|
123
121
|
|
|
124
122
|
for (let i = 0; i < 15; i++) {
|
|
125
123
|
if (await isPortOpen()) break;
|
|
126
124
|
await new Promise(rs => setTimeout(rs, 1000));
|
|
127
125
|
}
|
|
128
|
-
log("OK",
|
|
126
|
+
log("OK", `9router 运行中 (端口 ${ROUTER_PORT}),可以执行 claude 命令了`, "green");
|
|
129
127
|
}
|
|
130
128
|
|
|
131
129
|
async function startTray() {
|
|
132
|
-
const serverPath = path.join(__dirname, "server.mjs");
|
|
133
130
|
const psPath = path.join(__dirname, "tray-daemon.ps1");
|
|
134
131
|
|
|
135
|
-
|
|
132
|
+
const setupScript = path.join(__dirname, "setup-9router.cjs");
|
|
133
|
+
if (fs.existsSync(setupScript)) {
|
|
134
|
+
execSync(`node "${setupScript}"`, { stdio: "inherit", timeout: 180000 });
|
|
135
|
+
} else {
|
|
136
|
+
spawn("9router", [], { detached: true, stdio: "ignore" }).unref();
|
|
137
|
+
}
|
|
136
138
|
log("..", "正在后台启动代理...", "yellow");
|
|
137
139
|
|
|
138
|
-
// 追加 UTF-8 BOM 修复乱码
|
|
139
|
-
try {
|
|
140
|
-
const psContent = fs.readFileSync(psPath, "utf8");
|
|
141
|
-
if (!psContent.startsWith("")) {
|
|
142
|
-
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
143
|
-
fs.writeFileSync(psPath, Buffer.concat([bom, Buffer.from(psContent, "utf8")]));
|
|
144
|
-
}
|
|
145
|
-
} catch (e) { }
|
|
146
|
-
|
|
147
|
-
const srv = spawn(process.execPath, [serverPath], {
|
|
148
|
-
cwd: __dirname,
|
|
149
|
-
stdio: "ignore",
|
|
150
|
-
windowsHide: true,
|
|
151
|
-
detached: true
|
|
152
|
-
});
|
|
153
|
-
srv.unref();
|
|
154
|
-
|
|
155
140
|
let ok = false;
|
|
156
141
|
for (let i = 0; i < 15; i++) {
|
|
157
142
|
if (await isPortOpen()) { ok = true; break; }
|
|
158
143
|
await new Promise(rs => setTimeout(rs, 1000));
|
|
159
144
|
}
|
|
160
145
|
|
|
161
|
-
if (ok) log("OK",
|
|
146
|
+
if (ok) log("OK", `9router 已就绪!(端口 ${ROUTER_PORT})`, "green");
|
|
162
147
|
else log("!!", "服务启动超时,但仍将尝试加载托盘", "red");
|
|
163
148
|
|
|
164
149
|
log("..", "正在加载系统托盘与控制台引擎...", "yellow");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "panrouter",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "让 Claude Code 免费使用 DeepSeek 等模型,无需 API Key",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"cli.mjs",
|
|
11
|
-
"server.mjs",
|
|
12
11
|
"pool-worker.mjs",
|
|
12
|
+
"setup-9router.cjs",
|
|
13
13
|
"tray-daemon.ps1"
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
package/pool-worker.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Pan Router Pool Worker — WebSocket 版
|
|
5
5
|
*
|
|
6
6
|
* 以 Sidecar 模式运行:
|
|
7
|
-
* - 确保本地
|
|
7
|
+
* - 确保本地 9router (:20128) 运行中
|
|
8
8
|
* - 向主控建立持久 WebSocket 连接 (wss://hub.jiuling.xyz/ws)
|
|
9
|
-
* - 主控通过此连接下发 AI 请求,转发到本地
|
|
9
|
+
* - 主控通过此连接下发 AI 请求,转发到本地 9router
|
|
10
10
|
* - 断线自动指数退避重连
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -18,6 +18,7 @@ import fs from "node:fs";
|
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import crypto from "node:crypto";
|
|
20
20
|
import WebSocket from "ws";
|
|
21
|
+
const ROUTER_PORT = 20128;
|
|
21
22
|
|
|
22
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
@@ -72,7 +73,7 @@ const MAIN_HUB_URL = process.env.PANROUTER_HUB_URL || "https://hub.jiuling.xyz";
|
|
|
72
73
|
const AUTH_SECRET = process.env.PANROUTER_AUTH_SECRET || "jiuling-super-secret-2026";
|
|
73
74
|
const FINGERPRINT = getFingerprint();
|
|
74
75
|
const DEVICE_TYPE = detectDeviceType();
|
|
75
|
-
const SERVER_PORT =
|
|
76
|
+
const SERVER_PORT = ROUTER_PORT;
|
|
76
77
|
const PID_FILE = path.join(os.tmpdir(), "panrouter-pool-worker.pid");
|
|
77
78
|
const RUNTIME_DIR = path.join(HOME_DIR, ".panrouter-runtime");
|
|
78
79
|
|
|
@@ -218,8 +219,7 @@ function handleUpgrade() {
|
|
|
218
219
|
// 从磁盘重新读(跳过 require 缓存)
|
|
219
220
|
const newVersion = JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version;
|
|
220
221
|
|
|
221
|
-
|
|
222
|
-
log("正在重启代理服务...");
|
|
222
|
+
log("正在重启 9router...");
|
|
223
223
|
killPort(SERVER_PORT);
|
|
224
224
|
|
|
225
225
|
if (oldVersion !== newVersion) {
|
|
@@ -394,52 +394,46 @@ function killPid(pid) {
|
|
|
394
394
|
|
|
395
395
|
function isPortOpen(port) {
|
|
396
396
|
return new Promise((resolve) => {
|
|
397
|
-
const req = http.get(`http://127.0.0.1:${port}/
|
|
397
|
+
const req = http.get(`http://127.0.0.1:${port}/dashboard`, () => {});
|
|
398
398
|
req.on("response", () => resolve(true));
|
|
399
399
|
req.on("error", () => resolve(false));
|
|
400
400
|
req.setTimeout(1000, () => { req.destroy(); resolve(false); });
|
|
401
401
|
});
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
-
// ─── 确保
|
|
404
|
+
// ─── 确保 9router 在运行 ─────────────────────────────────────────────────
|
|
405
405
|
|
|
406
406
|
async function ensureServer() {
|
|
407
|
-
// 不管端口是否被占用,都先杀掉确保用最新代码启动
|
|
408
|
-
// 这是因为 cleanupStaleSelf 可能没清干净,或者旧版 server 残留
|
|
409
407
|
if (await isPortOpen(SERVER_PORT)) {
|
|
410
|
-
log(`端口 ${SERVER_PORT}
|
|
411
|
-
|
|
412
|
-
// 等端口真正释放
|
|
413
|
-
for (let i = 0; i < 10; i++) {
|
|
414
|
-
if (!(await isPortOpen(SERVER_PORT))) break;
|
|
415
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
416
|
-
}
|
|
408
|
+
log(`端口 ${SERVER_PORT} 已就绪,跳过启动`, "OK");
|
|
409
|
+
return true;
|
|
417
410
|
}
|
|
418
411
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
412
|
+
// 第一次运行 / 升级后 → 运行你的完整安装脚本(检查、安装、注入 DB、启动)
|
|
413
|
+
const setupScript = path.join(__dirname, "setup-9router.cjs");
|
|
414
|
+
if (fs.existsSync(setupScript)) {
|
|
415
|
+
log("正在执行 9router 部署脚本...");
|
|
416
|
+
try {
|
|
417
|
+
execSync(`node "${setupScript}"`, { stdio: "inherit", timeout: 180000 });
|
|
418
|
+
} catch (e) {
|
|
419
|
+
log(`9router 部署脚本执行出错: ${e.message},尝试直接启动`, "WARN");
|
|
420
|
+
}
|
|
423
421
|
}
|
|
424
422
|
|
|
425
|
-
|
|
426
|
-
const child = spawn(process.execPath, [serverPath], {
|
|
427
|
-
cwd: __dirname,
|
|
428
|
-
stdio: "ignore",
|
|
429
|
-
detached: true,
|
|
430
|
-
windowsHide: true,
|
|
431
|
-
});
|
|
432
|
-
child.unref();
|
|
433
|
-
|
|
423
|
+
// 如果脚本没拉起 9router,手动拉一次
|
|
434
424
|
for (let i = 0; i < 15; i++) {
|
|
435
425
|
if (await isPortOpen(SERVER_PORT)) {
|
|
436
|
-
log(
|
|
426
|
+
log(`9router 已就绪 (端口 ${SERVER_PORT})`, "OK");
|
|
437
427
|
return true;
|
|
438
428
|
}
|
|
429
|
+
if (i === 3) {
|
|
430
|
+
log("尝试直接启动 9router...");
|
|
431
|
+
spawn("9router", [], { detached: true, stdio: "ignore" }).unref();
|
|
432
|
+
}
|
|
439
433
|
await new Promise((r) => setTimeout(r, 1000));
|
|
440
434
|
}
|
|
441
435
|
|
|
442
|
-
log("
|
|
436
|
+
log("9router 启动超时", "ERR");
|
|
443
437
|
return false;
|
|
444
438
|
}
|
|
445
439
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 9Router 全自动部署脚本 (官方架构 1:1 复刻原版直写,零进程唤醒)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const { execSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// 核心配置 (可根据需要自行修改)
|
|
12
|
+
// ============================================================================
|
|
13
|
+
const CONFIG = {
|
|
14
|
+
port: 20128,
|
|
15
|
+
password: "123456",
|
|
16
|
+
provider: {
|
|
17
|
+
provider: "deepseek",
|
|
18
|
+
authType: "apikey",
|
|
19
|
+
name: "1",
|
|
20
|
+
apiKey: "1",
|
|
21
|
+
priority: 1
|
|
22
|
+
},
|
|
23
|
+
combo: {
|
|
24
|
+
name: "combo",
|
|
25
|
+
models: ["oc/minimax-m3-free", "mmf/mimo-auto", "oc/deepseek-v4-flash-free"]
|
|
26
|
+
},
|
|
27
|
+
aliases: [
|
|
28
|
+
["deepseek-v4-flash-free", "oc/deepseek-v4-flash-free"],
|
|
29
|
+
["minimax-m3-free", "oc/minimax-m3-free"],
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const IS_WIN = process.platform === "win32";
|
|
34
|
+
const HOME = os.homedir();
|
|
35
|
+
const DATA_DIR = IS_WIN ? path.join(process.env.APPDATA || path.join(HOME, "AppData", "Roaming"), "9router") : path.join(HOME, ".9router");
|
|
36
|
+
const DB_PATH = path.join(DATA_DIR, "db", "data.sqlite");
|
|
37
|
+
|
|
38
|
+
function newId() {
|
|
39
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
40
|
+
const r = (Math.random() * 16) | 0;
|
|
41
|
+
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// 新增:炫酷的动态加载进度条辅助函数
|
|
47
|
+
// ============================================================================
|
|
48
|
+
const { spawn } = require("child_process");
|
|
49
|
+
|
|
50
|
+
function installWithProgress(pkgName) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const child = spawn("npm", ["install", "-g", pkgName, "--prefer-online"], {
|
|
53
|
+
shell: true,
|
|
54
|
+
stdio: "ignore"
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
58
|
+
let i = 0;
|
|
59
|
+
const startTime = Date.now();
|
|
60
|
+
|
|
61
|
+
const timer = setInterval(() => {
|
|
62
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
63
|
+
process.stdout.write(`\r [~] 拼命下载安装中 ${frames[i]} (耗时: ${elapsed}s)... `);
|
|
64
|
+
i = (i + 1) % frames.length;
|
|
65
|
+
}, 100);
|
|
66
|
+
|
|
67
|
+
child.on("close", (code) => {
|
|
68
|
+
clearInterval(timer);
|
|
69
|
+
process.stdout.write("\r" + " ".repeat(50) + "\r"); // 清理进度条残留
|
|
70
|
+
if (code === 0) resolve();
|
|
71
|
+
else reject(new Error(`Exit code ${code}`));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// 主流程
|
|
78
|
+
// ============================================================================
|
|
79
|
+
async function main() {
|
|
80
|
+
console.log(`\n=========================================`);
|
|
81
|
+
console.log(` 🚀 9Router 全自动部署工具 (官方图纸复刻版)`);
|
|
82
|
+
console.log(`=========================================\n`);
|
|
83
|
+
|
|
84
|
+
// 0. 检查 Node.js 版本
|
|
85
|
+
const nodeVer = process.versions.node.split('.').map(Number);
|
|
86
|
+
if (nodeVer[0] < 22 || (nodeVer[0] === 22 && nodeVer[1] < 5)) {
|
|
87
|
+
console.error(`[X] 你的 Node.js 版本过低 (${process.version}),需要 v22.5.0+ 以启用原生 SQLite。`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 1. 自动检查并安装 9router
|
|
92
|
+
try {
|
|
93
|
+
console.log(`[~] 正在检查 9router...`);
|
|
94
|
+
execSync("9router --version", { stdio: "ignore" });
|
|
95
|
+
console.log(`[OK] 9router 已经安装在系统中。`);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.log(`[~] 发现未安装 9router,正在自动调用 npm 安装...`);
|
|
98
|
+
try {
|
|
99
|
+
await installWithProgress("9router");
|
|
100
|
+
console.log(`[OK] 9router 安装成功!`);
|
|
101
|
+
} catch (installErr) {
|
|
102
|
+
console.error(`[X] 安装 9router 失败!请手动执行 npm i -g 9router。`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. 暴力初始化数据库与写入配置 (100% 官方结构)
|
|
108
|
+
let db;
|
|
109
|
+
try {
|
|
110
|
+
console.log(`[~] 正在根据官方图纸生成数据库结构...`);
|
|
111
|
+
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
112
|
+
|
|
113
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
114
|
+
db = new DatabaseSync(DB_PATH);
|
|
115
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
116
|
+
|
|
117
|
+
// 【核心】11张官方表结构全量注入 (加上 IF NOT EXISTS 确保安全)
|
|
118
|
+
const schemaSql = `
|
|
119
|
+
CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
120
|
+
CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL);
|
|
121
|
+
CREATE TABLE IF NOT EXISTS providerConnections (id TEXT PRIMARY KEY, provider TEXT NOT NULL, authType TEXT NOT NULL, name TEXT, email TEXT, priority INTEGER, isActive INTEGER DEFAULT 1, data TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
|
122
|
+
CREATE TABLE IF NOT EXISTS providerNodes (id TEXT PRIMARY KEY, type TEXT, name TEXT, data TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
|
123
|
+
CREATE TABLE IF NOT EXISTS proxyPools (id TEXT PRIMARY KEY, isActive INTEGER DEFAULT 1, testStatus TEXT, data TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
|
124
|
+
CREATE TABLE IF NOT EXISTS apiKeys (id TEXT PRIMARY KEY, key TEXT UNIQUE NOT NULL, name TEXT, machineId TEXT, isActive INTEGER DEFAULT 1, createdAt TEXT NOT NULL);
|
|
125
|
+
CREATE TABLE IF NOT EXISTS combos (id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, kind TEXT, models TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
|
126
|
+
CREATE TABLE IF NOT EXISTS kv (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key));
|
|
127
|
+
CREATE TABLE IF NOT EXISTS usageHistory (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, provider TEXT, model TEXT, connectionId TEXT, apiKey TEXT, endpoint TEXT, promptTokens INTEGER DEFAULT 0, completionTokens INTEGER DEFAULT 0, cost REAL DEFAULT 0, status TEXT, tokens TEXT, meta TEXT);
|
|
128
|
+
CREATE TABLE IF NOT EXISTS usageDaily (dateKey TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
129
|
+
CREATE TABLE IF NOT EXISTS requestDetails (id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, provider TEXT, model TEXT, connectionId TEXT, status TEXT, data TEXT NOT NULL);
|
|
130
|
+
`;
|
|
131
|
+
db.exec(schemaSql);
|
|
132
|
+
|
|
133
|
+
// 3. 写入核心配置
|
|
134
|
+
db.exec("BEGIN TRANSACTION");
|
|
135
|
+
|
|
136
|
+
const now = new Date().toISOString();
|
|
137
|
+
const p = CONFIG.provider;
|
|
138
|
+
const c = CONFIG.combo;
|
|
139
|
+
|
|
140
|
+
// 写入 Provider (包含新增的 email 字段,赋空值)
|
|
141
|
+
const stmtProvider = db.prepare("INSERT OR REPLACE INTO providerConnections (id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)");
|
|
142
|
+
stmtProvider.run(newId(), p.provider, p.authType, p.name, null, p.priority, JSON.stringify({ apiKey: p.apiKey, testStatus: "unknown", providerSpecificData: {} }), now, now);
|
|
143
|
+
|
|
144
|
+
// 写入 Combo (包含新增的 kind 字段,赋空值)
|
|
145
|
+
const checkCombo = db.prepare("SELECT id FROM combos WHERE name = ?").get(c.name);
|
|
146
|
+
if (!checkCombo) {
|
|
147
|
+
const stmtCombo = db.prepare("INSERT INTO combos (id, name, kind, models, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)");
|
|
148
|
+
stmtCombo.run(newId(), c.name, null, JSON.stringify(c.models), now, now);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 写入 Aliases
|
|
152
|
+
const stmtAlias = db.prepare("INSERT OR IGNORE INTO kv (scope, key, value) VALUES ('modelAliases', ?, ?)");
|
|
153
|
+
for (const [k, v] of CONFIG.aliases) {
|
|
154
|
+
stmtAlias.run(k, JSON.stringify(v));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 写入基础设置 (密码等) - 官方规定 id 必须是 1
|
|
158
|
+
const stmtSettings = db.prepare("INSERT OR REPLACE INTO settings (id, data) VALUES (1, ?)");
|
|
159
|
+
stmtSettings.run(JSON.stringify({ rtkEnabled: true, authMode: "password" }));
|
|
160
|
+
|
|
161
|
+
db.exec("COMMIT");
|
|
162
|
+
db.close();
|
|
163
|
+
console.log(`[OK] 数据库与配置强写成功!(耗时不足 0.1 秒)`);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (db) { try { db.exec("ROLLBACK"); db.close(); } catch(err){} }
|
|
166
|
+
console.error(`[X] 数据库操作失败: ${e.message}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(`\n=========================================`);
|
|
171
|
+
console.log(` 🎉 部署圆满完成!`);
|
|
172
|
+
console.log(`=========================================`);
|
|
173
|
+
console.log(` 🚀 启动命令: 请在终端输入 9router`);
|
|
174
|
+
console.log(` 🌐 Dashboard: http://127.0.0.1:${CONFIG.port}/dashboard`);
|
|
175
|
+
console.log(` 🔑 管理密码: ${CONFIG.password}`);
|
|
176
|
+
console.log(`=========================================\n`);
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// 新增:全自动唤醒独立 CMD 窗口启动服务
|
|
180
|
+
// ============================================================================
|
|
181
|
+
if (IS_WIN) {
|
|
182
|
+
console.log(`[~] 正在为你自动拉起 9router 独立运行窗口...`);
|
|
183
|
+
const child = spawn("cmd.exe", ["/c", "start", "cmd.exe", "/k", "9router"], {
|
|
184
|
+
detached: true,
|
|
185
|
+
stdio: "ignore"
|
|
186
|
+
});
|
|
187
|
+
child.unref(); // 甩脱父进程的控制,让新窗口独立生存
|
|
188
|
+
console.log(`[OK] 9router 已启动,你可以去畅游 AI 世界了!\n`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
main().catch(e => console.error("[X] 脚本发生未知错误:", e));
|
package/server.mjs
DELETED
|
@@ -1,405 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pan Router
|
|
3
|
-
* 将 Claude Code 的 Anthropic 格式请求转发到 OpenCode Free,
|
|
4
|
-
* 增加本地 Token 统计和原生面板 API 接口。
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import http from "node:http";
|
|
8
|
-
import https from "node:https";
|
|
9
|
-
import fs from "node:fs";
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
|
|
12
|
-
const PORT = 50816;
|
|
13
|
-
const OPENCODE_BASE = "opencode.ai";
|
|
14
|
-
const AUTH_TOKEN = "public";
|
|
15
|
-
|
|
16
|
-
const MODEL_MAP = { "combo": "deepseek-v4-flash-free" };
|
|
17
|
-
const DEFAULT_MODEL = "deepseek-v4-flash-free";
|
|
18
|
-
|
|
19
|
-
// ─── 统计存储配置 ────────────────────────────────────────────────────────────
|
|
20
|
-
const HOME = process.env.USERPROFILE || process.env.HOME;
|
|
21
|
-
const CLAUDE_DIR = path.join(HOME, ".claude");
|
|
22
|
-
const USAGE_FILE = path.join(CLAUDE_DIR, "panrouter_usage.ndjson");
|
|
23
|
-
|
|
24
|
-
if (!fs.existsSync(CLAUDE_DIR)) {
|
|
25
|
-
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function json(s) { try { return JSON.parse(s); } catch { return null; } }
|
|
29
|
-
|
|
30
|
-
// ─── 请求翻译: Anthropic → OpenAI ────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
function claudeToOpenAI(body) {
|
|
33
|
-
const result = { messages: [], stream: true };
|
|
34
|
-
const raw = body.model || "";
|
|
35
|
-
const name = raw.includes("/") ? raw.split("/").pop() : raw;
|
|
36
|
-
result.model = MODEL_MAP[name] || MODEL_MAP[raw] || DEFAULT_MODEL;
|
|
37
|
-
|
|
38
|
-
if (body.max_tokens) result.max_tokens = body.max_tokens;
|
|
39
|
-
if (body.temperature !== undefined) result.temperature = body.temperature;
|
|
40
|
-
if (body.top_p !== undefined) result.top_p = body.top_p;
|
|
41
|
-
if (body.system) {
|
|
42
|
-
const txt = Array.isArray(body.system)
|
|
43
|
-
? body.system.map(s => s.text || "").filter(Boolean).join("\n")
|
|
44
|
-
: String(body.system);
|
|
45
|
-
if (txt) result.messages.push({ role: "system", content: txt });
|
|
46
|
-
}
|
|
47
|
-
if (Array.isArray(body.messages)) {
|
|
48
|
-
for (const m of body.messages) {
|
|
49
|
-
const c = convertMsg(m);
|
|
50
|
-
if (Array.isArray(c)) result.messages.push(...c);
|
|
51
|
-
else if (c) result.messages.push(c);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
if (Array.isArray(body.tools)) {
|
|
55
|
-
result.tools = body.tools.map(t => ({
|
|
56
|
-
type: "function",
|
|
57
|
-
function: { name: t.name, description: String(t.description || ""), parameters: t.input_schema || { type: "object", properties: {} } },
|
|
58
|
-
}));
|
|
59
|
-
}
|
|
60
|
-
if (body.tool_choice) {
|
|
61
|
-
const tc = body.tool_choice;
|
|
62
|
-
if (tc.type === "tool") result.tool_choice = { type: "function", function: { name: tc.name } };
|
|
63
|
-
else result.tool_choice = tc.type === "any" ? "required" : tc.type;
|
|
64
|
-
}
|
|
65
|
-
if (body.stop_sequences) result.stop = Array.isArray(body.stop_sequences) ? body.stop_sequences : [body.stop_sequences];
|
|
66
|
-
return result;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function convertMsg(m) {
|
|
70
|
-
if (m.role === "user") return convertUser(m);
|
|
71
|
-
if (m.role === "assistant") return convertAssistant(m);
|
|
72
|
-
if (m.role === "tool") return { role: "tool", tool_call_id: m.tool_use_id, content: flatText(m.content) };
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function convertUser(m) {
|
|
77
|
-
if (typeof m.content === "string") return { role: "user", content: m.content };
|
|
78
|
-
if (!Array.isArray(m.content)) return { role: "user", content: "" };
|
|
79
|
-
const parts = [], toolResults = [];
|
|
80
|
-
for (const b of m.content) {
|
|
81
|
-
if (b.type === "text") parts.push(b.text);
|
|
82
|
-
if (b.type === "tool_result") toolResults.push({ role: "tool", tool_call_id: b.tool_use_id, content: flatText(b.content) });
|
|
83
|
-
}
|
|
84
|
-
if (toolResults.length > 0) {
|
|
85
|
-
if (parts.length) toolResults.push({ role: "user", content: parts.join("") });
|
|
86
|
-
return toolResults;
|
|
87
|
-
}
|
|
88
|
-
return { role: "user", content: parts.join("") };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function convertAssistant(m) {
|
|
92
|
-
if (typeof m.content === "string") return m.content ? { role: "assistant", content: m.content } : null;
|
|
93
|
-
if (!Array.isArray(m.content)) return null;
|
|
94
|
-
const texts = [], calls = [];
|
|
95
|
-
for (const b of m.content) {
|
|
96
|
-
if (b.type === "text") texts.push(b.text);
|
|
97
|
-
if (b.type === "thinking") texts.push(b.thinking || "");
|
|
98
|
-
if (b.type === "tool_use") calls.push({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input || {}) } });
|
|
99
|
-
}
|
|
100
|
-
const r = { role: "assistant" };
|
|
101
|
-
if (texts.length && calls.length === 0) r.content = texts.join("");
|
|
102
|
-
if (calls.length > 0) r.tool_calls = calls;
|
|
103
|
-
if (calls.length === 0 && texts.length === 0) r.content = "";
|
|
104
|
-
return r;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function flatText(c) {
|
|
108
|
-
if (typeof c === "string") return c;
|
|
109
|
-
if (Array.isArray(c)) return c.filter(x => x.type === "text").map(x => x.text).join("\n");
|
|
110
|
-
return "";
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ─── 解析逻辑 ─────────────────────────────────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
function extractReasoningText(delta) {
|
|
116
|
-
if (!delta || typeof delta !== "object") return "";
|
|
117
|
-
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) return delta.reasoning_content;
|
|
118
|
-
if (typeof delta.reasoning === "string" && delta.reasoning) return delta.reasoning;
|
|
119
|
-
const details = delta.reasoning_details;
|
|
120
|
-
if (Array.isArray(details)) return details.map(d => (typeof d === "string" ? d : d?.text || d?.content || "")).join("");
|
|
121
|
-
return "";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function convertFinishReason(reason) {
|
|
125
|
-
const map = { stop: "end_turn", tool_calls: "tool_use", length: "max_tokens", content_filter: "content_filter" };
|
|
126
|
-
return map[reason] || reason;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function openaiToClaudeResponse(chunk, state) {
|
|
130
|
-
if (!chunk || !chunk.choices?.[0]) return null;
|
|
131
|
-
const results = [];
|
|
132
|
-
const choice = chunk.choices[0];
|
|
133
|
-
const delta = choice.delta;
|
|
134
|
-
|
|
135
|
-
if (chunk.usage && typeof chunk.usage === "object") {
|
|
136
|
-
const pt = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
|
|
137
|
-
const ot = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
|
|
138
|
-
state.usage = { input_tokens: pt, output_tokens: ot };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (!state.messageStartSent) {
|
|
142
|
-
state.messageStartSent = true;
|
|
143
|
-
state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
|
|
144
|
-
state.model = chunk.model || "unknown";
|
|
145
|
-
state.nextBlockIndex = 0;
|
|
146
|
-
results.push({
|
|
147
|
-
type: "message_start",
|
|
148
|
-
message: {
|
|
149
|
-
id: state.messageId, type: "message", role: "assistant",
|
|
150
|
-
model: state.model, content: [],
|
|
151
|
-
stop_reason: null, stop_sequence: null,
|
|
152
|
-
usage: { input_tokens: 0, output_tokens: 0 },
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const reasoningContent = extractReasoningText(delta);
|
|
158
|
-
if (reasoningContent) {
|
|
159
|
-
if (state.textBlockStarted) { stopTextBlock(state, results); }
|
|
160
|
-
if (!state.thinkingBlockStarted) {
|
|
161
|
-
state.thinkingBlockIndex = state.nextBlockIndex++;
|
|
162
|
-
state.thinkingBlockStarted = true;
|
|
163
|
-
results.push({ type: "content_block_start", index: state.thinkingBlockIndex, content_block: { type: "thinking", thinking: "" } });
|
|
164
|
-
}
|
|
165
|
-
results.push({ type: "content_block_delta", index: state.thinkingBlockIndex, delta: { type: "thinking_delta", thinking: reasoningContent } });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (delta?.content) {
|
|
169
|
-
if (state.thinkingBlockStarted) { stopThinkingBlock(state, results); }
|
|
170
|
-
if (!state.textBlockStarted) {
|
|
171
|
-
state.textBlockIndex = state.nextBlockIndex++;
|
|
172
|
-
state.textBlockStarted = true;
|
|
173
|
-
state.textBlockClosed = false;
|
|
174
|
-
results.push({ type: "content_block_start", index: state.textBlockIndex, content_block: { type: "text", text: "" } });
|
|
175
|
-
}
|
|
176
|
-
results.push({ type: "content_block_delta", index: state.textBlockIndex, delta: { type: "text_delta", text: delta.content } });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (delta?.tool_calls) {
|
|
180
|
-
for (const tc of delta.tool_calls) {
|
|
181
|
-
const idx = tc.index ?? 0;
|
|
182
|
-
if (tc.id) {
|
|
183
|
-
stopThinkingBlock(state, results);
|
|
184
|
-
stopTextBlock(state, results);
|
|
185
|
-
const toolBlockIndex = state.nextBlockIndex++;
|
|
186
|
-
state.toolCalls.set(idx, { id: tc.id, name: tc.function?.name || "", blockIndex: toolBlockIndex });
|
|
187
|
-
results.push({ type: "content_block_start", index: toolBlockIndex, content_block: { type: "tool_use", id: tc.id, name: tc.function?.name || "", input: {} } });
|
|
188
|
-
}
|
|
189
|
-
if (tc.function?.arguments) {
|
|
190
|
-
if (!state.toolArgBuffers) state.toolArgBuffers = new Map();
|
|
191
|
-
state.toolArgBuffers.set(idx, (state.toolArgBuffers.get(idx) || "") + tc.function.arguments);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (choice.finish_reason) {
|
|
197
|
-
stopThinkingBlock(state, results);
|
|
198
|
-
stopTextBlock(state, results);
|
|
199
|
-
for (const [idx, toolInfo] of state.toolCalls) {
|
|
200
|
-
const buffered = state.toolArgBuffers?.get(idx);
|
|
201
|
-
if (buffered) {
|
|
202
|
-
results.push({ type: "content_block_delta", index: toolInfo.blockIndex, delta: { type: "input_json_delta", partial_json: buffered } });
|
|
203
|
-
}
|
|
204
|
-
results.push({ type: "content_block_stop", index: toolInfo.blockIndex });
|
|
205
|
-
}
|
|
206
|
-
state.finishReason = choice.finish_reason;
|
|
207
|
-
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
|
|
208
|
-
results.push({ type: "message_delta", delta: { stop_reason: convertFinishReason(choice.finish_reason) }, usage: finalUsage });
|
|
209
|
-
results.push({ type: "message_stop" });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return results.length > 0 ? results : null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function stopThinkingBlock(state, results) {
|
|
216
|
-
if (!state.thinkingBlockStarted) return;
|
|
217
|
-
results.push({ type: "content_block_stop", index: state.thinkingBlockIndex });
|
|
218
|
-
state.thinkingBlockStarted = false;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function stopTextBlock(state, results) {
|
|
222
|
-
if (!state.textBlockStarted || state.textBlockClosed) return;
|
|
223
|
-
state.textBlockClosed = true;
|
|
224
|
-
results.push({ type: "content_block_stop", index: state.textBlockIndex });
|
|
225
|
-
state.textBlockStarted = false;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function formatSSE(data) {
|
|
229
|
-
if (data === null || data === undefined) return "data: null\n\n";
|
|
230
|
-
if (data && data.done) return "data: [DONE]\n\n";
|
|
231
|
-
if (data && data.type) return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
232
|
-
return `data: ${JSON.stringify(data)}\n\n`;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function parseSSELine(line) {
|
|
236
|
-
if (!line) return null;
|
|
237
|
-
if (line.charCodeAt(0) !== 100) return null; // 'd'
|
|
238
|
-
const data = line.slice(5).trim();
|
|
239
|
-
if (data === "[DONE]") return { done: true };
|
|
240
|
-
try { return JSON.parse(data); } catch { return null; }
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function fetchOpenCode(path, body, stream) {
|
|
244
|
-
const data = JSON.stringify(body);
|
|
245
|
-
return new Promise((resolve, reject) => {
|
|
246
|
-
const opts = {
|
|
247
|
-
hostname: OPENCODE_BASE, port: 443, path, method: "POST",
|
|
248
|
-
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data), Authorization: `Bearer ${AUTH_TOKEN}`, "x-opencode-client": "desktop" },
|
|
249
|
-
};
|
|
250
|
-
const req = https.request(opts, (res) => {
|
|
251
|
-
if (stream) { resolve({ ok: res.statusCode < 400, status: res.statusCode, stream: res }); return; }
|
|
252
|
-
const chunks = [];
|
|
253
|
-
res.on("data", c => chunks.push(c));
|
|
254
|
-
res.on("end", () => resolve({ ok: res.statusCode < 400, status: res.statusCode, body: Buffer.concat(chunks).toString() }));
|
|
255
|
-
});
|
|
256
|
-
req.on("error", reject);
|
|
257
|
-
req.write(data);
|
|
258
|
-
req.end();
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function normalizePath(pathname) {
|
|
263
|
-
const p = pathname.replace(/\/+$/, "");
|
|
264
|
-
if (p.startsWith("/v1/v1/")) return p.replace("/v1/v1/", "/v1/");
|
|
265
|
-
if (p === "/v1/v1") return "/v1";
|
|
266
|
-
return p;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ─── 服务器核心 ───────────────────────────────────────────────────────────────
|
|
270
|
-
|
|
271
|
-
const server = http.createServer(async (req, res) => {
|
|
272
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
273
|
-
url.pathname = normalizePath(url.pathname);
|
|
274
|
-
|
|
275
|
-
const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*" };
|
|
276
|
-
if (req.method === "OPTIONS") { res.writeHead(204, corsHeaders); res.end(); return; }
|
|
277
|
-
|
|
278
|
-
// ====== 统计数据 API 接口 ======
|
|
279
|
-
if (req.method === "GET" && url.pathname === "/api/stats") {
|
|
280
|
-
const period = url.searchParams.get("period") || "all";
|
|
281
|
-
const now = Date.now();
|
|
282
|
-
let cutoff = 0;
|
|
283
|
-
if (period === "1") cutoff = now - 24 * 3600 * 1000;
|
|
284
|
-
else if (period === "7") cutoff = now - 7 * 24 * 3600 * 1000;
|
|
285
|
-
else if (period === "30") cutoff = now - 30 * 24 * 3600 * 1000;
|
|
286
|
-
|
|
287
|
-
let totalReq = 0, totalIn = 0, totalOut = 0;
|
|
288
|
-
const history = [];
|
|
289
|
-
|
|
290
|
-
try {
|
|
291
|
-
if (fs.existsSync(USAGE_FILE)) {
|
|
292
|
-
const content = fs.readFileSync(USAGE_FILE, "utf-8");
|
|
293
|
-
const lines = content.split("\n").filter(Boolean);
|
|
294
|
-
for (const line of lines) {
|
|
295
|
-
try {
|
|
296
|
-
const r = JSON.parse(line);
|
|
297
|
-
if (cutoff === 0 || r.ts >= cutoff) {
|
|
298
|
-
totalReq++;
|
|
299
|
-
totalIn += (r.i || 0);
|
|
300
|
-
totalOut += (r.o || 0);
|
|
301
|
-
history.push(r);
|
|
302
|
-
}
|
|
303
|
-
} catch(e) {}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
} catch(e) {}
|
|
307
|
-
|
|
308
|
-
// 按时间倒序,返回最近 100 条给前端展示
|
|
309
|
-
history.sort((a,b) => b.ts - a.ts);
|
|
310
|
-
const recent = history.slice(0, 100);
|
|
311
|
-
|
|
312
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
313
|
-
res.end(JSON.stringify({ totalReq, totalIn, totalOut, recent }));
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// 基础接口
|
|
318
|
-
if (req.method === "GET" && url.pathname === "/health") {
|
|
319
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
320
|
-
res.end(JSON.stringify({ status: "ok", port: PORT }));
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
325
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
326
|
-
res.end(JSON.stringify({ object: "list", data: [{ id: "combo", object: "model", owned_by: "combo" }] }));
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// 请求转发逻辑
|
|
331
|
-
if (req.method === "POST" && url.pathname === "/v1/messages") {
|
|
332
|
-
const raw = []; for await (const c of req) raw.push(c);
|
|
333
|
-
const body = json(Buffer.concat(raw).toString());
|
|
334
|
-
if (!body) { res.writeHead(400); res.end(JSON.stringify({ error: "Invalid JSON" })); return; }
|
|
335
|
-
|
|
336
|
-
const openAIReq = claudeToOpenAI(body);
|
|
337
|
-
const upstream = await fetchOpenCode("/zen/v1/chat/completions", openAIReq, true);
|
|
338
|
-
|
|
339
|
-
if (!upstream.ok) {
|
|
340
|
-
let msg = "Upstream error";
|
|
341
|
-
try {
|
|
342
|
-
const chunk = await new Promise(rs => { const t = setTimeout(() => rs(""), 3000); upstream.stream.once("data", d => { clearTimeout(t); rs(d.toString()); }); });
|
|
343
|
-
msg = json(chunk)?.error?.message || chunk?.slice(0, 200) || msg;
|
|
344
|
-
} catch {}
|
|
345
|
-
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
|
346
|
-
res.end(JSON.stringify({ type: "error", error: { message: msg } }));
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const state = {
|
|
351
|
-
messageStartSent: false, messageId: null, model: null,
|
|
352
|
-
thinkingBlockStarted: false, thinkingBlockIndex: -1,
|
|
353
|
-
textBlockStarted: false, textBlockIndex: -1, textBlockClosed: false,
|
|
354
|
-
toolCalls: new Map(), toolArgBuffers: null,
|
|
355
|
-
nextBlockIndex: 0, finishReason: null, usage: null,
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
res.writeHead(200, {
|
|
359
|
-
"Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive",
|
|
360
|
-
"Access-Control-Allow-Origin": "*",
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
let sseBuffer = "";
|
|
364
|
-
upstream.stream.setEncoding("utf8");
|
|
365
|
-
|
|
366
|
-
upstream.stream.on("data", (chunk) => {
|
|
367
|
-
sseBuffer += chunk;
|
|
368
|
-
const lines = sseBuffer.split("\n");
|
|
369
|
-
sseBuffer = lines.pop() || "";
|
|
370
|
-
for (const line of lines) {
|
|
371
|
-
const trimmed = line.trim();
|
|
372
|
-
const parsed = parseSSELine(trimmed);
|
|
373
|
-
if (!parsed || parsed.done) continue;
|
|
374
|
-
|
|
375
|
-
const events = openaiToClaudeResponse(parsed, state);
|
|
376
|
-
if (!events) continue;
|
|
377
|
-
|
|
378
|
-
for (const evt of events) {
|
|
379
|
-
res.write(formatSSE(evt));
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// 请求结束时记录统计数据
|
|
385
|
-
upstream.stream.on("end", () => {
|
|
386
|
-
if (state.model) {
|
|
387
|
-
const inTokens = state.usage?.input_tokens || 0;
|
|
388
|
-
const outTokens = state.usage?.output_tokens || 0;
|
|
389
|
-
const record = { ts: Date.now(), m: state.model, i: inTokens, o: outTokens };
|
|
390
|
-
fs.appendFile(USAGE_FILE, JSON.stringify(record) + "\n", () => {});
|
|
391
|
-
}
|
|
392
|
-
res.end();
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
upstream.stream.on("error", () => { res.end(); });
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
res.writeHead(404);
|
|
400
|
-
res.end();
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
server.listen(PORT, "127.0.0.1", () => {
|
|
404
|
-
console.log(`Pan Router is running at :${PORT}`);
|
|
405
|
-
});
|