Files
claude-relay-service/src/utils/anthropicResponseDump.js
52227 1b834ffcdb feat: 增强稳定性与Antigravity适配 (僵尸流看门狗/自动重试/签名缓存)
主要变更:
1. **僵尸流看门狗 (Zombie Stream Watchdog)**:
   - 新增 resetActivityTimeout 机制,45秒无数据强制断开连接,防止服务假死。

2. **智能重试机制**:
   - 针对 Antigravity 429 (Resource Exhausted) 错误,自动清理会话并切换账号重试。
   - 涵盖流式 (Stream) 和非流式 (Non-stream) 请求。

3. **Thought Signature 增强**:
   - 新增签名缓存与恢复机制 (signatureCache)。
   - 增加 skip_thought_signature_validator 兜底签名策略。
   - 强制补充 thought: true 标记以满足上游校验。

4. **系统稳定性与调试**:
   - 使用 util.inspect 替代 JSON.stringify 打印错误日志,彻底修复循环引用导致的服务崩溃。
   - 新增针对 Antigravity 参数错误 (400) 的详细请求结构分析日志。
   - 优化日志写入为轮转模式 (safeRotatingAppend)。

5. **其他优化**:
   - antigravityClient 数据处理安全增强 (safeDataToString)。
2026-01-05 09:37:39 +08:00

126 lines
3.4 KiB
JavaScript

const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
const RESPONSE_DUMP_FILENAME = 'anthropic-responses-dump.jsonl'
function isEnabled() {
const raw = process.env[RESPONSE_DUMP_ENV]
if (!raw) {
return false
}
return raw === '1' || raw.toLowerCase() === 'true'
}
function getMaxBytes() {
const raw = process.env[RESPONSE_DUMP_MAX_BYTES_ENV]
if (!raw) {
return 2 * 1024 * 1024
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2 * 1024 * 1024
}
return parsed
}
function safeJsonStringify(payload, maxBytes) {
let json = ''
try {
json = JSON.stringify(payload)
} catch (e) {
return JSON.stringify({
type: 'anthropic_response_dump_error',
error: 'JSON.stringify_failed',
message: e?.message || String(e)
})
}
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
return json
}
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
return JSON.stringify({
type: 'anthropic_response_dump_truncated',
maxBytes,
originalBytes: Buffer.byteLength(json, 'utf8'),
partialJson: truncated
})
}
function summarizeAnthropicResponseBody(body) {
const content = Array.isArray(body?.content) ? body.content : []
const toolUses = content.filter((b) => b && b.type === 'tool_use')
const texts = content
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('')
return {
id: body?.id || null,
model: body?.model || null,
stop_reason: body?.stop_reason || null,
usage: body?.usage || null,
content_blocks: content.map((b) => (b ? b.type : null)).filter(Boolean),
tool_use_names: toolUses.map((b) => b.name).filter(Boolean),
text_preview: texts ? texts.slice(0, 800) : ''
}
}
async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
requestId: req?.requestId || null,
url: req?.originalUrl || req?.url || null,
meta,
response: responseInfo
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await safeRotatingAppend(filename, line)
} catch (e) {
logger.warn('Failed to dump Anthropic response', {
filename,
requestId: req?.requestId || null,
error: e?.message || String(e)
})
}
}
async function dumpAnthropicNonStreamResponse(req, statusCode, body, meta = {}) {
return dumpAnthropicResponse(
req,
{ kind: 'non-stream', statusCode, summary: summarizeAnthropicResponseBody(body), body },
meta
)
}
async function dumpAnthropicStreamSummary(req, summary, meta = {}) {
return dumpAnthropicResponse(req, { kind: 'stream', summary }, meta)
}
async function dumpAnthropicStreamError(req, error, meta = {}) {
return dumpAnthropicResponse(req, { kind: 'stream-error', error }, meta)
}
module.exports = {
dumpAnthropicNonStreamResponse,
dumpAnthropicStreamSummary,
dumpAnthropicStreamError,
RESPONSE_DUMP_ENV,
RESPONSE_DUMP_MAX_BYTES_ENV,
RESPONSE_DUMP_FILENAME
}