branching-chat-connector 0.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/claude-connector.mjs +476 -0
- package/package.json +17 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 本机「陪读」连接器:把本机已登录的 Claude(Agent SDK)暴露成一个
|
|
3
|
+
// OpenAI 兼容的流式 /v1/chat/completions,供 Branching Chat 前端在选用
|
|
4
|
+
// `claude-cli` 模型时直接连接。只监听 127.0.0.1,不收集任何密钥。
|
|
5
|
+
import http from 'node:http'
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
11
|
+
|
|
12
|
+
const HOST = '127.0.0.1'
|
|
13
|
+
const PORT = Number.parseInt(process.env.CLAUDE_CONNECTOR_PORT || '8765', 10)
|
|
14
|
+
const MODEL = process.env.CLAUDE_CONNECTOR_MODEL?.trim() || undefined
|
|
15
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
16
|
+
const TOKEN_FILE = path.join(os.homedir(), '.branching-chat-connector.json')
|
|
17
|
+
|
|
18
|
+
const DEFAULT_ALLOWED_ORIGINS = [
|
|
19
|
+
'https://chat.branchingchat.xyz',
|
|
20
|
+
'https://staging.branchingchat.xyz',
|
|
21
|
+
'http://localhost:5173',
|
|
22
|
+
'http://127.0.0.1:5173',
|
|
23
|
+
]
|
|
24
|
+
const ALLOWED_ORIGINS = new Set(
|
|
25
|
+
((process.env.CLAUDE_CONNECTOR_ALLOWED_ORIGINS || '')
|
|
26
|
+
.split(',')
|
|
27
|
+
.map((value) => value.trim().replace(/\/+$/, ''))
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.length
|
|
30
|
+
? process.env.CLAUDE_CONNECTOR_ALLOWED_ORIGINS.split(',')
|
|
31
|
+
: DEFAULT_ALLOWED_ORIGINS
|
|
32
|
+
)
|
|
33
|
+
.map((value) => value.trim().replace(/\/+$/, ''))
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
function loadOrCreateToken() {
|
|
38
|
+
const envToken = process.env.CLAUDE_CONNECTOR_TOKEN?.trim()
|
|
39
|
+
if (envToken) return { token: envToken, source: 'env' }
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'))
|
|
42
|
+
if (parsed && typeof parsed.token === 'string' && parsed.token.length >= 16) {
|
|
43
|
+
return { token: parsed.token, source: 'file' }
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// no existing token file; fall through to generate one
|
|
47
|
+
}
|
|
48
|
+
const token = crypto.randomBytes(24).toString('base64url')
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(TOKEN_FILE, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 })
|
|
51
|
+
fs.chmodSync(TOKEN_FILE, 0o600)
|
|
52
|
+
} catch {
|
|
53
|
+
// best effort; token still works for this run even if it can't be persisted
|
|
54
|
+
}
|
|
55
|
+
return { token, source: 'generated' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { token: PAIRING_TOKEN, source: TOKEN_SOURCE } = loadOrCreateToken()
|
|
59
|
+
|
|
60
|
+
// 系统提示以网页端「编辑系统 Prompt」为准:请求里携带的 system 消息会直接作为
|
|
61
|
+
// SDK systemPrompt。只有当网页没有下发任何 system 时,才用这个极简兜底(或下面的环境变量覆盖)。
|
|
62
|
+
const FALLBACK_SYSTEM_PROMPT =
|
|
63
|
+
process.env.CLAUDE_CONNECTOR_SYSTEM_PROMPT?.trim() ||
|
|
64
|
+
'请用简体中文,清晰、有条理地回答。你没有文件读写或命令执行能力,直接用文字回答即可。'
|
|
65
|
+
|
|
66
|
+
// 订阅登录链路上 Claude 的思考文本会被加密屏蔽(只回签名、不回文字)。思考确实发生时
|
|
67
|
+
// 用这句占位,让前端的「思考」折叠至少能显示出来,而不是空白。
|
|
68
|
+
const THINKING_REDACTED_NOTE = '(本机 Claude 思考过程不外显:订阅链路只返回加密签名,没有可显示的思考文本。)'
|
|
69
|
+
|
|
70
|
+
function normalizeOrigin(origin) {
|
|
71
|
+
return typeof origin === 'string' ? origin.trim().replace(/\/+$/, '') : ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isAllowedOrigin(origin) {
|
|
75
|
+
// No Origin header = non-browser/local caller (curl, same process) — trusted on loopback.
|
|
76
|
+
if (!origin) return true
|
|
77
|
+
return ALLOWED_ORIGINS.has(normalizeOrigin(origin))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyCors(req, res) {
|
|
81
|
+
const origin = normalizeOrigin(req.headers.origin)
|
|
82
|
+
// Only reflect explicitly allow-listed origins; a disallowed site gets no ACAO and is blocked.
|
|
83
|
+
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
|
84
|
+
res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
|
|
85
|
+
// HTTPS sites (e.g. chat.branchingchat.xyz) calling http://127.0.0.1 trigger Chrome's
|
|
86
|
+
// Private Network Access preflight; acknowledge it so the loopback request isn't blocked.
|
|
87
|
+
if (req.headers['access-control-request-private-network'] === 'true') {
|
|
88
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
res.setHeader('Vary', 'Origin')
|
|
92
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
93
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Pairing-Token')
|
|
94
|
+
res.setHeader('Access-Control-Max-Age', '600')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getRequestToken(req) {
|
|
98
|
+
const header = req.headers['x-pairing-token']
|
|
99
|
+
if (typeof header === 'string' && header.trim()) return header.trim()
|
|
100
|
+
const auth = req.headers['authorization']
|
|
101
|
+
if (typeof auth === 'string') {
|
|
102
|
+
const match = /^Bearer\s+(.+)$/i.exec(auth.trim())
|
|
103
|
+
if (match) return match[1].trim()
|
|
104
|
+
}
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tokenValid(req) {
|
|
109
|
+
const provided = getRequestToken(req)
|
|
110
|
+
if (!provided || !PAIRING_TOKEN) return false
|
|
111
|
+
const a = Buffer.from(provided)
|
|
112
|
+
const b = Buffer.from(PAIRING_TOKEN)
|
|
113
|
+
if (a.length !== b.length) return false
|
|
114
|
+
return crypto.timingSafeEqual(a, b)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sendJson(res, status, body) {
|
|
118
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
119
|
+
res.end(JSON.stringify(body))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extractText(content) {
|
|
123
|
+
if (typeof content === 'string') return content
|
|
124
|
+
if (Array.isArray(content)) {
|
|
125
|
+
return content
|
|
126
|
+
.map((part) => {
|
|
127
|
+
if (part && typeof part === 'object') {
|
|
128
|
+
if (part.type === 'text' && typeof part.text === 'string') return part.text
|
|
129
|
+
if (part.type === 'image_url') return '[图片:本机 Claude 暂不支持图片,已忽略]'
|
|
130
|
+
}
|
|
131
|
+
return ''
|
|
132
|
+
})
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join('\n')
|
|
135
|
+
}
|
|
136
|
+
return ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractSystemPrompt(messages) {
|
|
140
|
+
const parts = []
|
|
141
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
142
|
+
if (message?.role !== 'system') continue
|
|
143
|
+
const text = extractText(message.content).trim()
|
|
144
|
+
if (text) parts.push(text)
|
|
145
|
+
}
|
|
146
|
+
return parts.join('\n\n')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// system 消息已经单独抽给 SDK 的 systemPrompt,这里只拍平用户/助手对话。
|
|
150
|
+
function buildPrompt(messages) {
|
|
151
|
+
const lines = []
|
|
152
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
153
|
+
if (message?.role === 'system') continue
|
|
154
|
+
const text = extractText(message?.content).trim()
|
|
155
|
+
if (!text) continue
|
|
156
|
+
const role = message.role === 'assistant' ? '助手' : '用户'
|
|
157
|
+
lines.push(`${role}:${text}`)
|
|
158
|
+
}
|
|
159
|
+
return lines.join('\n\n')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildQueryOptions({ stream, systemPrompt, thinking }) {
|
|
163
|
+
const options = {
|
|
164
|
+
systemPrompt: systemPrompt || FALLBACK_SYSTEM_PROMPT,
|
|
165
|
+
allowedTools: [],
|
|
166
|
+
settingSources: [],
|
|
167
|
+
maxTurns: 1,
|
|
168
|
+
includePartialMessages: stream,
|
|
169
|
+
}
|
|
170
|
+
if (MODEL) options.model = MODEL
|
|
171
|
+
if (thinking) options.thinking = { type: 'adaptive' }
|
|
172
|
+
return options
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function streamEventText(message) {
|
|
176
|
+
if (message?.type !== 'stream_event') return ''
|
|
177
|
+
const event = message.event
|
|
178
|
+
if (event?.type !== 'content_block_delta') return ''
|
|
179
|
+
const delta = event.delta
|
|
180
|
+
if (delta?.type === 'text_delta' && typeof delta.text === 'string') return delta.text
|
|
181
|
+
return ''
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function streamEventThinking(message) {
|
|
185
|
+
if (message?.type !== 'stream_event') return ''
|
|
186
|
+
const event = message.event
|
|
187
|
+
if (event?.type !== 'content_block_delta') return ''
|
|
188
|
+
const delta = event.delta
|
|
189
|
+
if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') return delta.thinking
|
|
190
|
+
return ''
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isThinkingBlockStart(message) {
|
|
194
|
+
if (message?.type !== 'stream_event') return false
|
|
195
|
+
const event = message.event
|
|
196
|
+
if (event?.type !== 'content_block_start') return false
|
|
197
|
+
const blockType = event.content_block?.type
|
|
198
|
+
return blockType === 'thinking' || blockType === 'redacted_thinking'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function assistantMessageText(message) {
|
|
202
|
+
if (message?.type !== 'assistant') return ''
|
|
203
|
+
const content = message.message?.content
|
|
204
|
+
if (!Array.isArray(content)) return ''
|
|
205
|
+
return content
|
|
206
|
+
.map((block) =>
|
|
207
|
+
block?.type === 'text' && typeof block.text === 'string' ? block.text : ''
|
|
208
|
+
)
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.join('')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function assistantMessageThinking(message) {
|
|
214
|
+
if (message?.type !== 'assistant') return ''
|
|
215
|
+
const content = message.message?.content
|
|
216
|
+
if (!Array.isArray(content)) return ''
|
|
217
|
+
return content
|
|
218
|
+
.map((block) =>
|
|
219
|
+
block?.type === 'thinking' && typeof block.thinking === 'string' ? block.thinking : ''
|
|
220
|
+
)
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join('')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function assistantHasThinkingBlock(message) {
|
|
226
|
+
if (message?.type !== 'assistant') return false
|
|
227
|
+
const content = message.message?.content
|
|
228
|
+
return (
|
|
229
|
+
Array.isArray(content) &&
|
|
230
|
+
content.some((block) => block?.type === 'thinking' || block?.type === 'redacted_thinking')
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sseData(payload) {
|
|
235
|
+
return `data: ${JSON.stringify(payload)}\n\n`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 识别 Claude 订阅用量/速率达到上限的错误(订阅链路常见的几种措辞)。
|
|
239
|
+
function isCliLimitText(text) {
|
|
240
|
+
return /usage limit|rate.?limit|rate_limit|limit reached|limit_reached|too many requests|overloaded|429|quota|用量.{0,4}上限|额度|限额/i.test(
|
|
241
|
+
String(text || '')
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function handleChatCompletion(req, res, body) {
|
|
246
|
+
const stream = Boolean(body?.stream)
|
|
247
|
+
const prompt = buildPrompt(body?.messages)
|
|
248
|
+
const systemPrompt = extractSystemPrompt(body?.messages)
|
|
249
|
+
const thinking = body?.thinkingMode === 'enabled'
|
|
250
|
+
if (!prompt) {
|
|
251
|
+
sendJson(res, 400, {
|
|
252
|
+
error: { message: 'messages 为空,无法生成回答。', code: 'empty_messages' },
|
|
253
|
+
})
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const routeHeaders = {
|
|
258
|
+
'x-chat-provider': 'claude-cli',
|
|
259
|
+
'x-chat-model': MODEL || 'claude-code',
|
|
260
|
+
'x-chat-route': 'local:claude-cli',
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!stream) {
|
|
264
|
+
try {
|
|
265
|
+
let full = ''
|
|
266
|
+
let reasoning = ''
|
|
267
|
+
let sawThinking = false
|
|
268
|
+
for await (const message of query({
|
|
269
|
+
prompt,
|
|
270
|
+
options: buildQueryOptions({ stream: false, systemPrompt, thinking }),
|
|
271
|
+
})) {
|
|
272
|
+
full += assistantMessageText(message)
|
|
273
|
+
reasoning += assistantMessageThinking(message)
|
|
274
|
+
if (assistantHasThinkingBlock(message)) sawThinking = true
|
|
275
|
+
}
|
|
276
|
+
const reasoningOut = thinking
|
|
277
|
+
? reasoning.trim() || (sawThinking ? THINKING_REDACTED_NOTE : '')
|
|
278
|
+
: ''
|
|
279
|
+
res.writeHead(200, { ...routeHeaders, 'Content-Type': 'application/json; charset=utf-8' })
|
|
280
|
+
res.end(
|
|
281
|
+
JSON.stringify({
|
|
282
|
+
choices: [
|
|
283
|
+
{
|
|
284
|
+
message: {
|
|
285
|
+
role: 'assistant',
|
|
286
|
+
content: full,
|
|
287
|
+
...(reasoningOut ? { reasoning_content: reasoningOut } : {}),
|
|
288
|
+
},
|
|
289
|
+
finish_reason: 'stop',
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
})
|
|
293
|
+
)
|
|
294
|
+
} catch (error) {
|
|
295
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
296
|
+
if (isCliLimitText(detail)) {
|
|
297
|
+
sendJson(res, 429, {
|
|
298
|
+
error: { message: `本机 Claude 用量已达上限:${detail}`, code: 'cli_rate_limited' },
|
|
299
|
+
})
|
|
300
|
+
} else {
|
|
301
|
+
sendJson(res, 502, {
|
|
302
|
+
error: { message: `本机 Claude 连接器出错:${detail}`, code: 'connector_error' },
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
res.writeHead(200, {
|
|
310
|
+
...routeHeaders,
|
|
311
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
312
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
313
|
+
Connection: 'keep-alive',
|
|
314
|
+
})
|
|
315
|
+
let sentAny = false
|
|
316
|
+
let reasoningSent = false
|
|
317
|
+
let thinkingNoteSent = false
|
|
318
|
+
const emitThinkingNote = () => {
|
|
319
|
+
if (thinkingNoteSent || reasoningSent) return
|
|
320
|
+
thinkingNoteSent = true
|
|
321
|
+
res.write(sseData({ choices: [{ delta: { reasoning_content: THINKING_REDACTED_NOTE } }] }))
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
for await (const message of query({
|
|
325
|
+
prompt,
|
|
326
|
+
options: buildQueryOptions({ stream: true, systemPrompt, thinking }),
|
|
327
|
+
})) {
|
|
328
|
+
// 思考块出现就先亮出折叠(订阅链路里随后的 thinking_delta 文本是空的)。
|
|
329
|
+
// 仅当用户开启思考模式时才显示;关闭时即便底层默认触发思考块也不外显。
|
|
330
|
+
if (isThinkingBlockStart(message)) {
|
|
331
|
+
if (thinking) emitThinkingNote()
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
const reasoning = thinking ? streamEventThinking(message) : ''
|
|
335
|
+
if (reasoning) {
|
|
336
|
+
reasoningSent = true
|
|
337
|
+
res.write(sseData({ choices: [{ delta: { reasoning_content: reasoning } }] }))
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
const text = streamEventText(message)
|
|
341
|
+
if (text) {
|
|
342
|
+
sentAny = true
|
|
343
|
+
res.write(sseData({ choices: [{ delta: { content: text } }] }))
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
if (
|
|
347
|
+
message?.type === 'result' &&
|
|
348
|
+
message.subtype &&
|
|
349
|
+
message.subtype !== 'success'
|
|
350
|
+
) {
|
|
351
|
+
if (isCliLimitText(message.subtype)) {
|
|
352
|
+
res.write(sseData({ x_limit: 'cli_rate_limited' }))
|
|
353
|
+
}
|
|
354
|
+
if (!sentAny) {
|
|
355
|
+
res.write(
|
|
356
|
+
sseData({
|
|
357
|
+
choices: [{ delta: { content: `本机 Claude 返回错误:${message.subtype}` } }],
|
|
358
|
+
})
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
365
|
+
if (isCliLimitText(detail)) {
|
|
366
|
+
res.write(sseData({ x_limit: 'cli_rate_limited' }))
|
|
367
|
+
}
|
|
368
|
+
if (!sentAny) {
|
|
369
|
+
res.write(sseData({ choices: [{ delta: { content: `本机 Claude 连接器出错:${detail}` } }] }))
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
res.write(sseData({ choices: [{ delta: {}, finish_reason: 'stop' }] }))
|
|
373
|
+
res.write('data: [DONE]\n\n')
|
|
374
|
+
res.end()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const server = http.createServer((req, res) => {
|
|
378
|
+
applyCors(req, res)
|
|
379
|
+
|
|
380
|
+
if (req.method === 'OPTIONS') {
|
|
381
|
+
res.writeHead(204)
|
|
382
|
+
res.end()
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const url = new URL(req.url || '/', `http://${HOST}:${PORT}`)
|
|
387
|
+
const originOk = isAllowedOrigin(req.headers.origin)
|
|
388
|
+
|
|
389
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
390
|
+
sendJson(res, 200, {
|
|
391
|
+
ok: true,
|
|
392
|
+
provider: 'claude-cli',
|
|
393
|
+
model: MODEL || 'claude-code',
|
|
394
|
+
requiresPairing: true,
|
|
395
|
+
paired: tokenValid(req),
|
|
396
|
+
})
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (req.method === 'POST' && url.pathname === '/pair') {
|
|
401
|
+
// Hand the pairing token only to allow-listed browser origins (or local non-browser callers).
|
|
402
|
+
if (!originOk) {
|
|
403
|
+
sendJson(res, 403, {
|
|
404
|
+
error: { message: '该来源未被授权配对。', code: 'origin_not_allowed' },
|
|
405
|
+
})
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
sendJson(res, 200, { ok: true, token: PAIRING_TOKEN, model: MODEL || 'claude-code' })
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
|
|
413
|
+
if (!originOk) {
|
|
414
|
+
sendJson(res, 403, {
|
|
415
|
+
error: { message: '该来源未被授权访问本机 Claude。', code: 'origin_not_allowed' },
|
|
416
|
+
})
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
if (!tokenValid(req)) {
|
|
420
|
+
sendJson(res, 401, {
|
|
421
|
+
error: { message: '未配对或配对令牌失效,请在设置里重新配对。', code: 'unauthorized' },
|
|
422
|
+
})
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
let received = 0
|
|
426
|
+
const chunks = []
|
|
427
|
+
req.on('data', (chunk) => {
|
|
428
|
+
received += chunk.length
|
|
429
|
+
if (received > MAX_BODY_BYTES) {
|
|
430
|
+
sendJson(res, 413, { error: { message: '请求体过大。', code: 'payload_too_large' } })
|
|
431
|
+
req.destroy()
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
chunks.push(chunk)
|
|
435
|
+
})
|
|
436
|
+
req.on('end', () => {
|
|
437
|
+
if (res.writableEnded) return
|
|
438
|
+
let body
|
|
439
|
+
try {
|
|
440
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
441
|
+
} catch {
|
|
442
|
+
sendJson(res, 400, {
|
|
443
|
+
error: { message: '请求体不是合法 JSON。', code: 'invalid_json' },
|
|
444
|
+
})
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
handleChatCompletion(req, res, body).catch((error) => {
|
|
448
|
+
if (res.writableEnded) return
|
|
449
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
450
|
+
sendJson(res, 500, { error: { message: detail, code: 'connector_error' } })
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
sendJson(res, 404, { error: { message: 'Not found', code: 'not_found' } })
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
server.listen(PORT, HOST, () => {
|
|
460
|
+
const tokenSourceLabel =
|
|
461
|
+
TOKEN_SOURCE === 'env'
|
|
462
|
+
? '来自环境变量 CLAUDE_CONNECTOR_TOKEN'
|
|
463
|
+
: TOKEN_SOURCE === 'file'
|
|
464
|
+
? `来自 ${TOKEN_FILE}`
|
|
465
|
+
: `已生成并保存到 ${TOKEN_FILE}`
|
|
466
|
+
process.stdout.write(`Claude 陪读连接器已启动:http://${HOST}:${PORT}\n`)
|
|
467
|
+
process.stdout.write(
|
|
468
|
+
`模型:${MODEL || '(SDK 默认)'};健康检查 GET /health;配对 POST /pair;聊天 POST /v1/chat/completions\n`
|
|
469
|
+
)
|
|
470
|
+
process.stdout.write(`允许的网页来源:${[...ALLOWED_ORIGINS].join(',') || '(无)'}\n`)
|
|
471
|
+
process.stdout.write(`配对令牌(${tokenSourceLabel}):${PAIRING_TOKEN}\n`)
|
|
472
|
+
process.stdout.write(
|
|
473
|
+
'在网页设置里点「一键配对」即可自动获取上面的令牌;也可手动复制。只有列表里的来源才能配对。\n'
|
|
474
|
+
)
|
|
475
|
+
process.stdout.write('凭证使用本机已登录的 Claude(或 ANTHROPIC_API_KEY),连接器不收集任何密钥。\n')
|
|
476
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "branching-chat-connector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "本机「陪读」连接器:把你已登录的 Claude(Agent SDK)暴露成 OpenAI 兼容接口,供 Branching Chat 的「claude-cli(本机)」模型调用。只监听 127.0.0.1,不收集任何密钥。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "claude-connector.mjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"claude-connector.mjs"
|
|
9
|
+
],
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.158"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|