@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "1.6.14",
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://github.com/holysheep123/holysheep-cli.git"
45
+ "url": "git+https://gitee.com/holysheep123/holysheep-cli.git"
46
46
  },
47
47
  "license": "MIT",
48
48
  "bin": {
@@ -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
+ }
@@ -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
- return !!(s.env?.ANTHROPIC_AUTH_TOKEN || s.env?.ANTHROPIC_API_KEY)
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 = baseUrl
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
- ...(message.content ? { content: message.content } : {}),
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 parseOpenAIStreamText(text) {
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') return parsed
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
- if (delta.content) content += delta.content
373
- else if (choice.message?.content) content += choice.message.content
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
- return {
378
- id: responseCompleted.id || `chatcmpl_${Date.now()}`,
379
- object: 'chat.completion',
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)
@@ -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,