@simonyea/holysheep-cli 1.7.130 → 1.7.131

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.130",
3
+ "version": "1.7.131",
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",
@@ -68,22 +68,19 @@ async function runClaude(args = []) {
68
68
  claudeCodeTool.writeSettings(settings)
69
69
  }
70
70
 
71
- const { server, port, sessionId } = await startProcessProxy({})
72
- const proxyUrl = getLocalProxyUrl(port)
73
71
  const runtime = typeof claudeCodeTool.detectClaudeRuntime === 'function'
74
72
  ? claudeCodeTool.detectClaudeRuntime()
75
73
  : { kind: 'unknown', launchMode: 'env-proxy' }
74
+ const { server, port, sessionId } = await startProcessProxy({})
75
+ const proxyUrl = getLocalProxyUrl(port)
76
76
  const launchMode = runtime.launchMode === 'node-inject'
77
77
  ? 'local-api + connect-fallback + node-inject'
78
- : 'local-api + connect-fallback'
78
+ : 'whole-process-proxy + local-api'
79
79
 
80
80
  const env = {
81
81
  ...process.env,
82
82
  ANTHROPIC_API_KEY: undefined,
83
83
  ANTHROPIC_AUTH_TOKEN: apiKey,
84
- // Route ALL Anthropic API traffic exclusively through ANTHROPIC_BASE_URL.
85
- // HTTP(S)_PROXY must NOT be set: it causes Claude Code to open CONNECT
86
- // tunnels to api.anthropic.com, bypassing CRS multi-account scheduling.
87
84
  ANTHROPIC_BASE_URL: proxyUrl,
88
85
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
89
86
  HOLYSHEEP_CLAUDE_PROCESS_PROXY: '1',
@@ -91,10 +88,12 @@ async function runClaude(args = []) {
91
88
  HS_PROXY_URL: proxyUrl,
92
89
  HOLYSHEEP_CLAUDE_RUNTIME_KIND: runtime.kind || 'unknown',
93
90
  HOLYSHEEP_CLAUDE_LAUNCH_MODE: launchMode,
94
- HTTP_PROXY: undefined,
95
- HTTPS_PROXY: undefined,
96
- ALL_PROXY: undefined,
97
- NO_PROXY: undefined,
91
+ HTTP_PROXY: proxyUrl,
92
+ HTTPS_PROXY: proxyUrl,
93
+ ALL_PROXY: proxyUrl,
94
+ NO_PROXY: mergeNoProxy(process.env.NO_PROXY, ['127.0.0.1', 'localhost']),
95
+ ANTHROPIC_MAX_RETRIES: '0',
96
+ MAX_RETRIES: '0',
98
97
  }
99
98
 
100
99
  // 不再写 settings.json — 只用 env 变量,避免 Claude Code 从两个来源
@@ -161,11 +161,83 @@ function deriveNodeProxyUrl(lease) {
161
161
  function forwardViaNodeProxy({ nodeProxyUrl, targetUrl, clientReq, clientRes, extraHeaders = {} }) {
162
162
  const upstream = new URL(nodeProxyUrl)
163
163
  return new Promise((resolve, reject) => {
164
- const forwardReq = http.request({
164
+ let settled = false
165
+ let responseTimer = null
166
+ let stallTimer = null
167
+ let sawUpstreamResponse = false
168
+ let sawUpstreamData = false
169
+ let forwardReq = null
170
+
171
+ const clearResponseTimer = () => {
172
+ if (responseTimer) {
173
+ clearTimeout(responseTimer)
174
+ responseTimer = null
175
+ }
176
+ }
177
+
178
+ const clearStallTimer = () => {
179
+ if (stallTimer) {
180
+ clearTimeout(stallTimer)
181
+ stallTimer = null
182
+ }
183
+ }
184
+
185
+ const clearTimers = () => {
186
+ clearResponseTimer()
187
+ clearStallTimer()
188
+ }
189
+
190
+ const finish = () => {
191
+ if (settled) return
192
+ settled = true
193
+ clearTimers()
194
+ resolve()
195
+ }
196
+
197
+ const failWithAnthropicError = (message) => {
198
+ if (settled) return
199
+ settled = true
200
+ clearTimers()
201
+ if (forwardReq && !forwardReq.destroyed) {
202
+ forwardReq.destroy()
203
+ }
204
+ if (!clientRes.headersSent && !clientRes.destroyed) {
205
+ clientRes.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
206
+ clientRes.end(JSON.stringify({
207
+ type: 'error',
208
+ error: {
209
+ type: 'client_validation_error',
210
+ message,
211
+ },
212
+ }))
213
+ return resolve()
214
+ }
215
+ reject(new Error(message))
216
+ }
217
+
218
+ const fail = (error) => {
219
+ const err = error instanceof Error ? error : new Error(String(error || 'Proxy error'))
220
+ return failWithAnthropicError(err.message || 'Proxy error')
221
+ }
222
+
223
+ const armResponseTimer = () => {
224
+ if (settled || sawUpstreamResponse || responseTimer) return
225
+ responseTimer = setTimeout(() => failWithAnthropicError('upstream response timeout'), RESPONSE_TIMEOUT_MS)
226
+ }
227
+
228
+ const armStallTimer = () => {
229
+ if (settled) return
230
+ sawUpstreamData = true
231
+ clearStallTimer()
232
+ stallTimer = setTimeout(() => failWithAnthropicError('upstream stream stalled'), STALL_TIMEOUT_MS)
233
+ }
234
+
235
+ forwardReq = http.request({
165
236
  host: upstream.hostname,
166
237
  port: Number(upstream.port || 80),
167
238
  method: clientReq.method,
168
239
  path: targetUrl.toString(),
240
+ agent: false,
169
241
  headers: {
170
242
  ...clientReq.headers,
171
243
  ...extraHeaders,
@@ -173,22 +245,45 @@ function forwardViaNodeProxy({ nodeProxyUrl, targetUrl, clientReq, clientRes, ex
173
245
  connection: 'close',
174
246
  },
175
247
  }, (forwardRes) => {
248
+ sawUpstreamResponse = true
249
+ clearResponseTimer()
250
+
176
251
  const status = forwardRes.statusCode || 502
177
252
  if (status === 403 || status === 502 || status === 503) {
178
- // 收集响应体用于错误消息,不透传给客户端
179
253
  const chunks = []
180
254
  forwardRes.on('data', (c) => chunks.push(c))
181
255
  forwardRes.on('end', () => {
256
+ if (settled) return
257
+ settled = true
258
+ clearTimers()
182
259
  const body = Buffer.concat(chunks).toString('utf8')
183
260
  reject(createForwardError(status, body))
184
261
  })
262
+ forwardRes.on('error', fail)
185
263
  return
186
264
  }
265
+
187
266
  clientRes.writeHead(status, forwardRes.headers)
267
+ forwardRes.on('data', armStallTimer)
268
+ forwardRes.once('end', () => {
269
+ clearStallTimer()
270
+ finish()
271
+ })
272
+ forwardRes.once('close', () => {
273
+ clearStallTimer()
274
+ finish()
275
+ })
276
+ forwardRes.once('error', fail)
188
277
  forwardRes.pipe(clientRes)
189
- resolve()
190
278
  })
191
- forwardReq.once('error', reject)
279
+
280
+ forwardReq.setTimeout(RESPONSE_TIMEOUT_MS, () => {
281
+ if (!sawUpstreamResponse) failWithAnthropicError('upstream response timeout')
282
+ })
283
+ forwardReq.once('error', fail)
284
+ clientReq.once('aborted', () => finish())
285
+
286
+ armResponseTimer()
192
287
  clientReq.pipe(forwardReq)
193
288
  })
194
289
  }
@@ -202,6 +297,7 @@ function createConnectTunnel(proxyUrl, target, headers) {
202
297
  method: 'CONNECT',
203
298
  path: target,
204
299
  headers,
300
+ agent: false,
205
301
  timeout: 15000, // CONNECT 握手 15 秒超时
206
302
  })
207
303
 
@@ -221,11 +317,11 @@ function createConnectTunnel(proxyUrl, target, headers) {
221
317
  })
222
318
  }
223
319
 
224
- // 等上游返回 first byte 的最大时间。Claude 长上下文(>200K tokens)的 prefill 时间
225
- // 可以达到 30-90 秒,120 秒留了充足裕量。默认 120 秒,可通过环境变量
226
- // HS_CLAUDE_RESPONSE_TIMEOUT_MS 覆盖。旧值 300 秒导致异常时用户感知卡 5 分钟。
227
- const RESPONSE_TIMEOUT_MS = Number(process.env.HS_CLAUDE_RESPONSE_TIMEOUT_MS) || 120000
228
- // 上游 stream 已开始后,相邻两个 chunk 之间的最大间隔(覆盖 extended thinking 间歇)。
320
+ // 等上游返回 first byte 的最大时间。
321
+ // 这里不能默认等 5 分钟,否则上游/代理链路卡死时,用户会感觉每次下一条请求都被“挂住”。
322
+ // 默认快速失败为 15 秒,仍可通过环境变量覆盖。
323
+ const RESPONSE_TIMEOUT_MS = Number(process.env.HS_CLAUDE_RESPONSE_TIMEOUT_MS) || 15000
324
+ // 上游 stream 已开始后,相邻两个 chunk 之间的最大间隔。
229
325
  const STALL_TIMEOUT_MS = Number(process.env.HS_CLAUDE_STALL_TIMEOUT_MS) || 120000
230
326
 
231
327
  function pipeWithCleanup(a, b) {
@@ -233,45 +329,57 @@ function pipeWithCleanup(a, b) {
233
329
  if (typeof sock.setKeepAlive === 'function') sock.setKeepAlive(true, 10000)
234
330
  }
235
331
 
236
- // 双阶段超时:
237
- // 1. 客户端发了数据,上游 RESPONSE_TIMEOUT_MS 没回第一个字节 → 断开(node proxy 挂了)
238
- // 2. 上游在流数据突然停了 STALL_TIMEOUT_MS → 断开(stream 中断)
239
332
  let timer = null
333
+ let closed = false
240
334
  let streaming = false
241
- const kill = (reason) => {
242
- if (timer) clearTimeout(timer)
243
- a.destroy(new Error(reason))
244
- b.destroy(new Error(reason))
245
- }
246
- a.on('data', () => {
247
- // 客户端发了数据(请求),等上游响应
248
- if (!streaming) {
249
- if (timer) clearTimeout(timer)
250
- timer = setTimeout(() => kill('upstream response timeout'), RESPONSE_TIMEOUT_MS)
335
+
336
+ const clearTimer = () => {
337
+ if (timer) {
338
+ clearTimeout(timer)
339
+ timer = null
251
340
  }
252
- })
253
- b.on('data', () => {
254
- // 上游回数据了,切换到 stream 模式
341
+ }
342
+
343
+ const destroySocket = (sock, err) => {
344
+ if (!sock || sock.destroyed) return
345
+ if (err) sock.destroy(err)
346
+ else sock.destroy()
347
+ }
348
+
349
+ const close = (reason) => {
350
+ if (closed) return
351
+ closed = true
352
+ clearTimer()
353
+ const err = reason ? new Error(reason) : null
354
+ destroySocket(a, err)
355
+ destroySocket(b, err)
356
+ }
357
+
358
+ const armResponseTimer = () => {
359
+ if (streaming || closed || timer) return
360
+ timer = setTimeout(() => close('upstream response timeout'), RESPONSE_TIMEOUT_MS)
361
+ }
362
+
363
+ const armStallTimer = () => {
364
+ if (closed) return
255
365
  streaming = true
256
- // 每次收到数据重置 stall 超时
257
- if (timer) clearTimeout(timer)
258
- timer = setTimeout(() => kill('upstream stream stalled'), STALL_TIMEOUT_MS)
259
- })
366
+ clearTimer()
367
+ timer = setTimeout(() => close('upstream stream stalled'), STALL_TIMEOUT_MS)
368
+ }
369
+
370
+ b.on('data', armStallTimer)
260
371
 
372
+ armResponseTimer()
261
373
  a.pipe(b)
262
374
  b.pipe(a)
263
- const close = () => {
264
- if (timer) clearTimeout(timer)
265
- a.destroy()
266
- b.destroy()
267
- }
268
- a.once('error', close)
269
- b.once('error', close)
270
- a.once('close', close)
271
- b.once('close', close)
375
+
376
+ a.once('error', () => close())
377
+ b.once('error', () => close())
378
+ a.once('close', () => close())
379
+ b.once('close', () => close())
272
380
  }
273
381
 
274
- function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH }) {
382
+ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH, allowAnthropicConnect = false }) {
275
383
  const server = http.createServer(async (clientReq, clientRes) => {
276
384
  const isDirect = !clientReq.url.startsWith('http')
277
385
 
@@ -341,7 +449,7 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH }) {
341
449
 
342
450
  // Hosts that must NOT be tunneled directly — they must go through CRS via
343
451
  // the HTTP path (ANTHROPIC_BASE_URL), not a CONNECT tunnel that bypasses it.
344
- const BLOCKED_CONNECT = new Set(['api.anthropic.com'])
452
+ const BLOCKED_CONNECT = allowAnthropicConnect ? new Set() : new Set(['api.anthropic.com'])
345
453
 
346
454
  server.on('connect', async (req, clientSocket, head) => {
347
455
  const target = String(req.url || '').trim()
@@ -408,7 +516,7 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH }) {
408
516
  return server
409
517
  }
410
518
 
411
- async function startProcessProxy({ port = null, sessionId = null, configPath = CONFIG_PATH } = {}) {
519
+ async function startProcessProxy({ port = null, sessionId = null, configPath = CONFIG_PATH, allowAnthropicConnect = false } = {}) {
412
520
  const config = readConfig(configPath)
413
521
  const preferredPort = port || getProcessProxyPort(config)
414
522
  const effectiveSessionId = sessionId || crypto.randomUUID()
@@ -416,7 +524,7 @@ async function startProcessProxy({ port = null, sessionId = null, configPath = C
416
524
  // 启动时拿一次 lease,之后靠被动重试维持,不再主动续约
417
525
  await fetchFreshLease(config, effectiveSessionId)
418
526
 
419
- const server = createProcessProxyServer({ sessionId: effectiveSessionId, configPath })
527
+ const server = createProcessProxyServer({ sessionId: effectiveSessionId, configPath, allowAnthropicConnect })
420
528
 
421
529
  return new Promise((resolve, reject) => {
422
530
  const tryListen = (p) => {