@simonyea/holysheep-cli 1.6.14 → 1.7.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/package.json +2 -2
- package/src/commands/doctor.js +33 -0
- package/src/index.js +14 -0
- package/src/tools/claude-bridge.js +331 -0
- package/src/tools/claude-code.js +52 -4
- package/src/tools/openclaw-bridge.js +127 -24
- package/src/utils/config.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonyea/holysheep-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openai-china",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"homepage": "https://holysheep.ai",
|
|
43
43
|
"repository": {
|
|
44
44
|
"type": "git",
|
|
45
|
-
"url": "git+https://
|
|
45
|
+
"url": "git+https://gitee.com/holysheep123/holysheep-cli.git"
|
|
46
46
|
},
|
|
47
47
|
"license": "MIT",
|
|
48
48
|
"bin": {
|
package/src/commands/doctor.js
CHANGED
|
@@ -58,6 +58,9 @@ async function doctor() {
|
|
|
58
58
|
if (tool.id === 'openclaw' && installed) {
|
|
59
59
|
printOpenClawDetails(tool, installState, nodeMajor)
|
|
60
60
|
}
|
|
61
|
+
if (tool.id === 'claude-code' && installed && configured) {
|
|
62
|
+
printClaudeBridgeDetails(tool)
|
|
63
|
+
}
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
console.log()
|
|
@@ -156,6 +159,36 @@ function printOpenClawDetails(tool, installState, nodeMajor) {
|
|
|
156
159
|
})
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
function printClaudeBridgeDetails(tool) {
|
|
163
|
+
const port = typeof tool.getBridgePort === 'function' ? tool.getBridgePort() : null
|
|
164
|
+
if (!port) return
|
|
165
|
+
const bridgeConfig = typeof tool.getBridgeConfig === 'function' ? tool.getBridgeConfig() : {}
|
|
166
|
+
|
|
167
|
+
let healthy = false
|
|
168
|
+
try {
|
|
169
|
+
execSync(
|
|
170
|
+
process.platform === 'win32'
|
|
171
|
+
? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/health -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
|
|
172
|
+
: `curl -sf http://127.0.0.1:${port}/health -o /dev/null --max-time 1`,
|
|
173
|
+
{ stdio: 'ignore', timeout: 3000 }
|
|
174
|
+
)
|
|
175
|
+
healthy = true
|
|
176
|
+
} catch {}
|
|
177
|
+
|
|
178
|
+
const icon = healthy ? chalk.green('↳') : chalk.yellow('↳')
|
|
179
|
+
const text = healthy
|
|
180
|
+
? chalk.green(`Claude Bridge 已运行:127.0.0.1:${port}`)
|
|
181
|
+
: chalk.yellow(`Claude Bridge 未运行:127.0.0.1:${port}(可运行 hs claude-bridge)`)
|
|
182
|
+
console.log(` ${icon} ${text}`)
|
|
183
|
+
|
|
184
|
+
const relayUrl = bridgeConfig.relayUrl || bridgeConfig.controlPlaneUrl || ''
|
|
185
|
+
if (relayUrl) {
|
|
186
|
+
console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude Relay: ${relayUrl}`)}`)
|
|
187
|
+
} else {
|
|
188
|
+
console.log(` ${chalk.gray('↳')} ${chalk.gray('Claude Relay: 未配置,当前仍直连 Anthropic 兼容入口')}`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
159
192
|
function maskKey(key) {
|
|
160
193
|
if (!key || key.length < 8) return '****'
|
|
161
194
|
return key.slice(0, 6) + '...' + key.slice(-4)
|
package/src/index.js
CHANGED
|
@@ -167,6 +167,20 @@ program
|
|
|
167
167
|
})
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
+
// ── claude-bridge ────────────────────────────────────────────────────────────
|
|
171
|
+
program
|
|
172
|
+
.command('claude-bridge')
|
|
173
|
+
.description('启动 HolySheep 的 Claude Code 本地桥接服务')
|
|
174
|
+
.option('--port <port>', '指定桥接服务端口')
|
|
175
|
+
.option('--host <host>', '指定桥接服务监听地址')
|
|
176
|
+
.action((opts) => {
|
|
177
|
+
const { startBridge } = require('./tools/claude-bridge')
|
|
178
|
+
startBridge({
|
|
179
|
+
port: opts.port ? Number(opts.port) : null,
|
|
180
|
+
host: opts.host || '127.0.0.1',
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
170
184
|
// 默认:无命令时显示帮助 + 提示 setup
|
|
171
185
|
program
|
|
172
186
|
.action(() => {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const os = require('os')
|
|
8
|
+
const crypto = require('crypto')
|
|
9
|
+
const { spawn, execSync } = require('child_process')
|
|
10
|
+
const fetch = global.fetch || require('node-fetch')
|
|
11
|
+
|
|
12
|
+
const HOLYSHEEP_DIR = path.join(os.homedir(), '.holysheep')
|
|
13
|
+
const CLAUDE_BRIDGE_CONFIG_FILE = path.join(HOLYSHEEP_DIR, 'claude-bridge.json')
|
|
14
|
+
const DEFAULT_BRIDGE_PORT = 14555
|
|
15
|
+
const isWin = process.platform === 'win32'
|
|
16
|
+
const leaseCache = new Map()
|
|
17
|
+
|
|
18
|
+
function ensureDir() {
|
|
19
|
+
if (!fs.existsSync(HOLYSHEEP_DIR)) fs.mkdirSync(HOLYSHEEP_DIR, { recursive: true })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = { port: null, host: '127.0.0.1', config: CLAUDE_BRIDGE_CONFIG_FILE }
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const value = argv[i]
|
|
26
|
+
if (value === '--port') args.port = Number(argv[++i])
|
|
27
|
+
else if (value === '--host') args.host = argv[++i]
|
|
28
|
+
else if (value === '--config') args.config = argv[++i]
|
|
29
|
+
}
|
|
30
|
+
return args
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readBridgeConfig(configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
36
|
+
} catch {
|
|
37
|
+
return {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeBridgeConfig(data, configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
|
|
42
|
+
ensureDir()
|
|
43
|
+
fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf8')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getConfiguredBridgePort(config = readBridgeConfig()) {
|
|
47
|
+
const port = Number(config.port)
|
|
48
|
+
return Number.isInteger(port) && port > 0 ? port : DEFAULT_BRIDGE_PORT
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getBridgeBaseUrl(port = getConfiguredBridgePort()) {
|
|
52
|
+
return `http://127.0.0.1:${port}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sendJson(res, statusCode, payload) {
|
|
56
|
+
res.writeHead(statusCode, {
|
|
57
|
+
'content-type': 'application/json; charset=utf-8',
|
|
58
|
+
'cache-control': 'no-store',
|
|
59
|
+
})
|
|
60
|
+
res.end(JSON.stringify(payload))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRawBody(req) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const chunks = []
|
|
66
|
+
let size = 0
|
|
67
|
+
req.on('data', (chunk) => {
|
|
68
|
+
size += chunk.length
|
|
69
|
+
if (size > 20 * 1024 * 1024) {
|
|
70
|
+
reject(new Error('Request body too large'))
|
|
71
|
+
req.destroy()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
chunks.push(chunk)
|
|
75
|
+
})
|
|
76
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
77
|
+
req.on('error', reject)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stripBearer(value) {
|
|
82
|
+
const raw = String(value || '').trim()
|
|
83
|
+
if (!raw) return ''
|
|
84
|
+
if (/^Bearer\s+/i.test(raw)) return raw.replace(/^Bearer\s+/i, '').trim()
|
|
85
|
+
return raw
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildBodyHash(body) {
|
|
89
|
+
return crypto.createHash('sha256').update(body || Buffer.alloc(0)).digest('hex')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildBridgeSignature(req, config, body, timestamp) {
|
|
93
|
+
const secret = String(config.bridgeSecret || '').trim()
|
|
94
|
+
if (!secret) return ''
|
|
95
|
+
|
|
96
|
+
const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
|
|
97
|
+
const canonical = [
|
|
98
|
+
String(req.method || 'GET').toUpperCase(),
|
|
99
|
+
url.pathname + (url.search || ''),
|
|
100
|
+
String(timestamp),
|
|
101
|
+
buildBodyHash(body),
|
|
102
|
+
String(config.bridgeId || '')
|
|
103
|
+
].join('\n')
|
|
104
|
+
|
|
105
|
+
return crypto.createHmac('sha256', secret).update(canonical).digest('hex')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getBridgeUpstream(config) {
|
|
109
|
+
return String(config.relayUrl || config.controlPlaneUrl || config.baseUrlAnthropic || '').replace(/\/+$/, '')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseJsonBody(body) {
|
|
113
|
+
if (!body || !body.length) return null
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(body.toString('utf8'))
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractSessionId(parsedBody, config) {
|
|
122
|
+
const headerSessionId =
|
|
123
|
+
parsedBody?.metadata?.session_id ||
|
|
124
|
+
parsedBody?.session_id ||
|
|
125
|
+
parsedBody?.metadata?.conversation_id ||
|
|
126
|
+
null
|
|
127
|
+
if (headerSessionId) return String(headerSessionId)
|
|
128
|
+
|
|
129
|
+
const metadataUserId = parsedBody?.metadata?.user_id
|
|
130
|
+
if (typeof metadataUserId === 'string' && metadataUserId.trim()) {
|
|
131
|
+
return metadataUserId.trim()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return `${config.bridgeId || 'local-bridge'}:default`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isRelayMode(config) {
|
|
138
|
+
const upstream = getBridgeUpstream(config)
|
|
139
|
+
return /\/claude-relay(?:\/|$)/.test(upstream)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function requestSessionLease(config, sessionId, parsedBody) {
|
|
143
|
+
const controlPlaneUrl = getBridgeUpstream(config)
|
|
144
|
+
const cached = leaseCache.get(sessionId)
|
|
145
|
+
if (cached && cached.expiresAt && new Date(cached.expiresAt).getTime() - Date.now() > 30_000) {
|
|
146
|
+
return cached
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const response = await fetch(`${controlPlaneUrl}/session/open`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: {
|
|
152
|
+
'content-type': 'application/json',
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
sessionId,
|
|
156
|
+
bridgeId: config.bridgeId || 'local-bridge',
|
|
157
|
+
deviceId: config.deviceId || '',
|
|
158
|
+
model: parsedBody?.model || null,
|
|
159
|
+
installSource: config.installSource || 'holysheep-cli',
|
|
160
|
+
}),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const payload = await response.json().catch(() => null)
|
|
164
|
+
if (!response.ok || !payload?.success || !payload?.data?.nodeBaseUrl || !payload?.data?.ticket) {
|
|
165
|
+
const message =
|
|
166
|
+
payload?.error?.message ||
|
|
167
|
+
payload?.message ||
|
|
168
|
+
`Failed to open Claude relay session (HTTP ${response.status})`
|
|
169
|
+
throw new Error(message)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
leaseCache.set(sessionId, payload.data)
|
|
173
|
+
return payload.data
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildUpstreamHeaders(req, config, body) {
|
|
177
|
+
const headers = {}
|
|
178
|
+
for (const [key, value] of Object.entries(req.headers || {})) {
|
|
179
|
+
const normalized = key.toLowerCase()
|
|
180
|
+
if (normalized === 'host') continue
|
|
181
|
+
if (normalized === 'content-length') continue
|
|
182
|
+
headers[normalized] = value
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const token = stripBearer(headers.authorization) || headers['x-api-key'] || config.apiKey || ''
|
|
186
|
+
delete headers.authorization
|
|
187
|
+
if (token) headers['x-api-key'] = token
|
|
188
|
+
if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01'
|
|
189
|
+
headers['user-agent'] = 'holysheep-claude-bridge/1.0'
|
|
190
|
+
headers['x-hs-bridge-id'] = config.bridgeId || 'local-bridge'
|
|
191
|
+
headers['x-hs-install-source'] = config.installSource || 'holysheep-cli'
|
|
192
|
+
if (config.deviceId) headers['x-hs-device-id'] = config.deviceId
|
|
193
|
+
if (config.userToken) headers['x-hs-user-token'] = config.userToken
|
|
194
|
+
if (config.nodeId) headers['x-hs-node-id'] = config.nodeId
|
|
195
|
+
if (config.sessionMode) headers['x-hs-session-mode'] = config.sessionMode
|
|
196
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
197
|
+
headers['x-hs-bridge-timestamp'] = String(timestamp)
|
|
198
|
+
const signature = buildBridgeSignature(req, config, body, timestamp)
|
|
199
|
+
if (signature) headers['x-hs-bridge-signature'] = signature
|
|
200
|
+
if (body && body.length > 0) headers['content-length'] = String(body.length)
|
|
201
|
+
|
|
202
|
+
return headers
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createBridgeServer(configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
|
|
206
|
+
return http.createServer(async (req, res) => {
|
|
207
|
+
if (req.method === 'OPTIONS') {
|
|
208
|
+
res.writeHead(204, {
|
|
209
|
+
'access-control-allow-origin': '*',
|
|
210
|
+
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
|
211
|
+
'access-control-allow-headers': 'content-type,authorization,x-api-key,anthropic-version',
|
|
212
|
+
})
|
|
213
|
+
return res.end()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const config = readBridgeConfig(configPath)
|
|
218
|
+
const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
|
|
219
|
+
const port = getConfiguredBridgePort(config)
|
|
220
|
+
|
|
221
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
222
|
+
return sendJson(res, 200, {
|
|
223
|
+
ok: true,
|
|
224
|
+
port,
|
|
225
|
+
upstream: getBridgeUpstream(config),
|
|
226
|
+
relayUrl: config.relayUrl || '',
|
|
227
|
+
mode: config.relayUrl ? 'relay' : 'direct',
|
|
228
|
+
bridgeId: config.bridgeId || null,
|
|
229
|
+
nodeId: config.nodeId || null,
|
|
230
|
+
installSource: config.installSource || 'holysheep-cli',
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let upstreamBase = getBridgeUpstream(config)
|
|
235
|
+
if (!upstreamBase) {
|
|
236
|
+
return sendJson(res, 500, { error: { message: 'Bridge upstream is not configured' } })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const body = await readRawBody(req)
|
|
240
|
+
const parsedBody = parseJsonBody(body)
|
|
241
|
+
if (isRelayMode(config) && req.method !== 'GET' && url.pathname !== '/health') {
|
|
242
|
+
const sessionId = extractSessionId(parsedBody, config)
|
|
243
|
+
const lease = await requestSessionLease(config, sessionId, parsedBody)
|
|
244
|
+
upstreamBase = String(lease.nodeBaseUrl || '').replace(/\/+$/, '')
|
|
245
|
+
req.headers['x-hs-session-id'] = sessionId
|
|
246
|
+
req.headers['x-hs-bridge-ticket'] = lease.ticket
|
|
247
|
+
req.headers['x-hs-node-id'] = lease.nodeId || ''
|
|
248
|
+
}
|
|
249
|
+
const upstreamUrl = `${upstreamBase}${url.pathname}${url.search || ''}`
|
|
250
|
+
const upstream = await fetch(upstreamUrl, {
|
|
251
|
+
method: req.method,
|
|
252
|
+
headers: buildUpstreamHeaders(req, config, body),
|
|
253
|
+
body: body.length > 0 ? body : undefined,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
res.writeHead(upstream.status, {
|
|
257
|
+
'content-type': upstream.headers.get('content-type') || 'application/json; charset=utf-8',
|
|
258
|
+
'cache-control': upstream.headers.get('cache-control') || 'no-store',
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
if (upstream.body && typeof upstream.body.pipe === 'function') {
|
|
262
|
+
upstream.body.pipe(res)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const text = await upstream.text()
|
|
267
|
+
res.end(text)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return sendJson(res, 500, { error: { message: error.message || 'Claude bridge error' } })
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function waitForBridge(port) {
|
|
275
|
+
for (let i = 0; i < 12; i++) {
|
|
276
|
+
const t0 = Date.now()
|
|
277
|
+
while (Date.now() - t0 < 500) {}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
execSync(
|
|
281
|
+
isWin
|
|
282
|
+
? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/health -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
|
|
283
|
+
: `curl -sf http://127.0.0.1:${port}/health -o /dev/null --max-time 1`,
|
|
284
|
+
{ stdio: 'ignore', timeout: 3000 }
|
|
285
|
+
)
|
|
286
|
+
return true
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function startBridge(args = parseArgs(process.argv.slice(2))) {
|
|
294
|
+
const config = readBridgeConfig(args.config)
|
|
295
|
+
const port = args.port || getConfiguredBridgePort(config)
|
|
296
|
+
const host = args.host || '127.0.0.1'
|
|
297
|
+
const server = createBridgeServer(args.config)
|
|
298
|
+
|
|
299
|
+
server.listen(port, host, () => {
|
|
300
|
+
process.stdout.write(`HolySheep Claude bridge listening on http://${host}:${port}\n`)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
return server
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function spawnBridge(port = getConfiguredBridgePort()) {
|
|
307
|
+
if (waitForBridge(port)) return true
|
|
308
|
+
|
|
309
|
+
const scriptPath = path.join(__dirname, '..', 'index.js')
|
|
310
|
+
const child = spawn(process.execPath, [scriptPath, 'claude-bridge', '--port', String(port)], {
|
|
311
|
+
detached: true,
|
|
312
|
+
stdio: 'ignore',
|
|
313
|
+
})
|
|
314
|
+
child.unref()
|
|
315
|
+
|
|
316
|
+
return waitForBridge(port)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
module.exports = {
|
|
320
|
+
CLAUDE_BRIDGE_CONFIG_FILE,
|
|
321
|
+
DEFAULT_BRIDGE_PORT,
|
|
322
|
+
createBridgeServer,
|
|
323
|
+
getBridgeBaseUrl,
|
|
324
|
+
getConfiguredBridgePort,
|
|
325
|
+
parseArgs,
|
|
326
|
+
readBridgeConfig,
|
|
327
|
+
spawnBridge,
|
|
328
|
+
startBridge,
|
|
329
|
+
waitForBridge,
|
|
330
|
+
writeBridgeConfig,
|
|
331
|
+
}
|
package/src/tools/claude-code.js
CHANGED
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
const fs = require('fs')
|
|
12
12
|
const path = require('path')
|
|
13
13
|
const os = require('os')
|
|
14
|
+
const crypto = require('crypto')
|
|
15
|
+
const {
|
|
16
|
+
BASE_URL_ANTHROPIC,
|
|
17
|
+
BASE_URL_CLAUDE_RELAY,
|
|
18
|
+
} = require('../utils/config')
|
|
19
|
+
const {
|
|
20
|
+
DEFAULT_BRIDGE_PORT,
|
|
21
|
+
getBridgeBaseUrl,
|
|
22
|
+
getConfiguredBridgePort,
|
|
23
|
+
readBridgeConfig,
|
|
24
|
+
spawnBridge,
|
|
25
|
+
writeBridgeConfig,
|
|
26
|
+
} = require('./claude-bridge')
|
|
14
27
|
|
|
15
28
|
const SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.json')
|
|
16
29
|
|
|
@@ -38,15 +51,43 @@ module.exports = {
|
|
|
38
51
|
},
|
|
39
52
|
isConfigured() {
|
|
40
53
|
const s = readSettings()
|
|
41
|
-
|
|
54
|
+
const bridge = readBridgeConfig()
|
|
55
|
+
const bridgePort = getConfiguredBridgePort(bridge)
|
|
56
|
+
return Boolean(
|
|
57
|
+
s.env?.ANTHROPIC_AUTH_TOKEN &&
|
|
58
|
+
s.env?.ANTHROPIC_BASE_URL === getBridgeBaseUrl(bridgePort) &&
|
|
59
|
+
bridge.apiKey &&
|
|
60
|
+
bridge.bridgeSecret
|
|
61
|
+
)
|
|
42
62
|
},
|
|
43
63
|
configure(apiKey, baseUrl) {
|
|
64
|
+
const bridge = readBridgeConfig()
|
|
65
|
+
const bridgePort = getConfiguredBridgePort(bridge)
|
|
66
|
+
const relayUrl = bridge.relayUrl || BASE_URL_CLAUDE_RELAY || ''
|
|
67
|
+
writeBridgeConfig({
|
|
68
|
+
port: bridgePort || DEFAULT_BRIDGE_PORT,
|
|
69
|
+
apiKey,
|
|
70
|
+
baseUrlAnthropic: baseUrl || BASE_URL_ANTHROPIC,
|
|
71
|
+
controlPlaneUrl: relayUrl || baseUrl || BASE_URL_ANTHROPIC,
|
|
72
|
+
relayUrl,
|
|
73
|
+
bridgeId: bridge.bridgeId || crypto.randomUUID(),
|
|
74
|
+
deviceId: bridge.deviceId || crypto.randomUUID(),
|
|
75
|
+
bridgeSecret: bridge.bridgeSecret || crypto.randomBytes(32).toString('hex'),
|
|
76
|
+
nodeId: bridge.nodeId || '',
|
|
77
|
+
sessionMode: bridge.sessionMode || 'pinned-node',
|
|
78
|
+
installSource: 'holysheep-cli',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!spawnBridge(bridgePort)) {
|
|
82
|
+
throw new Error('HolySheep Claude Bridge 启动失败')
|
|
83
|
+
}
|
|
84
|
+
|
|
44
85
|
const settings = readSettings()
|
|
45
86
|
if (!settings.env) settings.env = {}
|
|
46
|
-
// Claude Code 用 ANTHROPIC_AUTH_TOKEN(最高优先级),兼容 ANTHROPIC_API_KEY
|
|
47
87
|
settings.env.ANTHROPIC_AUTH_TOKEN = apiKey
|
|
48
|
-
settings.env.ANTHROPIC_BASE_URL =
|
|
88
|
+
settings.env.ANTHROPIC_BASE_URL = getBridgeBaseUrl(bridgePort)
|
|
49
89
|
settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
|
|
90
|
+
settings.env.HOLYSHEEP_CLAUDE_BRIDGE = '1'
|
|
50
91
|
// 清理旧的同义字段
|
|
51
92
|
delete settings.env.ANTHROPIC_API_KEY
|
|
52
93
|
writeSettings(settings)
|
|
@@ -59,12 +100,19 @@ module.exports = {
|
|
|
59
100
|
delete settings.env.ANTHROPIC_API_KEY
|
|
60
101
|
delete settings.env.ANTHROPIC_BASE_URL
|
|
61
102
|
delete settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
|
103
|
+
delete settings.env.HOLYSHEEP_CLAUDE_BRIDGE
|
|
62
104
|
}
|
|
63
105
|
writeSettings(settings)
|
|
64
106
|
},
|
|
65
107
|
getConfigPath() { return SETTINGS_FILE },
|
|
66
|
-
hint: '
|
|
108
|
+
hint: '通过 HolySheep 本地 Bridge 接入,支持热切换,无需重启终端',
|
|
67
109
|
launchCmd: 'claude',
|
|
68
110
|
installCmd: 'npm install -g @anthropic-ai/claude-code',
|
|
69
111
|
docsUrl: 'https://docs.anthropic.com/claude-code',
|
|
112
|
+
getBridgePort() {
|
|
113
|
+
return getConfiguredBridgePort()
|
|
114
|
+
},
|
|
115
|
+
getBridgeConfig() {
|
|
116
|
+
return readBridgeConfig()
|
|
117
|
+
},
|
|
70
118
|
}
|
|
@@ -59,6 +59,7 @@ function sendOpenAIStream(res, payload) {
|
|
|
59
59
|
const choice = payload.choices?.[0] || {}
|
|
60
60
|
const message = choice.message || {}
|
|
61
61
|
const created = payload.created || Math.floor(Date.now() / 1000)
|
|
62
|
+
const messageContent = extractOpenAITextContent(message.content)
|
|
62
63
|
|
|
63
64
|
res.writeHead(200, {
|
|
64
65
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
@@ -75,7 +76,7 @@ function sendOpenAIStream(res, payload) {
|
|
|
75
76
|
index: 0,
|
|
76
77
|
delta: {
|
|
77
78
|
role: 'assistant',
|
|
78
|
-
...(
|
|
79
|
+
...(messageContent ? { content: messageContent } : {}),
|
|
79
80
|
...(message.tool_calls ? { tool_calls: message.tool_calls } : {}),
|
|
80
81
|
},
|
|
81
82
|
finish_reason: null,
|
|
@@ -101,11 +102,30 @@ function normalizeText(value) {
|
|
|
101
102
|
if (Array.isArray(value)) return value.map(normalizeText).filter(Boolean).join('\n')
|
|
102
103
|
if (value && typeof value === 'object') {
|
|
103
104
|
if (typeof value.text === 'string') return value.text
|
|
105
|
+
if (typeof value.output_text === 'string') return value.output_text
|
|
104
106
|
if (typeof value.content === 'string') return value.content
|
|
107
|
+
if (typeof value.value === 'string') return value.value
|
|
105
108
|
}
|
|
106
109
|
return value == null ? '' : String(value)
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
function extractOpenAITextContent(content) {
|
|
113
|
+
if (typeof content === 'string') return content
|
|
114
|
+
if (!Array.isArray(content)) return normalizeText(content)
|
|
115
|
+
|
|
116
|
+
return content
|
|
117
|
+
.map((part) => {
|
|
118
|
+
if (typeof part === 'string') return part
|
|
119
|
+
if (!part || typeof part !== 'object') return ''
|
|
120
|
+
if (part.type === 'text') return normalizeText(part.text)
|
|
121
|
+
if (part.type === 'output_text') return normalizeText(part.text)
|
|
122
|
+
if (part.type === 'input_text') return normalizeText(part.text)
|
|
123
|
+
return normalizeText(part.text || part.content || part.value)
|
|
124
|
+
})
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join('')
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
function parseDataUrl(url) {
|
|
110
130
|
const match = String(url || '').match(/^data:([^;]+);base64,(.+)$/)
|
|
111
131
|
if (!match) return null
|
|
@@ -314,10 +334,102 @@ function pickRoute(model) {
|
|
|
314
334
|
return 'openai'
|
|
315
335
|
}
|
|
316
336
|
|
|
317
|
-
function
|
|
337
|
+
function responseOutputToText(output) {
|
|
338
|
+
return (output || [])
|
|
339
|
+
.flatMap((item) => {
|
|
340
|
+
if (item?.type === 'message') return item.content || []
|
|
341
|
+
if (item?.content) return item.content
|
|
342
|
+
return []
|
|
343
|
+
})
|
|
344
|
+
.filter((item) => item?.type === 'output_text' || item?.type === 'text')
|
|
345
|
+
.map((item) => extractOpenAITextContent(item.text || item.content || item))
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join('')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function responseOutputToToolCalls(output) {
|
|
351
|
+
return (output || [])
|
|
352
|
+
.filter((item) => item?.type === 'function_call' && item.name)
|
|
353
|
+
.map((item, index) => ({
|
|
354
|
+
id: item.call_id || item.id || `call_${index + 1}`,
|
|
355
|
+
type: 'function',
|
|
356
|
+
function: {
|
|
357
|
+
name: item.name,
|
|
358
|
+
arguments: typeof item.arguments === 'string'
|
|
359
|
+
? item.arguments
|
|
360
|
+
: JSON.stringify(item.arguments || {}),
|
|
361
|
+
},
|
|
362
|
+
}))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function responseToChatCompletion(responseBody, requestedModel) {
|
|
366
|
+
const response = responseBody?.response && typeof responseBody.response === 'object'
|
|
367
|
+
? responseBody.response
|
|
368
|
+
: responseBody
|
|
369
|
+
|
|
370
|
+
const text = responseOutputToText(response.output)
|
|
371
|
+
const toolCalls = responseOutputToToolCalls(response.output)
|
|
372
|
+
const status = String(response.status || '').toLowerCase()
|
|
373
|
+
const finishReason = status === 'completed' || status === '' ? 'stop' : 'length'
|
|
374
|
+
const outputTokens = response.usage?.output_tokens || response.usage?.completion_tokens || 0
|
|
375
|
+
const promptTokens = response.usage?.input_tokens || response.usage?.prompt_tokens || 0
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
id: response.id || `chatcmpl_${Date.now()}`,
|
|
379
|
+
object: 'chat.completion',
|
|
380
|
+
created: response.created_at || Math.floor(Date.now() / 1000),
|
|
381
|
+
model: requestedModel || response.model,
|
|
382
|
+
choices: [{
|
|
383
|
+
index: 0,
|
|
384
|
+
message: {
|
|
385
|
+
role: 'assistant',
|
|
386
|
+
content: text || null,
|
|
387
|
+
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
|
|
388
|
+
},
|
|
389
|
+
finish_reason: finishReason,
|
|
390
|
+
}],
|
|
391
|
+
usage: response.usage
|
|
392
|
+
? {
|
|
393
|
+
prompt_tokens: promptTokens,
|
|
394
|
+
completion_tokens: outputTokens,
|
|
395
|
+
total_tokens: response.usage.total_tokens || (promptTokens + outputTokens),
|
|
396
|
+
}
|
|
397
|
+
: undefined,
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function normalizeOpenAICompatibleResponse(parsed, requestedModel) {
|
|
402
|
+
if (!parsed || typeof parsed !== 'object') return parsed
|
|
403
|
+
|
|
404
|
+
if (parsed.object === 'response' || Array.isArray(parsed.output)) {
|
|
405
|
+
return responseToChatCompletion(parsed, requestedModel)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (parsed.object === 'chat.completion' || parsed.object === 'chat.completion.chunk') {
|
|
409
|
+
const choice = parsed.choices?.[0]
|
|
410
|
+
if (choice?.message) {
|
|
411
|
+
choice.message = {
|
|
412
|
+
...choice.message,
|
|
413
|
+
content: extractOpenAITextContent(choice.message.content) || null,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (choice?.delta?.content != null) {
|
|
417
|
+
choice.delta = {
|
|
418
|
+
...choice.delta,
|
|
419
|
+
content: extractOpenAITextContent(choice.delta.content),
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return parsed
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function parseOpenAIStreamText(text, requestedModel) {
|
|
318
428
|
try {
|
|
319
429
|
const parsed = JSON.parse(String(text || ''))
|
|
320
|
-
if (parsed && typeof parsed === 'object')
|
|
430
|
+
if (parsed && typeof parsed === 'object') {
|
|
431
|
+
return normalizeOpenAICompatibleResponse(parsed, requestedModel)
|
|
432
|
+
}
|
|
321
433
|
} catch {}
|
|
322
434
|
|
|
323
435
|
const blocks = String(text || '').split(/\r?\n\r?\n+/).filter(Boolean)
|
|
@@ -356,11 +468,7 @@ function parseOpenAIStreamText(text) {
|
|
|
356
468
|
if (eventName === 'response.completed' && chunk.response) {
|
|
357
469
|
responseCompleted = chunk.response
|
|
358
470
|
if (!content) {
|
|
359
|
-
const outputText = (chunk.response.output
|
|
360
|
-
.flatMap((item) => item?.content || [])
|
|
361
|
-
.filter((item) => item?.type === 'output_text' && typeof item.text === 'string')
|
|
362
|
-
.map((item) => item.text)
|
|
363
|
-
.join('')
|
|
471
|
+
const outputText = responseOutputToText(chunk.response.output)
|
|
364
472
|
if (outputText) content = outputText
|
|
365
473
|
}
|
|
366
474
|
continue
|
|
@@ -369,28 +477,23 @@ function parseOpenAIStreamText(text) {
|
|
|
369
477
|
finalChunk = chunk
|
|
370
478
|
const choice = chunk.choices?.[0] || {}
|
|
371
479
|
const delta = choice.delta || {}
|
|
372
|
-
|
|
373
|
-
|
|
480
|
+
const deltaContent = extractOpenAITextContent(delta.content)
|
|
481
|
+
const messageContent = extractOpenAITextContent(choice.message?.content)
|
|
482
|
+
if (deltaContent) content += deltaContent
|
|
483
|
+
else if (messageContent) content += messageContent
|
|
374
484
|
}
|
|
375
485
|
|
|
376
486
|
if (responseCompleted) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
created: responseCompleted.created_at || Math.floor(Date.now() / 1000),
|
|
381
|
-
model: responseCompleted.model,
|
|
382
|
-
choices: [{
|
|
383
|
-
index: 0,
|
|
384
|
-
message: { role: 'assistant', content: content || null },
|
|
385
|
-
finish_reason: responseCompleted.status === 'completed' ? 'stop' : 'length',
|
|
386
|
-
}],
|
|
387
|
-
usage: responseCompleted.usage,
|
|
487
|
+
const completion = responseToChatCompletion(responseCompleted, requestedModel || responseCompleted.model)
|
|
488
|
+
if (!completion.choices?.[0]?.message?.content && content) {
|
|
489
|
+
completion.choices[0].message.content = content
|
|
388
490
|
}
|
|
491
|
+
return completion
|
|
389
492
|
}
|
|
390
493
|
|
|
391
494
|
if (!finalChunk) return null
|
|
392
495
|
|
|
393
|
-
return {
|
|
496
|
+
return normalizeOpenAICompatibleResponse({
|
|
394
497
|
id: finalChunk.id || `chatcmpl_${Date.now()}`,
|
|
395
498
|
object: 'chat.completion',
|
|
396
499
|
created: finalChunk.created || Math.floor(Date.now() / 1000),
|
|
@@ -401,7 +504,7 @@ function parseOpenAIStreamText(text) {
|
|
|
401
504
|
finish_reason: finalChunk.choices?.[0]?.finish_reason || 'stop',
|
|
402
505
|
}],
|
|
403
506
|
usage: finalChunk.usage,
|
|
404
|
-
}
|
|
507
|
+
}, requestedModel)
|
|
405
508
|
}
|
|
406
509
|
|
|
407
510
|
async function relayOpenAIRequest(requestBody, config, res) {
|
|
@@ -420,7 +523,7 @@ async function relayOpenAIRequest(requestBody, config, res) {
|
|
|
420
523
|
})
|
|
421
524
|
|
|
422
525
|
const text = await upstream.text()
|
|
423
|
-
const parsed = parseOpenAIStreamText(text)
|
|
526
|
+
const parsed = parseOpenAIStreamText(text, requestBody.model)
|
|
424
527
|
if (upstream.ok && parsed) {
|
|
425
528
|
if (requestBody.stream) return sendOpenAIStream(res, parsed)
|
|
426
529
|
return sendJson(res, upstream.status, parsed)
|
package/src/utils/config.js
CHANGED
|
@@ -11,6 +11,7 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
|
11
11
|
|
|
12
12
|
const BASE_URL_ANTHROPIC = 'https://api.holysheep.ai' // 不带 /v1 (Anthropic SDK)
|
|
13
13
|
const BASE_URL_OPENAI = 'https://api.holysheep.ai/v1' // 带 /v1 (OpenAI 兼容)
|
|
14
|
+
const BASE_URL_CLAUDE_RELAY = process.env.HOLYSHEEP_CLAUDE_RELAY_URL || 'https://api.holysheep.ai/claude-relay'
|
|
14
15
|
const SHOP_URL = 'https://holysheep.ai'
|
|
15
16
|
|
|
16
17
|
function ensureDir() {
|
|
@@ -45,6 +46,7 @@ module.exports = {
|
|
|
45
46
|
CONFIG_FILE,
|
|
46
47
|
BASE_URL_ANTHROPIC,
|
|
47
48
|
BASE_URL_OPENAI,
|
|
49
|
+
BASE_URL_CLAUDE_RELAY,
|
|
48
50
|
SHOP_URL,
|
|
49
51
|
loadConfig,
|
|
50
52
|
saveConfig,
|