panrouter 1.0.1
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.js +182 -0
- package/package.json +14 -0
- package/server.mjs +429 -0
package/cli.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pan Router CLI — 一键安装 + 启动
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* npx pan-router # 一键安装 + 启动
|
|
8
|
+
* npx pan-router --install # 只安装配置
|
|
9
|
+
* npx pan-router --server # 只启动代理
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync, spawn } from "node:child_process";
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const HOME = process.env.USERPROFILE || process.env.HOME;
|
|
20
|
+
const CLAUDE_DIR = path.join(HOME, ".claude");
|
|
21
|
+
const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
|
|
22
|
+
const BACKUP_PATH = path.join(CLAUDE_DIR, "settings.json.panrouter.backup");
|
|
23
|
+
|
|
24
|
+
function log(label, msg, color = "") {
|
|
25
|
+
const colors = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" };
|
|
26
|
+
const c = colors[color] || "";
|
|
27
|
+
console.log(`${c}[${label}]${colors.reset} ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function run(cmd) {
|
|
31
|
+
try {
|
|
32
|
+
execSync(cmd, { stdio: "inherit" });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runSilent(cmd) {
|
|
40
|
+
try {
|
|
41
|
+
execSync(cmd, { stdio: "pipe" });
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── 1. 检查 / 安装 Claude Code ──────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function installClaudeCode() {
|
|
51
|
+
log("..", "正在检查 Claude Code...", "yellow");
|
|
52
|
+
if (runSilent("claude --version")) {
|
|
53
|
+
log("OK", "Claude Code 已就绪", "green");
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log("..", "正在安装 Claude Code...", "yellow");
|
|
58
|
+
if (!run("npm install -g @anthropic-ai/claude-code")) {
|
|
59
|
+
log("!!", "Claude Code 安装失败", "red");
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
log("OK", "Claude Code 安装成功", "green");
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── 2. 写入配置文件 ─────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function writeConfig() {
|
|
69
|
+
log("..", "正在写入配置...", "yellow");
|
|
70
|
+
|
|
71
|
+
if (!fs.existsSync(CLAUDE_DIR)) {
|
|
72
|
+
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 备份
|
|
76
|
+
if (fs.existsSync(SETTINGS_PATH)) {
|
|
77
|
+
fs.copyFileSync(SETTINGS_PATH, BACKUP_PATH);
|
|
78
|
+
log("OK", "原配置已备份", "green");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const config = {
|
|
82
|
+
env: {
|
|
83
|
+
ANTHROPIC_BASE_URL: "http://127.0.0.1:50816",
|
|
84
|
+
ANTHROPIC_AUTH_TOKEN: "public",
|
|
85
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: "combo",
|
|
86
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: "combo",
|
|
87
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: "combo",
|
|
88
|
+
},
|
|
89
|
+
hasCompletedOnboarding: true,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
93
|
+
log("OK", "配置文件已写入 ~/.claude/settings.json", "green");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── 3. 启动代理服务器(后台运行) ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function startServer() {
|
|
99
|
+
const serverPath = path.join(__dirname, "server.mjs");
|
|
100
|
+
|
|
101
|
+
// 关掉旧的 Pan Router
|
|
102
|
+
try {
|
|
103
|
+
if (process.platform === "win32") {
|
|
104
|
+
execSync('taskkill /f /fi "WINDOWTITLE eq Pan Router*" >nul 2>&1', { stdio: "pipe" });
|
|
105
|
+
} else {
|
|
106
|
+
execSync("pkill -f 'node.*server.mjs' 2>/dev/null", { stdio: "pipe" });
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
|
|
110
|
+
log("..", "正在启动 Pan Router(端口 50816)...", "yellow");
|
|
111
|
+
|
|
112
|
+
// 后台启动,不阻塞终端
|
|
113
|
+
const child = spawn("node", [serverPath], {
|
|
114
|
+
cwd: __dirname,
|
|
115
|
+
stdio: "ignore",
|
|
116
|
+
detached: true,
|
|
117
|
+
});
|
|
118
|
+
child.unref();
|
|
119
|
+
|
|
120
|
+
// 等 2 秒确认启动
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
try {
|
|
123
|
+
const req = http.get("http://127.0.0.1:50816/health", () => {});
|
|
124
|
+
req.on("error", () => {});
|
|
125
|
+
} catch {}
|
|
126
|
+
log("OK", "Pan Router 已启动(端口 50816)", "green");
|
|
127
|
+
console.log("\n 现在可以运行: \x1b[33mclaude \"你好\"\x1b[0m\n");
|
|
128
|
+
}, 2000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── 主流程 ──────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function printBanner() {
|
|
134
|
+
console.log(`
|
|
135
|
+
\x1b[36m============================================\x1b[0m
|
|
136
|
+
\x1b[36m Pan Router - Claude Code 一键安装\x1b[0m
|
|
137
|
+
\x1b[36m 无需 API Key,开箱即用\x1b[0m
|
|
138
|
+
\x1b[36m============================================\x1b[0m
|
|
139
|
+
`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function main() {
|
|
143
|
+
const args = process.argv.slice(2);
|
|
144
|
+
|
|
145
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
146
|
+
console.log(`
|
|
147
|
+
pan-router — Claude Code 免费 AI 路由代理
|
|
148
|
+
|
|
149
|
+
\x1b[33m用法:\x1b[0m
|
|
150
|
+
panrouter 一键安装 + 启动
|
|
151
|
+
panrouter --install 只安装配置
|
|
152
|
+
panrouter --server 只启动代理
|
|
153
|
+
panrouter --help 显示帮助
|
|
154
|
+
|
|
155
|
+
\x1b[33m配置:\x1b[0m
|
|
156
|
+
代理运行在 http://127.0.0.1:50816
|
|
157
|
+
Claude Code 自动使用,无需额外设置
|
|
158
|
+
`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (args.includes("--install") || args.includes("-i")) {
|
|
163
|
+
printBanner();
|
|
164
|
+
if (!installClaudeCode()) process.exit(1);
|
|
165
|
+
writeConfig();
|
|
166
|
+
console.log("\n ✓ 安装完成,运行 \x1b[33mpanrouter --server\x1b[0m 启动代理\n");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (args.includes("--server") || args.includes("-s")) {
|
|
171
|
+
startServer();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 默认:全流程
|
|
176
|
+
printBanner();
|
|
177
|
+
if (!installClaudeCode()) process.exit(1);
|
|
178
|
+
writeConfig();
|
|
179
|
+
startServer();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
main();
|
package/package.json
ADDED
package/server.mjs
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pan Router
|
|
3
|
+
*
|
|
4
|
+
* 将 Claude Code 的 Anthropic 格式请求转发到 OpenCode Free,
|
|
5
|
+
* 自动完成格式翻译 (Anthropic ↔ OpenAI)。
|
|
6
|
+
*
|
|
7
|
+
* 照抄 9Router 的 openaiToClaudeResponse + formatSSE 逻辑
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node pan-router.mjs
|
|
11
|
+
* # ~/.claude/settings.json 的 env 设:
|
|
12
|
+
* # ANTHROPIC_BASE_URL: http://127.0.0.1:50816
|
|
13
|
+
* # ANTHROPIC_AUTH_TOKEN: public
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import https from "node:https";
|
|
18
|
+
|
|
19
|
+
const PORT = 50816;
|
|
20
|
+
const OPENCODE_BASE = "opencode.ai";
|
|
21
|
+
const AUTH_TOKEN = "public";
|
|
22
|
+
|
|
23
|
+
const MODEL_MAP = { "combo": "deepseek-v4-flash-free" };
|
|
24
|
+
const DEFAULT_MODEL = "deepseek-v4-flash-free";
|
|
25
|
+
|
|
26
|
+
function json(s) { try { return JSON.parse(s); } catch { return null; } }
|
|
27
|
+
|
|
28
|
+
// ─── 请求翻译: Anthropic → OpenAI ────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function claudeToOpenAI(body) {
|
|
31
|
+
const result = { messages: [], stream: true };
|
|
32
|
+
const raw = body.model || "";
|
|
33
|
+
const name = raw.includes("/") ? raw.split("/").pop() : raw;
|
|
34
|
+
result.model = MODEL_MAP[name] || MODEL_MAP[raw] || DEFAULT_MODEL;
|
|
35
|
+
|
|
36
|
+
if (body.max_tokens) result.max_tokens = body.max_tokens;
|
|
37
|
+
if (body.temperature !== undefined) result.temperature = body.temperature;
|
|
38
|
+
if (body.top_p !== undefined) result.top_p = body.top_p;
|
|
39
|
+
if (body.system) {
|
|
40
|
+
const txt = Array.isArray(body.system)
|
|
41
|
+
? body.system.map(s => s.text || "").filter(Boolean).join("\n")
|
|
42
|
+
: String(body.system);
|
|
43
|
+
if (txt) result.messages.push({ role: "system", content: txt });
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(body.messages)) {
|
|
46
|
+
for (const m of body.messages) {
|
|
47
|
+
const c = convertMsg(m);
|
|
48
|
+
if (Array.isArray(c)) result.messages.push(...c);
|
|
49
|
+
else if (c) result.messages.push(c);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(body.tools)) {
|
|
53
|
+
result.tools = body.tools.map(t => ({
|
|
54
|
+
type: "function",
|
|
55
|
+
function: { name: t.name, description: String(t.description || ""), parameters: t.input_schema || { type: "object", properties: {} } },
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
if (body.tool_choice) {
|
|
59
|
+
const tc = body.tool_choice;
|
|
60
|
+
if (tc.type === "tool") result.tool_choice = { type: "function", function: { name: tc.name } };
|
|
61
|
+
else result.tool_choice = tc.type === "any" ? "required" : tc.type;
|
|
62
|
+
}
|
|
63
|
+
if (body.stop_sequences) result.stop = Array.isArray(body.stop_sequences) ? body.stop_sequences : [body.stop_sequences];
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function convertMsg(m) {
|
|
68
|
+
if (m.role === "user") return convertUser(m);
|
|
69
|
+
if (m.role === "assistant") return convertAssistant(m);
|
|
70
|
+
if (m.role === "tool") return { role: "tool", tool_call_id: m.tool_use_id, content: flatText(m.content) };
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function convertUser(m) {
|
|
75
|
+
if (typeof m.content === "string") return { role: "user", content: m.content };
|
|
76
|
+
if (!Array.isArray(m.content)) return { role: "user", content: "" };
|
|
77
|
+
const parts = [], toolResults = [];
|
|
78
|
+
for (const b of m.content) {
|
|
79
|
+
if (b.type === "text") parts.push(b.text);
|
|
80
|
+
if (b.type === "tool_result") toolResults.push({ role: "tool", tool_call_id: b.tool_use_id, content: flatText(b.content) });
|
|
81
|
+
}
|
|
82
|
+
if (toolResults.length > 0) {
|
|
83
|
+
if (parts.length) toolResults.push({ role: "user", content: parts.join("") });
|
|
84
|
+
return toolResults;
|
|
85
|
+
}
|
|
86
|
+
return { role: "user", content: parts.join("") };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function convertAssistant(m) {
|
|
90
|
+
if (typeof m.content === "string") return m.content ? { role: "assistant", content: m.content } : null;
|
|
91
|
+
if (!Array.isArray(m.content)) return null;
|
|
92
|
+
const texts = [], calls = [];
|
|
93
|
+
for (const b of m.content) {
|
|
94
|
+
if (b.type === "text") texts.push(b.text);
|
|
95
|
+
if (b.type === "thinking") texts.push(b.thinking || "");
|
|
96
|
+
if (b.type === "tool_use") calls.push({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input || {}) } });
|
|
97
|
+
}
|
|
98
|
+
const r = { role: "assistant" };
|
|
99
|
+
if (texts.length && calls.length === 0) r.content = texts.join("");
|
|
100
|
+
if (calls.length > 0) r.tool_calls = calls;
|
|
101
|
+
if (calls.length === 0 && texts.length === 0) r.content = "";
|
|
102
|
+
return r;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function flatText(c) {
|
|
106
|
+
if (typeof c === "string") return c;
|
|
107
|
+
if (Array.isArray(c)) return c.filter(x => x.type === "text").map(x => x.text).join("\n");
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── 照抄 9Router: extractReasoningText ─────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function extractReasoningText(delta) {
|
|
114
|
+
if (!delta || typeof delta !== "object") return "";
|
|
115
|
+
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) return delta.reasoning_content;
|
|
116
|
+
if (typeof delta.reasoning === "string" && delta.reasoning) return delta.reasoning;
|
|
117
|
+
const details = delta.reasoning_details;
|
|
118
|
+
if (Array.isArray(details)) return details.map(d => (typeof d === "string" ? d : d?.text || d?.content || "")).join("");
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── 照抄 9Router: openaiToClaudeResponse (流式) ────────────────────────────
|
|
123
|
+
// 来源: 9router/open-sse/translator/response/openai-to-claude.js
|
|
124
|
+
|
|
125
|
+
function convertFinishReason(reason) {
|
|
126
|
+
const map = { stop: "end_turn", tool_calls: "tool_use", length: "max_tokens", content_filter: "content_filter" };
|
|
127
|
+
return map[reason] || reason;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function openaiToClaudeResponse(chunk, state) {
|
|
131
|
+
if (!chunk || !chunk.choices?.[0]) return null;
|
|
132
|
+
const results = [];
|
|
133
|
+
const choice = chunk.choices[0];
|
|
134
|
+
const delta = choice.delta;
|
|
135
|
+
|
|
136
|
+
// Track usage
|
|
137
|
+
if (chunk.usage && typeof chunk.usage === "object") {
|
|
138
|
+
const pt = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
|
|
139
|
+
const ot = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
|
|
140
|
+
state.usage = { input_tokens: pt, output_tokens: ot };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// First chunk - message_start
|
|
144
|
+
if (!state.messageStartSent) {
|
|
145
|
+
state.messageStartSent = true;
|
|
146
|
+
state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
|
|
147
|
+
state.model = chunk.model || "unknown";
|
|
148
|
+
state.nextBlockIndex = 0;
|
|
149
|
+
results.push({
|
|
150
|
+
type: "message_start",
|
|
151
|
+
message: {
|
|
152
|
+
id: state.messageId, type: "message", role: "assistant",
|
|
153
|
+
model: state.model, content: [],
|
|
154
|
+
stop_reason: null, stop_sequence: null,
|
|
155
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Handle reasoning (thinking)
|
|
161
|
+
const reasoningContent = extractReasoningText(delta);
|
|
162
|
+
if (reasoningContent) {
|
|
163
|
+
// Stop text block if running
|
|
164
|
+
if (state.textBlockStarted) { stopTextBlock(state, results); }
|
|
165
|
+
if (!state.thinkingBlockStarted) {
|
|
166
|
+
state.thinkingBlockIndex = state.nextBlockIndex++;
|
|
167
|
+
state.thinkingBlockStarted = true;
|
|
168
|
+
results.push({ type: "content_block_start", index: state.thinkingBlockIndex, content_block: { type: "thinking", thinking: "" } });
|
|
169
|
+
}
|
|
170
|
+
results.push({ type: "content_block_delta", index: state.thinkingBlockIndex, delta: { type: "thinking_delta", thinking: reasoningContent } });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle regular content
|
|
174
|
+
if (delta?.content) {
|
|
175
|
+
if (state.thinkingBlockStarted) { stopThinkingBlock(state, results); }
|
|
176
|
+
if (!state.textBlockStarted) {
|
|
177
|
+
state.textBlockIndex = state.nextBlockIndex++;
|
|
178
|
+
state.textBlockStarted = true;
|
|
179
|
+
state.textBlockClosed = false;
|
|
180
|
+
results.push({ type: "content_block_start", index: state.textBlockIndex, content_block: { type: "text", text: "" } });
|
|
181
|
+
}
|
|
182
|
+
results.push({ type: "content_block_delta", index: state.textBlockIndex, delta: { type: "text_delta", text: delta.content } });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Tool calls
|
|
186
|
+
if (delta?.tool_calls) {
|
|
187
|
+
for (const tc of delta.tool_calls) {
|
|
188
|
+
const idx = tc.index ?? 0;
|
|
189
|
+
if (tc.id) {
|
|
190
|
+
stopThinkingBlock(state, results);
|
|
191
|
+
stopTextBlock(state, results);
|
|
192
|
+
const toolBlockIndex = state.nextBlockIndex++;
|
|
193
|
+
state.toolCalls.set(idx, { id: tc.id, name: tc.function?.name || "", blockIndex: toolBlockIndex });
|
|
194
|
+
results.push({ type: "content_block_start", index: toolBlockIndex, content_block: { type: "tool_use", id: tc.id, name: tc.function?.name || "", input: {} } });
|
|
195
|
+
}
|
|
196
|
+
if (tc.function?.arguments) {
|
|
197
|
+
if (!state.toolArgBuffers) state.toolArgBuffers = new Map();
|
|
198
|
+
state.toolArgBuffers.set(idx, (state.toolArgBuffers.get(idx) || "") + tc.function.arguments);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Finish
|
|
204
|
+
if (choice.finish_reason) {
|
|
205
|
+
stopThinkingBlock(state, results);
|
|
206
|
+
stopTextBlock(state, results);
|
|
207
|
+
for (const [idx, toolInfo] of state.toolCalls) {
|
|
208
|
+
const buffered = state.toolArgBuffers?.get(idx);
|
|
209
|
+
if (buffered) {
|
|
210
|
+
results.push({ type: "content_block_delta", index: toolInfo.blockIndex, delta: { type: "input_json_delta", partial_json: buffered } });
|
|
211
|
+
}
|
|
212
|
+
results.push({ type: "content_block_stop", index: toolInfo.blockIndex });
|
|
213
|
+
}
|
|
214
|
+
state.finishReason = choice.finish_reason;
|
|
215
|
+
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
|
|
216
|
+
results.push({ type: "message_delta", delta: { stop_reason: convertFinishReason(choice.finish_reason) }, usage: finalUsage });
|
|
217
|
+
results.push({ type: "message_stop" });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return results.length > 0 ? results : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function stopThinkingBlock(state, results) {
|
|
224
|
+
if (!state.thinkingBlockStarted) return;
|
|
225
|
+
results.push({ type: "content_block_stop", index: state.thinkingBlockIndex });
|
|
226
|
+
state.thinkingBlockStarted = false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function stopTextBlock(state, results) {
|
|
230
|
+
if (!state.textBlockStarted || state.textBlockClosed) return;
|
|
231
|
+
state.textBlockClosed = true;
|
|
232
|
+
results.push({ type: "content_block_stop", index: state.textBlockIndex });
|
|
233
|
+
state.textBlockStarted = false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── 照抄 9Router: formatSSE ────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function formatSSE(data) {
|
|
239
|
+
if (data === null || data === undefined) return "data: null\n\n";
|
|
240
|
+
if (data && data.done) return "data: [DONE]\n\n";
|
|
241
|
+
// Claude format: events have a type field
|
|
242
|
+
if (data && data.type) {
|
|
243
|
+
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
244
|
+
}
|
|
245
|
+
return `data: ${JSON.stringify(data)}\n\n`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── SSE 行解析 (照抄 9Router parseSSELine) ─────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function parseSSELine(line) {
|
|
251
|
+
if (!line) return null;
|
|
252
|
+
if (line.charCodeAt(0) !== 100) return null; // 'd'
|
|
253
|
+
const data = line.slice(5).trim();
|
|
254
|
+
if (data === "[DONE]") return { done: true };
|
|
255
|
+
try { return JSON.parse(data); } catch { return null; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── 非流式响应 ──────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function openAIToClaudeResponse(resp) {
|
|
261
|
+
const choice = resp.choices?.[0];
|
|
262
|
+
if (!choice) return { type: "message", role: "assistant", content: [{ type: "text", text: "" }], model: resp.model || "unknown", stop_reason: "end_turn", stop_sequence: null };
|
|
263
|
+
const msg = choice.message || {};
|
|
264
|
+
const blocks = [];
|
|
265
|
+
const tc = msg.tool_calls;
|
|
266
|
+
if (msg.reasoning_content || msg.reasoning) blocks.push({ type: "thinking", thinking: msg.reasoning_content || msg.reasoning });
|
|
267
|
+
if (Array.isArray(tc) && tc.length > 0) {
|
|
268
|
+
if (msg.content) blocks.push({ type: "text", text: msg.content });
|
|
269
|
+
for (const t of tc) blocks.push({ type: "tool_use", id: t.id, name: t.function.name, input: safeJson(t.function.arguments) });
|
|
270
|
+
} else {
|
|
271
|
+
blocks.push({ type: "text", text: msg.content || "" });
|
|
272
|
+
}
|
|
273
|
+
const finishMap = { stop: "end_turn", tool_calls: "tool_use", length: "max_tokens" };
|
|
274
|
+
const result = { id: resp.id || `msg_${Date.now()}`, type: "message", role: "assistant", content: blocks, model: resp.model || "unknown", stop_reason: finishMap[choice.finish_reason] || choice.finish_reason || "end_turn", stop_sequence: null };
|
|
275
|
+
if (resp.usage) result.usage = { input_tokens: resp.usage.prompt_tokens || 0, output_tokens: resp.usage.completion_tokens || 0 };
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function safeJson(s) { try { return JSON.parse(s); } catch { return s; } }
|
|
280
|
+
|
|
281
|
+
// ─── HTTP 请求 ───────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
function fetchOpenCode(path, body, stream) {
|
|
284
|
+
const data = JSON.stringify(body);
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
const opts = {
|
|
287
|
+
hostname: OPENCODE_BASE, port: 443, path, method: "POST",
|
|
288
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data), Authorization: `Bearer ${AUTH_TOKEN}`, "x-opencode-client": "desktop" },
|
|
289
|
+
};
|
|
290
|
+
const req = https.request(opts, (res) => {
|
|
291
|
+
if (stream) { resolve({ ok: res.statusCode < 400, status: res.statusCode, stream: res }); return; }
|
|
292
|
+
const chunks = [];
|
|
293
|
+
res.on("data", c => chunks.push(c));
|
|
294
|
+
res.on("end", () => resolve({ ok: res.statusCode < 400, status: res.statusCode, body: Buffer.concat(chunks).toString() }));
|
|
295
|
+
});
|
|
296
|
+
req.on("error", reject);
|
|
297
|
+
req.write(data);
|
|
298
|
+
req.end();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
// ─── 路径标准化 ──────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function normalizePath(pathname) {
|
|
306
|
+
const p = pathname.replace(/\/+$/, "");
|
|
307
|
+
if (p.startsWith("/v1/v1/")) return p.replace("/v1/v1/", "/v1/");
|
|
308
|
+
if (p === "/v1/v1") return "/v1";
|
|
309
|
+
return p;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── 服务器 ───────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
const server = http.createServer(async (req, res) => {
|
|
315
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
316
|
+
url.pathname = normalizePath(url.pathname);
|
|
317
|
+
|
|
318
|
+
const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*" };
|
|
319
|
+
if (req.method === "OPTIONS") { res.writeHead(204, corsHeaders); res.end(); return; }
|
|
320
|
+
|
|
321
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
322
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
323
|
+
res.end(JSON.stringify({ status: "ok", port: PORT }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
328
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
329
|
+
res.end(JSON.stringify({ object: "list", data: [{ id: "combo", object: "model", owned_by: "combo" }] }));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (req.method === "GET" && url.pathname.startsWith("/v1/models/")) {
|
|
334
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
335
|
+
res.end(JSON.stringify({ id: url.pathname.split("/").pop(), object: "model", owned_by: "combo" }));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
340
|
+
const raw = []; for await (const c of req) raw.push(c);
|
|
341
|
+
const body = json(Buffer.concat(raw).toString()) || {};
|
|
342
|
+
let chars = 0;
|
|
343
|
+
for (const msg of (body.messages || [])) {
|
|
344
|
+
if (typeof msg.content === "string") chars += msg.content.length;
|
|
345
|
+
else if (Array.isArray(msg.content)) for (const p of msg.content) { if (p.type === "text" && p.text) chars += p.text.length; }
|
|
346
|
+
}
|
|
347
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
348
|
+
res.end(JSON.stringify({ input_tokens: Math.ceil(chars / 4) || 1 }));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (req.method === "POST" && url.pathname === "/v1/messages") {
|
|
353
|
+
const raw = []; for await (const c of req) raw.push(c);
|
|
354
|
+
const body = json(Buffer.concat(raw).toString());
|
|
355
|
+
if (!body) { res.writeHead(400); res.end(JSON.stringify({ error: "Invalid JSON" })); return; }
|
|
356
|
+
|
|
357
|
+
const openAIReq = claudeToOpenAI(body);
|
|
358
|
+
const upstream = await fetchOpenCode("/zen/v1/chat/completions", openAIReq, true);
|
|
359
|
+
|
|
360
|
+
if (!upstream.ok) {
|
|
361
|
+
let msg = "Upstream error";
|
|
362
|
+
try {
|
|
363
|
+
const chunk = await new Promise(rs => { const t = setTimeout(() => rs(""), 3000); upstream.stream.once("data", d => { clearTimeout(t); rs(d.toString()); }); });
|
|
364
|
+
msg = json(chunk)?.error?.message || chunk?.slice(0, 200) || msg;
|
|
365
|
+
} catch {}
|
|
366
|
+
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
|
367
|
+
res.end(JSON.stringify({ type: "error", error: { message: msg } }));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ─── 照抄 9Router 的流式处理 ─────────────────────────────────────────────
|
|
372
|
+
// pipeline: 解析 SSE → openaiToClaudeResponse → formatSSE → 写出
|
|
373
|
+
const state = {
|
|
374
|
+
messageStartSent: false, messageId: null, model: null,
|
|
375
|
+
thinkingBlockStarted: false, thinkingBlockIndex: -1,
|
|
376
|
+
textBlockStarted: false, textBlockIndex: -1, textBlockClosed: false,
|
|
377
|
+
toolCalls: new Map(), toolArgBuffers: null,
|
|
378
|
+
nextBlockIndex: 0, finishReason: null, usage: null,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
res.writeHead(200, {
|
|
382
|
+
"Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive",
|
|
383
|
+
"Access-Control-Allow-Origin": "*",
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
let sseBuffer = "";
|
|
387
|
+
upstream.stream.setEncoding("utf8");
|
|
388
|
+
|
|
389
|
+
upstream.stream.on("data", (chunk) => {
|
|
390
|
+
sseBuffer += chunk;
|
|
391
|
+
const lines = sseBuffer.split("\n");
|
|
392
|
+
sseBuffer = lines.pop() || "";
|
|
393
|
+
for (const line of lines) {
|
|
394
|
+
const trimmed = line.trim();
|
|
395
|
+
const parsed = parseSSELine(trimmed);
|
|
396
|
+
if (!parsed || parsed.done) continue;
|
|
397
|
+
|
|
398
|
+
// 用 9Router 的 openaiToClaudeResponse 处理这个 chunk
|
|
399
|
+
const events = openaiToClaudeResponse(parsed, state);
|
|
400
|
+
if (!events) continue;
|
|
401
|
+
|
|
402
|
+
for (const evt of events) {
|
|
403
|
+
res.write(formatSSE(evt));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
upstream.stream.on("end", () => {
|
|
409
|
+
res.end();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
upstream.stream.on("error", () => { res.end(); });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
res.writeHead(404);
|
|
417
|
+
res.end();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
421
|
+
console.log(`
|
|
422
|
+
╔═══════════════════════════════════════════╗
|
|
423
|
+
║ Pan Router :${PORT} ║
|
|
424
|
+
║ ║
|
|
425
|
+
║ Claude Code ← Pan Router → OpenCode Free ║
|
|
426
|
+
╚═══════════════════════════════════════════╝
|
|
427
|
+
Model: combo → deepseek-v4-flash-free
|
|
428
|
+
`);
|
|
429
|
+
});
|