@simonyea/holysheep-cli 1.7.126 → 1.7.128

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.7.126",
3
+ "version": "1.7.128",
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",
@@ -46,6 +46,26 @@ function getControlPlaneUrl(config) {
46
46
  }
47
47
 
48
48
  const leaseCache = new Map()
49
+ const MAX_PROXY_RETRIES = 3
50
+
51
+ function createForwardError(statusCode, body) {
52
+ const error = new Error(`HTTP ${statusCode}: ${String(body || '').slice(0, 200)}`)
53
+ error.statusCode = Number(statusCode) || 502
54
+ error.body = String(body || '')
55
+ return error
56
+ }
57
+
58
+ function shouldRefreshLeaseAfterError(err) {
59
+ const statusCode = Number(err?.statusCode || 0)
60
+ const body = String(err?.body || err?.message || '')
61
+ if (statusCode === 403 || statusCode === 503) return true
62
+ return (
63
+ body.includes('client_validation_error') ||
64
+ body.includes('Claude Code 必须使用 hs claude 指令启动') ||
65
+ body.includes('当前代理节点') ||
66
+ body.includes('No active Claude relay nodes are available')
67
+ )
68
+ }
49
69
 
50
70
  function isRetryableNodeLeaseError(err) {
51
71
  const message = String(err?.message || '')
@@ -159,7 +179,7 @@ function forwardViaNodeProxy({ nodeProxyUrl, targetUrl, clientReq, clientRes, ex
159
179
  forwardRes.on('data', (c) => chunks.push(c))
160
180
  forwardRes.on('end', () => {
161
181
  const body = Buffer.concat(chunks).toString('utf8')
162
- reject(new Error(`HTTP ${status}: ${body.slice(0, 200)}`))
182
+ reject(createForwardError(status, body))
163
183
  })
164
184
  return
165
185
  }
@@ -200,15 +220,21 @@ function createConnectTunnel(proxyUrl, target, headers) {
200
220
  })
201
221
  }
202
222
 
223
+ // 等上游返回 first byte 的最大时间。Claude 长上下文(>200K tokens)的 prefill 时间
224
+ // 可以达到 30-90 秒,30 秒太严会误杀正常长 prompt。默认 300 秒,可通过环境变量
225
+ // HS_CLAUDE_RESPONSE_TIMEOUT_MS 覆盖。
226
+ const RESPONSE_TIMEOUT_MS = Number(process.env.HS_CLAUDE_RESPONSE_TIMEOUT_MS) || 300000
227
+ // 上游 stream 已开始后,相邻两个 chunk 之间的最大间隔(覆盖 extended thinking 间歇)。
228
+ const STALL_TIMEOUT_MS = Number(process.env.HS_CLAUDE_STALL_TIMEOUT_MS) || 120000
229
+
203
230
  function pipeWithCleanup(a, b) {
204
231
  for (const sock of [a, b]) {
205
232
  if (typeof sock.setKeepAlive === 'function') sock.setKeepAlive(true, 10000)
206
233
  }
207
234
 
208
235
  // 双阶段超时:
209
- // 1. 客户端发了数据,上游 30 秒没回第一个字节 → 断开(node proxy 挂了)
210
- // 2. 上游在流数据突然停了 120 → 断开(stream 中断)
211
- // 120 秒足够覆盖 extended thinking 的间歇(thinking 期间服务端仍会发心跳)
236
+ // 1. 客户端发了数据,上游 RESPONSE_TIMEOUT_MS 没回第一个字节 → 断开(node proxy 挂了)
237
+ // 2. 上游在流数据突然停了 STALL_TIMEOUT_MS → 断开(stream 中断)
212
238
  let timer = null
213
239
  let streaming = false
214
240
  const kill = (reason) => {
@@ -220,15 +246,15 @@ function pipeWithCleanup(a, b) {
220
246
  // 客户端发了数据(请求),等上游响应
221
247
  if (!streaming) {
222
248
  if (timer) clearTimeout(timer)
223
- timer = setTimeout(() => kill('upstream response timeout'), 30000)
249
+ timer = setTimeout(() => kill('upstream response timeout'), RESPONSE_TIMEOUT_MS)
224
250
  }
225
251
  })
226
252
  b.on('data', () => {
227
253
  // 上游回数据了,切换到 stream 模式
228
254
  streaming = true
229
- // 每次收到数据重置 120 秒超时
255
+ // 每次收到数据重置 stall 超时
230
256
  if (timer) clearTimeout(timer)
231
- timer = setTimeout(() => kill('upstream stream stalled'), 120000)
257
+ timer = setTimeout(() => kill('upstream stream stalled'), STALL_TIMEOUT_MS)
232
258
  })
233
259
 
234
260
  a.pipe(b)
@@ -277,29 +303,37 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH }) {
277
303
  })
278
304
  }
279
305
 
280
- try {
281
- await doForward(getCachedLease(sessionId))
282
- } catch (err) {
283
- if (clientRes.headersSent) return
284
- const shouldRefreshLease =
285
- !leaseCache.has(sessionId) || (err && isRetryableNodeLeaseError(err))
286
- if (shouldRefreshLease) {
287
- try {
306
+ let lastError = null
307
+ for (let attempt = 0; attempt <= MAX_PROXY_RETRIES; attempt++) {
308
+ try {
309
+ if (attempt === 0) {
310
+ await doForward(getCachedLease(sessionId))
311
+ } else {
288
312
  const config = readConfig(configPath)
289
313
  leaseCache.delete(sessionId)
314
+ if (shouldRefreshLeaseAfterError(lastError)) {
315
+ await closeSession(configPath, sessionId)
316
+ }
290
317
  const freshLease = await fetchFreshLease(config, sessionId)
291
318
  await doForward(freshLease)
292
- } catch (retryError) {
293
- if (!clientRes.headersSent) {
294
- clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
295
- clientRes.end(retryError.message || 'Proxy error')
296
- }
297
319
  }
298
- } else {
299
- clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
300
- clientRes.end(err?.message || 'Proxy error')
320
+ lastError = null
321
+ break
322
+ } catch (err) {
323
+ lastError = err
324
+ if (clientRes.headersSent) return
325
+ if (!isRetryableNodeLeaseError(err) && attempt > 0) break
301
326
  }
302
327
  }
328
+ if (lastError && !clientRes.headersSent) {
329
+ const status = Number(lastError.statusCode || 502)
330
+ const body = String(lastError.body || lastError.message || 'Proxy error')
331
+ const isJson = body.trim().startsWith('{')
332
+ clientRes.writeHead(status, {
333
+ 'content-type': isJson ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8'
334
+ })
335
+ clientRes.end(body)
336
+ }
303
337
  })
304
338
 
305
339
  // Hosts that must NOT be tunneled directly — they must go through CRS via
@@ -333,26 +367,37 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH }) {
333
367
  pipeWithCleanup(clientSocket, upstreamSocket)
334
368
  }
335
369
 
336
- try {
337
- await doConnect(getCachedLease(sessionId))
338
- } catch (err) {
339
- const shouldRefreshLease =
340
- !leaseCache.has(sessionId) || (err && isRetryableNodeLeaseError(err))
341
- if (shouldRefreshLease) {
342
- try {
370
+ let lastError = null
371
+ for (let attempt = 0; attempt <= MAX_PROXY_RETRIES; attempt++) {
372
+ try {
373
+ if (attempt === 0) {
374
+ await doConnect(getCachedLease(sessionId))
375
+ } else {
343
376
  const config = readConfig(configPath)
344
377
  leaseCache.delete(sessionId)
378
+ if (shouldRefreshLeaseAfterError(lastError)) {
379
+ await closeSession(configPath, sessionId)
380
+ }
345
381
  const freshLease = await fetchFreshLease(config, sessionId)
346
382
  await doConnect(freshLease)
347
- } catch (retryError) {
348
- clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain; charset=utf-8\r\n\r\n${retryError.message}`)
349
- clientSocket.destroy()
350
383
  }
351
- } else {
352
- clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain; charset=utf-8\r\n\r\n${err?.message || 'Proxy error'}`)
353
- clientSocket.destroy()
384
+ lastError = null
385
+ break
386
+ } catch (err) {
387
+ lastError = err
388
+ if (!isRetryableNodeLeaseError(err) && attempt > 0) break
354
389
  }
355
390
  }
391
+ if (lastError) {
392
+ const status = Number(lastError.statusCode || 502)
393
+ const body = String(lastError.body || lastError.message || 'Proxy error')
394
+ const statusText = status === 403 ? 'Forbidden' : status === 503 ? 'Service Unavailable' : 'Bad Gateway'
395
+ const contentType = body.trim().startsWith('{')
396
+ ? 'application/json; charset=utf-8'
397
+ : 'text/plain; charset=utf-8'
398
+ clientSocket.write(`HTTP/1.1 ${status} ${statusText}\r\ncontent-type: ${contentType}\r\n\r\n${body}`)
399
+ clientSocket.destroy()
400
+ }
356
401
  })
357
402
 
358
403
  return server