diff --git a/src/cursor-client.ts b/src/cursor-client.ts index d58b05e..8a475b8 100644 --- a/src/cursor-client.ts +++ b/src/cursor-client.ts @@ -56,7 +56,7 @@ export async function sendCursorRequest( return; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error(`[Cursor] 请求失败 (${attempt}/${maxRetries}): ${msg}`); + console.error(`[Cursor] 请求失败 (${attempt}/${maxRetries}): ${msg.substring(0, 100)}`); if (attempt < maxRetries) { console.log(`[Cursor] 2s 后重试...`); await new Promise(r => setTimeout(r, 2000)); @@ -73,7 +73,7 @@ async function sendCursorRequestInner( ): Promise { const headers = getChromeHeaders(); - console.log(`[Cursor] 发送请求: model=${req.model}, messages=${req.messages.length}`); + // 详细日志记录在 handler 层 const config = getConfig(); const controller = new AbortController(); diff --git a/src/handler.ts b/src/handler.ts index be56065..4705e7c 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -18,6 +18,7 @@ import type { import { convertToCursorRequest, parseToolCalls, hasToolCalls } from './converter.js'; import { sendCursorRequest, sendCursorRequestFull } from './cursor-client.js'; import { getConfig } from './config.js'; +import { createRequestLogger, type RequestLogger } from './logger.js'; function msgId(): string { return 'msg_' + uuidv4().replace(/-/g, '').substring(0, 24); @@ -405,12 +406,34 @@ async function handleMockIdentityNonStream(res: Response, body: AnthropicRequest export async function handleMessages(req: Request, res: Response): Promise { const body = req.body as AnthropicRequest; - console.log(`[Handler] 收到请求: model=${body.model}, messages=${body.messages?.length}, stream=${body.stream}, tools=${body.tools?.length ?? 0}`); + const systemStr = typeof body.system === 'string' ? body.system : Array.isArray(body.system) ? body.system.map((b: any) => b.text || '').join('') : ''; + const log = createRequestLogger({ + method: req.method, + path: req.path, + model: body.model, + stream: !!body.stream, + hasTools: (body.tools?.length ?? 0) > 0, + toolCount: body.tools?.length ?? 0, + messageCount: body.messages?.length ?? 0, + apiFormat: 'anthropic', + systemPromptLength: systemStr.length, + }); + + log.startPhase('receive', '接收请求'); + log.recordOriginalRequest(body); + log.info('Handler', 'receive', `收到 Anthropic Messages 请求`, { + model: body.model, + messageCount: body.messages?.length, + stream: body.stream, + toolCount: body.tools?.length ?? 0, + maxTokens: body.max_tokens, + hasSystem: !!body.system, + thinking: body.thinking?.type, + }); try { - // 注意:图片预处理已移入 convertToCursorRequest → preprocessImages() 统一处理 if (isIdentityProbe(body)) { - console.log(`[Handler] 拦截到身份探针,返回模拟响应以规避风控`); + log.intercepted('身份探针拦截 → 返回模拟响应'); if (body.stream) { return await handleMockIdentityStream(res, body); } else { @@ -419,16 +442,21 @@ export async function handleMessages(req: Request, res: Response): Promise } // 转换为 Cursor 请求 + log.startPhase('convert', '格式转换'); + log.info('Handler', 'convert', '开始转换为 Cursor 请求格式'); const cursorReq = await convertToCursorRequest(body); + log.endPhase(); + log.recordCursorRequest(cursorReq); + log.debug('Handler', 'convert', `转换完成: ${cursorReq.messages.length} messages, model=${cursorReq.model}`); if (body.stream) { - await handleStream(res, cursorReq, body); + await handleStream(res, cursorReq, body, log); } else { - await handleNonStream(res, cursorReq, body); + await handleNonStream(res, cursorReq, body, log); } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - console.error(`[Handler] 请求处理失败:`, message); + log.fail(message); res.status(500).json({ type: 'error', error: { type: 'api_error', message }, @@ -533,7 +561,7 @@ function deduplicateContinuation(existing: string, continuation: string): string if (matchedLines >= 2) { // 移除续写中匹配的行 const deduped = continuationLines.slice(matchedLines).join('\n'); - console.log(`[Handler] 行级去重: 移除了续写前 ${matchedLines} 行的重复内容`); + // 行级去重记录到详细日志 return deduped; } break; @@ -588,7 +616,7 @@ export function buildRetryRequest(body: AnthropicRequest, attempt: number): Anth // ==================== 流式处理 ==================== -async function handleStream(res: Response, cursorReq: CursorChatRequest, body: AnthropicRequest): Promise { +async function handleStream(res: Response, cursorReq: CursorChatRequest, body: AnthropicRequest, log: RequestLogger): Promise { // 设置 SSE headers res.writeHead(200, { 'Content-Type': 'text/event-stream', @@ -631,19 +659,26 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A const executeStream = async () => { fullResponse = ''; + const apiStart = Date.now(); + let firstChunk = true; + log.startPhase('send', '发送到 Cursor'); await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => { if (event.type !== 'text-delta' || !event.delta) return; + if (firstChunk) { log.recordTTFT(); log.endPhase(); log.startPhase('response', '接收响应'); firstChunk = false; } fullResponse += event.delta; - - // 有工具时始终缓冲,无工具时也缓冲(用于拒绝检测) - // 不再直接流式发送,统一在流结束后处理 }); + log.endPhase(); + log.recordCursorApiTime(apiStart); }; try { await executeStream(); - console.log(`[Handler] 原始响应 (${fullResponse.length} chars, tools=${hasTools}): ${fullResponse.substring(0, 200)}${fullResponse.length > 200 ? '...' : ''}`); + log.recordRawResponse(fullResponse); + log.info('Handler', 'response', `原始响应: ${fullResponse.length} chars`, { + preview: fullResponse.substring(0, 300), + hasTools, + }); // ★ Thinking 提取(在拒绝检测之前,防止 thinking 内容触发 isRefusal 误判) const thinkingEnabled = body.thinking?.type === 'enabled'; @@ -653,7 +688,9 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A if (thinkingMatch) { thinkingContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); fullResponse = fullResponse.replace(/[\s\S]*?<\/thinking>\s*/g, '').trim(); - console.log(`[Handler] 剥离 thinking: ${thinkingContent.length} chars, 剩余 ${fullResponse.length} chars`); + log.info('Handler', 'thinking', `剥离 thinking: ${thinkingContent.length} chars, 剩余 ${fullResponse.length} chars`); + log.recordThinking(thinkingContent); + log.updateSummary({ thinkingChars: thinkingContent.length }); } } @@ -666,27 +703,28 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A while (shouldRetryRefusal() && retryCount < MAX_REFUSAL_RETRIES) { retryCount++; - console.log(`[Handler] 检测到拒绝(第${retryCount}次),自动重试...原始: ${fullResponse.substring(0, 100)}`); + log.warn('Handler', 'retry', `检测到拒绝(第${retryCount}次),自动重试`, { preview: fullResponse.substring(0, 200) }); + log.updateSummary({ retryCount }); const retryBody = buildRetryRequest(body, retryCount - 1); activeCursorReq = await convertToCursorRequest(retryBody); await executeStream(); - console.log(`[Handler] 重试响应 (${fullResponse.length} chars): ${fullResponse.substring(0, 200)}${fullResponse.length > 200 ? '...' : ''}`); + log.info('Handler', 'retry', `重试响应: ${fullResponse.length} chars`, { preview: fullResponse.substring(0, 200) }); } if (shouldRetryRefusal()) { if (!hasTools) { // 工具能力询问 → 返回详细能力描述;其他 → 返回身份回复 if (isToolCapabilityQuestion(body)) { - console.log(`[Handler] 工具能力询问被拒绝,返回 Claude 能力描述`); + log.info('Handler', 'refusal', '工具能力询问被拒绝 → 返回 Claude 能力描述'); fullResponse = CLAUDE_TOOLS_RESPONSE; } else { - console.log(`[Handler] 重试${MAX_REFUSAL_RETRIES}次后仍被拒绝,返回 Claude 身份回复`); + log.warn('Handler', 'refusal', `重试${MAX_REFUSAL_RETRIES}次后仍被拒绝 → 降级为 Claude 身份回复`); fullResponse = CLAUDE_IDENTITY_RESPONSE; } } else { // 工具模式拒绝:不返回纯文本(会让 Claude Code 误认为任务完成) // 返回一个合理的纯文本,让它以 end_turn 结束,Claude Code 会根据上下文继续 - console.log(`[Handler] 工具模式下拒绝且无工具调用,返回简短引导文本`); + log.warn('Handler', 'refusal', '工具模式下拒绝且无工具调用 → 返回简短引导文本'); fullResponse = 'Let me proceed with the task.'; } } @@ -694,10 +732,10 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A // 极短响应重试(可能是连接中断) if (hasTools && fullResponse.trim().length < 10 && retryCount < MAX_REFUSAL_RETRIES) { retryCount++; - console.log(`[Handler] 响应过短 (${fullResponse.length} chars),重试第${retryCount}次`); + log.warn('Handler', 'retry', `响应过短 (${fullResponse.length} chars),重试第${retryCount}次`); activeCursorReq = await convertToCursorRequest(body); await executeStream(); - console.log(`[Handler] 重试响应 (${fullResponse.length} chars): ${fullResponse.substring(0, 200)}${fullResponse.length > 200 ? '...' : ''}`); + log.info('Handler', 'retry', `重试响应: ${fullResponse.length} chars`, { preview: fullResponse.substring(0, 200) }); } // 流完成后,处理完整响应 @@ -713,7 +751,8 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A while (hasTools && isTruncated(fullResponse) && continueCount < MAX_AUTO_CONTINUE) { continueCount++; const prevLength = fullResponse.length; - console.log(`[Handler] ⚠️ 内部检测到截断 (${fullResponse.length} chars),Proxy 将隐式请求无缝续写 (第${continueCount}次)...`); + log.warn('Handler', 'continuation', `内部检测到截断 (${fullResponse.length} chars),隐式续写 (第${continueCount}次)`); + log.updateSummary({ continuationCount: continueCount }); // 提取截断点的最后一段文本作为上下文锚点 const anchorLength = Math.min(300, fullResponse.length); @@ -754,7 +793,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener }); if (continuationResponse.trim().length === 0) { - console.log(`[Handler] ⚠️ 续写返回空响应,停止续写`); + log.warn('Handler', 'continuation', '续写返回空响应,停止续写'); break; } @@ -763,19 +802,19 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener const deduped = deduplicateContinuation(fullResponse, continuationResponse); fullResponse += deduped; if (deduped.length !== continuationResponse.length) { - console.log(`[Handler] 续写去重: 移除了 ${continuationResponse.length - deduped.length} chars 的重复内容`); + log.debug('Handler', 'continuation', `续写去重: 移除了 ${continuationResponse.length - deduped.length} chars 的重复内容`); } - console.log(`[Handler] 续写拼接完成: ${prevLength} → ${fullResponse.length} chars (+${deduped.length})`); + log.info('Handler', 'continuation', `续写拼接完成: ${prevLength} → ${fullResponse.length} chars (+${deduped.length})`); // ★ 无进展检测:去重后没有新内容,说明模型在重复自己,继续续写无意义 if (deduped.trim().length === 0) { - console.log(`[Handler] ⚠️ 续写内容全部为重复,停止续写`); + log.warn('Handler', 'continuation', '续写内容全部为重复,停止续写'); break; } // ★ 最小进展检测:去重后新增内容过少(<100 chars),模型几乎已完成 if (deduped.trim().length < 100) { - console.log(`[Handler] ⚠️ 续写新增内容过少 (${deduped.trim().length} chars < 100),停止续写`); + log.info('Handler', 'continuation', `续写新增内容过少 (${deduped.trim().length} chars < 100),停止续写`); break; } @@ -783,7 +822,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener if (deduped.trim().length < 500) { consecutiveSmallAdds++; if (consecutiveSmallAdds >= 2) { - console.log(`[Handler] ⚠️ 连续 ${consecutiveSmallAdds} 次小增量续写,停止续写`); + log.info('Handler', 'continuation', `连续 ${consecutiveSmallAdds} 次小增量续写,停止续写`); break; } } else { @@ -793,10 +832,11 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener let stopReason = (hasTools && isTruncated(fullResponse)) ? 'max_tokens' : 'end_turn'; if (stopReason === 'max_tokens') { - console.log(`[Handler] ⚠️ ${MAX_AUTO_CONTINUE}次隐式续写后仍受限于截断 (${fullResponse.length} chars),设置 stop_reason=max_tokens`); + log.warn('Handler', 'truncation', `${MAX_AUTO_CONTINUE}次续写后仍截断 (${fullResponse.length} chars) → stop_reason=max_tokens`); } // ★ Thinking 块发送:在实际内容之前发送 thinking content block + log.startPhase('stream', 'SSE 输出'); if (thinkingEnabled && thinkingContent) { writeSSE(res, 'content_block_start', { type: 'content_block_start', index: blockIndex, @@ -825,7 +865,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener toolChoiceRetry < TOOL_CHOICE_MAX_RETRIES ) { toolChoiceRetry++; - console.log(`[Handler] tool_choice=any 但模型未调用工具(第${toolChoiceRetry}次),强制重试...`); + log.warn('Handler', 'retry', `tool_choice=any 但模型未调用工具(第${toolChoiceRetry}次),强制重试`); // 在现有 Cursor 请求中追加强制 user 消息(不重新转换整个请求,代价最小) const forceMsg: CursorMessage = { @@ -848,7 +888,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener ({ toolCalls, cleanText } = parseToolCalls(fullResponse)); } if (toolChoice?.type === 'any' && toolCalls.length === 0) { - console.log(`[Handler] tool_choice=any 重试${TOOL_CHOICE_MAX_RETRIES}次后仍无工具调用`); + log.warn('Handler', 'toolparse', `tool_choice=any 重试${TOOL_CHOICE_MAX_RETRIES}次后仍无工具调用`); } @@ -857,7 +897,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener // Check if the residual text is a known refusal, if so, drop it completely! if (REFUSAL_PATTERNS.some(p => p.test(cleanText))) { - console.log(`[Handler] Supressed refusal text generated during tool usage: ${cleanText.substring(0, 100)}...`); + log.info('Handler', 'sanitize', `抑制工具调用中的拒绝文本`, { preview: cleanText.substring(0, 200) }); cleanText = ''; } @@ -923,7 +963,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener const isActualRefusal = stopReason !== 'max_tokens' && (isShortResponse ? isRefusal(fullResponse) : startsWithRefusal); if (isActualRefusal) { - console.log(`[Handler] Supressed complete refusal without tools: ${fullResponse.substring(0, 100)}...`); + log.info('Handler', 'sanitize', `抑制无工具的完整拒绝响应`, { preview: fullResponse.substring(0, 200) }); textToSend = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?'; } @@ -978,8 +1018,13 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener writeSSE(res, 'message_stop', { type: 'message_stop' }); + // ★ 记录完成 + log.recordFinalResponse(fullResponse); + log.complete(fullResponse.length, stopReason); + } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); + log.fail(message); writeSSE(res, 'error', { type: 'error', error: { type: 'api_error', message }, }); @@ -993,7 +1038,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener // ==================== 非流式处理 ==================== -async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body: AnthropicRequest): Promise { +async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body: AnthropicRequest, log: RequestLogger): Promise { // ★ 非流式保活:手动设置 chunked 响应,在缓冲期间每 15s 发送空白字符保活 // JSON.parse 会忽略前导空白,所以客户端解析不受影响 res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -1006,12 +1051,21 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body }, 15000); try { + log.startPhase('send', '发送到 Cursor (非流式)'); + const apiStart = Date.now(); let fullText = await sendCursorRequestFull(cursorReq); + log.recordTTFT(); + log.recordCursorApiTime(apiStart); + log.recordRawResponse(fullText); + log.startPhase('response', '处理响应'); const hasTools = (body.tools?.length ?? 0) > 0; let activeCursorReq = cursorReq; let retryCount = 0; - console.log(`[Handler] 非流式原始响应 (${fullText.length} chars, tools=${hasTools}): ${fullText.substring(0, 300)}${fullText.length > 300 ? '...' : ''}`); + log.info('Handler', 'response', `非流式原始响应: ${fullText.length} chars`, { + preview: fullText.substring(0, 300), + hasTools, + }); // ★ Thinking 提取(在拒绝检测之前) const thinkingEnabled = body.thinking?.type === 'enabled'; @@ -1021,7 +1075,7 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body if (thinkingMatch) { thinkingContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); fullText = fullText.replace(/[\s\S]*?<\/thinking>\s*/g, '').trim(); - console.log(`[Handler] 非流式:剥离 thinking: ${thinkingContent.length} chars, 剩余 ${fullText.length} chars`); + log.info('Handler', 'thinking', `非流式剥离 thinking: ${thinkingContent.length} chars, 剩余 ${fullText.length} chars`); } } @@ -1031,7 +1085,8 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body if (shouldRetry()) { for (let attempt = 0; attempt < MAX_REFUSAL_RETRIES; attempt++) { retryCount++; - console.log(`[Handler] 非流式:检测到拒绝(第${retryCount}次重试)...原始: ${fullText.substring(0, 100)}`); + log.warn('Handler', 'retry', `非流式检测到拒绝(第${retryCount}次重试)`, { preview: fullText.substring(0, 200) }); + log.updateSummary({ retryCount }); const retryBody = buildRetryRequest(body, attempt); activeCursorReq = await convertToCursorRequest(retryBody); fullText = await sendCursorRequestFull(activeCursorReq); @@ -1039,13 +1094,13 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body } if (shouldRetry()) { if (hasTools) { - console.log(`[Handler] 非流式:工具模式下拒绝,引导模型输出`); + log.warn('Handler', 'refusal', '非流式工具模式下拒绝 → 引导模型输出'); fullText = 'I understand the request. Let me analyze the information and proceed with the appropriate action.'; } else if (isToolCapabilityQuestion(body)) { - console.log(`[Handler] 非流式:工具能力询问被拒绝,返回 Claude 能力描述`); + log.info('Handler', 'refusal', '非流式工具能力询问被拒绝 → 返回 Claude 能力描述'); fullText = CLAUDE_TOOLS_RESPONSE; } else { - console.log(`[Handler] 非流式:重试${MAX_REFUSAL_RETRIES}次后仍被拒绝,返回 Claude 身份回复`); + log.warn('Handler', 'refusal', `非流式重试${MAX_REFUSAL_RETRIES}次后仍被拒绝 → 降级为 Claude 身份回复`); fullText = CLAUDE_IDENTITY_RESPONSE; } } @@ -1054,10 +1109,10 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body // ★ 极短响应重试(可能是连接中断) if (hasTools && fullText.trim().length < 10 && retryCount < MAX_REFUSAL_RETRIES) { retryCount++; - console.log(`[Handler] 非流式:响应过短 (${fullText.length} chars),重试第${retryCount}次`); + log.warn('Handler', 'retry', `非流式响应过短 (${fullText.length} chars),重试第${retryCount}次`); activeCursorReq = await convertToCursorRequest(body); fullText = await sendCursorRequestFull(activeCursorReq); - console.log(`[Handler] 非流式:重试响应 (${fullText.length} chars): ${fullText.substring(0, 200)}${fullText.length > 200 ? '...' : ''}`); + log.info('Handler', 'retry', `非流式重试响应: ${fullText.length} chars`, { preview: fullText.substring(0, 200) }); } // ★ 内部截断续写(与流式路径对齐) @@ -1071,7 +1126,8 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body while (hasTools && isTruncated(fullText) && continueCount < MAX_AUTO_CONTINUE) { continueCount++; const prevLength = fullText.length; - console.log(`[Handler] ⚠️ 非流式:内部检测到截断 (${fullText.length} chars),Proxy 将隐式请求无缝续写 (第${continueCount}次)...`); + log.warn('Handler', 'continuation', `非流式检测到截断 (${fullText.length} chars),隐式续写 (第${continueCount}次)`); + log.updateSummary({ continuationCount: continueCount }); const anchorLength = Math.min(300, fullText.length); const anchorText = fullText.slice(-anchorLength); @@ -1104,7 +1160,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener const continuationResponse = await sendCursorRequestFull(continuationReq); if (continuationResponse.trim().length === 0) { - console.log(`[Handler] ⚠️ 非流式续写返回空响应,停止续写`); + log.warn('Handler', 'continuation', '非流式续写返回空响应,停止续写'); break; } @@ -1112,19 +1168,19 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener const deduped = deduplicateContinuation(fullText, continuationResponse); fullText += deduped; if (deduped.length !== continuationResponse.length) { - console.log(`[Handler] 非流式续写去重: 移除了 ${continuationResponse.length - deduped.length} chars 的重复内容`); + log.debug('Handler', 'continuation', `非流式续写去重: 移除了 ${continuationResponse.length - deduped.length} chars 的重复内容`); } - console.log(`[Handler] 非流式续写拼接完成: ${prevLength} → ${fullText.length} chars (+${deduped.length})`); + log.info('Handler', 'continuation', `非流式续写拼接完成: ${prevLength} → ${fullText.length} chars (+${deduped.length})`); // ★ 无进展检测:去重后没有新内容,停止续写 if (deduped.trim().length === 0) { - console.log(`[Handler] ⚠️ 非流式续写内容全部为重复,停止续写`); + log.warn('Handler', 'continuation', '非流式续写内容全部为重复,停止续写'); break; } // ★ 最小进展检测:去重后新增内容过少(<100 chars),模型几乎已完成 if (deduped.trim().length < 100) { - console.log(`[Handler] ⚠️ 非流式续写新增内容过少 (${deduped.trim().length} chars < 100),停止续写`); + log.info('Handler', 'continuation', `非流式续写新增内容过少 (${deduped.trim().length} chars < 100),停止续写`); break; } @@ -1132,7 +1188,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener if (deduped.trim().length < 500) { consecutiveSmallAdds++; if (consecutiveSmallAdds >= 2) { - console.log(`[Handler] ⚠️ 非流式连续 ${consecutiveSmallAdds} 次小增量续写,停止续写`); + log.info('Handler', 'continuation', `非流式连续 ${consecutiveSmallAdds} 次小增量续写,停止续写`); break; } } else { @@ -1150,7 +1206,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener // ★ 截断检测:代码块/XML 未闭合时,返回 max_tokens 让 Claude Code 自动继续 let stopReason = (hasTools && isTruncated(fullText)) ? 'max_tokens' : 'end_turn'; if (stopReason === 'max_tokens') { - console.log(`[Handler] ⚠️ 非流式检测到截断响应 (${fullText.length} chars),设置 stop_reason=max_tokens`); + log.warn('Handler', 'truncation', `非流式检测到截断响应 (${fullText.length} chars) → stop_reason=max_tokens`); } if (hasTools) { @@ -1166,7 +1222,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener toolChoiceRetry < TOOL_CHOICE_MAX_RETRIES ) { toolChoiceRetry++; - console.log(`[Handler] 非流式:tool_choice=any 但模型未调用工具(第${toolChoiceRetry}次),强制重试...`); + log.warn('Handler', 'retry', `非流式 tool_choice=any 但模型未调用工具(第${toolChoiceRetry}次),强制重试`); const forceMessages = [ ...activeCursorReq.messages, @@ -1189,14 +1245,14 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener ({ toolCalls, cleanText } = parseToolCalls(fullText)); } if (toolChoice?.type === 'any' && toolCalls.length === 0) { - console.log(`[Handler] 非流式:tool_choice=any 重试${TOOL_CHOICE_MAX_RETRIES}次后仍无工具调用`); + log.warn('Handler', 'toolparse', `非流式 tool_choice=any 重试${TOOL_CHOICE_MAX_RETRIES}次后仍无工具调用`); } if (toolCalls.length > 0) { stopReason = 'tool_use'; if (isRefusal(cleanText)) { - console.log(`[Handler] Supressed refusal text generated during non-stream tool usage: ${cleanText.substring(0, 100)}...`); + log.info('Handler', 'sanitize', `非流式抑制工具调用中的拒绝文本`, { preview: cleanText.substring(0, 200) }); cleanText = ''; } @@ -1219,7 +1275,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener const startsRefusal = isRefusal(fullText.substring(0, 300)); const isRealRefusal = stopReason !== 'max_tokens' && (isShort ? isRefusal(fullText) : startsRefusal); if (isRealRefusal) { - console.log(`[Handler] Supressed pure text refusal (non-stream): ${fullText.substring(0, 100)}...`); + log.info('Handler', 'sanitize', `非流式抑制纯文本拒绝响应`, { preview: fullText.substring(0, 200) }); textToSend = 'Let me proceed with the task.'; } contentBlocks.push({ type: 'text', text: textToSend }); @@ -1246,10 +1302,14 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener clearInterval(keepaliveInterval); res.end(JSON.stringify(response)); + // ★ 记录完成 + log.recordFinalResponse(fullText); + log.complete(fullText.length, stopReason); + } catch (err: unknown) { clearInterval(keepaliveInterval); const message = err instanceof Error ? err.message : String(err); - console.error(`[Handler] 非流式请求处理失败:`, message); + log.fail(message); try { res.end(JSON.stringify({ type: 'error', diff --git a/src/index.ts b/src/index.ts index 9330b8f..1cbd7f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import express from 'express'; import { getConfig } from './config.js'; import { handleMessages, listModels, countTokens } from './handler.js'; import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js'; +import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream } from './log-viewer.js'; // 从 package.json 读取版本号,统一来源,避免多处硬编码 const require = createRequire(import.meta.url); @@ -35,6 +36,14 @@ app.use((_req, res, next) => { next(); }); +// ★ 日志查看器(在鉴权之前,始终可访问) +app.get('/logs', serveLogViewer); +app.get('/api/logs', apiGetLogs); +app.get('/api/requests', apiGetRequests); +app.get('/api/stats', apiGetStats); +app.get('/api/payload/:requestId', apiGetPayload); +app.get('/api/logs/stream', apiLogsStream); + // ★ API 鉴权中间件:配置了 authTokens 则需要 Bearer token app.use((req, res, next) => { // 跳过无需鉴权的路径 @@ -97,6 +106,7 @@ app.get('/', (_req, res) => { openai_responses: 'POST /v1/responses', models: 'GET /v1/models', health: 'GET /health', + log_viewer: 'GET /logs', }, usage: { claude_code: 'export ANTHROPIC_BASE_URL=http://localhost:' + config.port, @@ -128,6 +138,9 @@ app.listen(config.port, () => { console.log(' ║ OpenAI / Cursor IDE: ║'); console.log(` ║ OPENAI_BASE_URL= ║`); console.log(` ║ http://localhost:${config.port}/v1 ║`); + console.log(' ╠══════════════════════════════════════╣'); + console.log(' ║ 📊 Log Viewer: ║'); + console.log(` ║ http://localhost:${config.port}/logs ║`); console.log(' ╚══════════════════════════════════════╝'); console.log(''); }); diff --git a/src/log-viewer.ts b/src/log-viewer.ts new file mode 100644 index 0000000..a3bb837 --- /dev/null +++ b/src/log-viewer.ts @@ -0,0 +1,527 @@ +/** + * log-viewer.ts - 全链路日志 Web UI v3 + * + * 核心特性: + * - 完整请求参数查看(原始请求 body, messages, tools) + * - 提示词查看(system prompt, 用户消息) + * - 模型返回内容查看(原始响应, 最终响应, thinking, tool calls) + * - 阶段耗时时间线 + * - 重试/续写历史 + * - 实时 SSE + 搜索 + 过滤 + */ + +import type { Request, Response } from 'express'; +import { getAllLogs, getRequestSummaries, getStats, getRequestPayload, subscribeToLogs, subscribeToSummaries } from './logger.js'; + +// ==================== API 路由 ==================== + +export function apiGetLogs(req: Request, res: Response): void { + const { requestId, level, source, limit, since } = req.query; + res.json(getAllLogs({ + requestId: requestId as string, level: level as any, source: source as any, + limit: limit ? parseInt(limit as string) : 200, + since: since ? parseInt(since as string) : undefined, + })); +} + +export function apiGetRequests(req: Request, res: Response): void { + res.json(getRequestSummaries(req.query.limit ? parseInt(req.query.limit as string) : 50)); +} + +export function apiGetStats(_req: Request, res: Response): void { + res.json(getStats()); +} + +/** GET /api/payload/:requestId - 获取请求的完整参数和响应 */ +export function apiGetPayload(req: Request, res: Response): void { + const payload = getRequestPayload(req.params.requestId as string); + if (!payload) { res.status(404).json({ error: 'Not found' }); return; } + res.json(payload); +} + +export function apiLogsStream(req: Request, res: Response): void { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', + }); + const sse = (event: string, data: string) => 'event: ' + event + '\ndata: ' + data + '\n\n'; + try { res.write(sse('stats', JSON.stringify(getStats()))); } catch { /**/ } + const unsubLog = subscribeToLogs(e => { try { res.write(sse('log', JSON.stringify(e))); } catch { /**/ } }); + const unsubSummary = subscribeToSummaries(s => { + try { res.write(sse('summary', JSON.stringify(s))); res.write(sse('stats', JSON.stringify(getStats()))); } catch { /**/ } + }); + const hb = setInterval(() => { try { res.write(': heartbeat\n\n'); } catch { /**/ } }, 15000); + req.on('close', () => { unsubLog(); unsubSummary(); clearInterval(hb); }); +} + +export function serveLogViewer(_req: Request, res: Response): void { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(LOG_VIEWER_HTML); +} + +// ==================== HTML ==================== + +const LOG_VIEWER_HTML = ` + + + + +Cursor2API - 全链路日志 + + + + +
+
+

Cursor2API 日志

+
+
0请求
+
0
+
0
+
-ms 均耗
+
-ms TTFT
+
+
+
已连接
+
+
+
+
+ +
+ + + + + +
+
+
📡

等待请求...

+
+
+
+
+

🔍 实时日志流

+
+
+ + + + +
+
+
+
+
阶段耗时
+ +
+
+
📋

实时日志将在此显示

发起请求后即可看到全链路日志

+
+
+
+
+
+ + + +`; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..75d57d2 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,465 @@ +/** + * logger.ts - 全链路日志系统 v3 + * + * 核心升级: + * - 存储完整的请求参数(messages, system prompt, tools) + * - 存储完整的模型返回内容(raw response) + * - 存储转换后的 Cursor 请求 + * - 阶段耗时追踪 (Phase Timing) + * - TTFT (Time To First Token) + * - 全部通过 Web UI 可视化 + */ + +import { EventEmitter } from 'events'; + +// ==================== 类型定义 ==================== + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +export type LogSource = 'Handler' | 'OpenAI' | 'Cursor' | 'Auth' | 'System' | 'Converter'; +export type LogPhase = + | 'receive' | 'auth' | 'convert' | 'intercept' | 'send' + | 'response' | 'refusal' | 'retry' | 'truncation' | 'continuation' + | 'thinking' | 'toolparse' | 'sanitize' | 'stream' | 'complete' | 'error'; + +export interface LogEntry { + id: string; + requestId: string; + timestamp: number; + level: LogLevel; + source: LogSource; + phase: LogPhase; + message: string; + details?: unknown; + duration?: number; +} + +export interface PhaseTiming { + phase: LogPhase; + label: string; + startTime: number; + endTime?: number; + duration?: number; +} + +/** + * 完整请求数据 — 存储每个请求的全量参数和响应 + */ +export interface RequestPayload { + // ===== 原始请求 ===== + /** 原始请求 body(Anthropic 或 OpenAI 格式) */ + originalRequest?: unknown; + /** System prompt(提取出来方便查看) */ + systemPrompt?: string; + /** 用户消息列表摘要 */ + messages?: Array<{ role: string; contentPreview: string; contentLength: number; hasImages?: boolean }>; + /** 工具定义列表 */ + tools?: Array<{ name: string; description?: string }>; + + // ===== 转换后请求 ===== + /** 转换后的 Cursor 请求 */ + cursorRequest?: unknown; + /** Cursor 消息列表摘要 */ + cursorMessages?: Array<{ role: string; contentPreview: string; contentLength: number }>; + + // ===== 模型响应 ===== + /** 原始模型返回全文 */ + rawResponse?: string; + /** 清洗/处理后的最终响应 */ + finalResponse?: string; + /** Thinking 内容 */ + thinkingContent?: string; + /** 工具调用解析结果 */ + toolCalls?: unknown[]; + /** 每次重试的原始响应 */ + retryResponses?: Array<{ attempt: number; response: string; reason: string }>; + /** 每次续写的原始响应 */ + continuationResponses?: Array<{ index: number; response: string; dedupedLength: number }>; +} + +export interface RequestSummary { + requestId: string; + startTime: number; + endTime?: number; + method: string; + path: string; + model: string; + stream: boolean; + apiFormat: 'anthropic' | 'openai' | 'responses'; + hasTools: boolean; + toolCount: number; + messageCount: number; + status: 'processing' | 'success' | 'error' | 'intercepted'; + responseChars: number; + retryCount: number; + continuationCount: number; + stopReason?: string; + error?: string; + toolCallsDetected: number; + ttft?: number; + cursorApiTime?: number; + phaseTimings: PhaseTiming[]; + thinkingChars: number; + systemPromptLength: number; +} + +// ==================== 存储 ==================== + +const MAX_ENTRIES = 5000; +const MAX_REQUESTS = 200; + +let logCounter = 0; +const logEntries: LogEntry[] = []; +const requestSummaries: Map = new Map(); +const requestPayloads: Map = new Map(); +const requestOrder: string[] = []; + +const logEmitter = new EventEmitter(); +logEmitter.setMaxListeners(50); + +function shortId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let id = ''; + for (let i = 0; i < 8; i++) id += chars[Math.floor(Math.random() * chars.length)]; + return id; +} + +// ==================== 统计 ==================== + +export function getStats() { + let success = 0, error = 0, intercepted = 0, processing = 0; + let totalTime = 0, timeCount = 0, totalTTFT = 0, ttftCount = 0; + for (const s of requestSummaries.values()) { + if (s.status === 'success') success++; + else if (s.status === 'error') error++; + else if (s.status === 'intercepted') intercepted++; + else if (s.status === 'processing') processing++; + if (s.endTime) { totalTime += s.endTime - s.startTime; timeCount++; } + if (s.ttft) { totalTTFT += s.ttft; ttftCount++; } + } + return { + totalRequests: requestSummaries.size, + successCount: success, errorCount: error, + interceptedCount: intercepted, processingCount: processing, + avgResponseTime: timeCount > 0 ? Math.round(totalTime / timeCount) : 0, + avgTTFT: ttftCount > 0 ? Math.round(totalTTFT / ttftCount) : 0, + totalLogEntries: logEntries.length, + }; +} + +// ==================== 核心 API ==================== + +export function createRequestLogger(opts: { + method: string; + path: string; + model: string; + stream: boolean; + hasTools: boolean; + toolCount: number; + messageCount: number; + apiFormat?: 'anthropic' | 'openai' | 'responses'; + systemPromptLength?: number; +}): RequestLogger { + const requestId = shortId(); + const summary: RequestSummary = { + requestId, startTime: Date.now(), + method: opts.method, path: opts.path, model: opts.model, + stream: opts.stream, + apiFormat: opts.apiFormat || (opts.path.includes('chat/completions') ? 'openai' : + opts.path.includes('responses') ? 'responses' : 'anthropic'), + hasTools: opts.hasTools, toolCount: opts.toolCount, + messageCount: opts.messageCount, + status: 'processing', responseChars: 0, + retryCount: 0, continuationCount: 0, toolCallsDetected: 0, + phaseTimings: [], thinkingChars: 0, + systemPromptLength: opts.systemPromptLength || 0, + }; + const payload: RequestPayload = {}; + + requestSummaries.set(requestId, summary); + requestPayloads.set(requestId, payload); + requestOrder.push(requestId); + + while (requestOrder.length > MAX_REQUESTS) { + const oldId = requestOrder.shift()!; + requestSummaries.delete(oldId); + requestPayloads.delete(oldId); + } + + const toolInfo = opts.hasTools ? ` tools=${opts.toolCount}` : ''; + const fmtTag = summary.apiFormat === 'openai' ? ' [OAI]' : summary.apiFormat === 'responses' ? ' [RSP]' : ''; + console.log(`\x1b[36m⟶\x1b[0m [${requestId}] ${opts.method} ${opts.path}${fmtTag} | model=${opts.model} stream=${opts.stream}${toolInfo} msgs=${opts.messageCount}`); + + return new RequestLogger(requestId, summary, payload); +} + +export function getAllLogs(opts?: { requestId?: string; level?: LogLevel; source?: LogSource; limit?: number; since?: number }): LogEntry[] { + let result = logEntries; + if (opts?.requestId) result = result.filter(e => e.requestId === opts.requestId); + if (opts?.level) { + const levels: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + const minLevel = levels[opts.level]; + result = result.filter(e => levels[e.level] >= minLevel); + } + if (opts?.source) result = result.filter(e => e.source === opts.source); + if (opts?.since) result = result.filter(e => e.timestamp > opts!.since!); + if (opts?.limit) result = result.slice(-opts.limit); + return result; +} + +export function getRequestSummaries(limit?: number): RequestSummary[] { + const ids = limit ? requestOrder.slice(-limit) : requestOrder; + return ids.map(id => requestSummaries.get(id)!).filter(Boolean).reverse(); +} + +/** 获取请求的完整 payload 数据 */ +export function getRequestPayload(requestId: string): RequestPayload | undefined { + return requestPayloads.get(requestId); +} + +export function subscribeToLogs(listener: (entry: LogEntry) => void): () => void { + logEmitter.on('log', listener); + return () => logEmitter.off('log', listener); +} + +export function subscribeToSummaries(listener: (summary: RequestSummary) => void): () => void { + logEmitter.on('summary', listener); + return () => logEmitter.off('summary', listener); +} + +function addEntry(entry: LogEntry): void { + logEntries.push(entry); + while (logEntries.length > MAX_ENTRIES) logEntries.shift(); + logEmitter.emit('log', entry); +} + +// ==================== RequestLogger ==================== + +export class RequestLogger { + readonly requestId: string; + private summary: RequestSummary; + private payload: RequestPayload; + private activePhase: PhaseTiming | null = null; + + constructor(requestId: string, summary: RequestSummary, payload: RequestPayload) { + this.requestId = requestId; + this.summary = summary; + this.payload = payload; + } + + private log(level: LogLevel, source: LogSource, phase: LogPhase, message: string, details?: unknown): void { + addEntry({ + id: `log_${++logCounter}`, + requestId: this.requestId, + timestamp: Date.now(), + level, source, phase, message, details, + duration: Date.now() - this.summary.startTime, + }); + } + + // ---- 阶段追踪 ---- + startPhase(phase: LogPhase, label: string): void { + if (this.activePhase && !this.activePhase.endTime) { + this.activePhase.endTime = Date.now(); + this.activePhase.duration = this.activePhase.endTime - this.activePhase.startTime; + } + const t: PhaseTiming = { phase, label, startTime: Date.now() }; + this.activePhase = t; + this.summary.phaseTimings.push(t); + } + endPhase(): void { + if (this.activePhase && !this.activePhase.endTime) { + this.activePhase.endTime = Date.now(); + this.activePhase.duration = this.activePhase.endTime - this.activePhase.startTime; + } + } + + // ---- 便捷方法 ---- + debug(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { this.log('debug', source, phase, message, details); } + info(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { this.log('info', source, phase, message, details); } + warn(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { + this.log('warn', source, phase, message, details); + console.log(`\x1b[33m⚠\x1b[0m [${this.requestId}] ${message}`); + } + error(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { + this.log('error', source, phase, message, details); + console.error(`\x1b[31m✗\x1b[0m [${this.requestId}] ${message}`); + } + + // ---- 特殊事件 ---- + recordTTFT(): void { this.summary.ttft = Date.now() - this.summary.startTime; } + recordCursorApiTime(startTime: number): void { this.summary.cursorApiTime = Date.now() - startTime; } + + // ---- 全量数据记录 ---- + + /** 记录原始请求(包含 messages, system, tools 等) */ + recordOriginalRequest(body: any): void { + // system prompt + if (typeof body.system === 'string') { + this.payload.systemPrompt = body.system; + } else if (Array.isArray(body.system)) { + this.payload.systemPrompt = body.system.map((b: any) => b.text || '').join('\n'); + } + + // messages 摘要 + 完整存储 + if (Array.isArray(body.messages)) { + const MAX_MSG = 100000; // 单条消息最大存储 100K + this.payload.messages = body.messages.map((m: any) => { + let fullContent = ''; + let contentLength = 0; + let hasImages = false; + if (typeof m.content === 'string') { + fullContent = m.content.length > MAX_MSG ? m.content.substring(0, MAX_MSG) + '\n... [截断]' : m.content; + contentLength = m.content.length; + } else if (Array.isArray(m.content)) { + const textParts = m.content.filter((c: any) => c.type === 'text'); + const imageParts = m.content.filter((c: any) => c.type === 'image' || c.type === 'image_url'); + hasImages = imageParts.length > 0; + const text = textParts.map((c: any) => c.text || '').join('\n'); + fullContent = text.length > MAX_MSG ? text.substring(0, MAX_MSG) + '\n... [截断]' : text; + contentLength = text.length; + if (hasImages) fullContent += `\n[+${imageParts.length} images]`; + } + return { role: m.role, contentPreview: fullContent, contentLength, hasImages }; + }); + } + + // tools + if (Array.isArray(body.tools)) { + this.payload.tools = body.tools.map((t: any) => ({ + name: t.name || t.function?.name || 'unknown', + description: (t.description || t.function?.description || '').substring(0, 200), + })); + } + + // 存全量 (去掉 base64 图片数据避免内存爆炸) + this.payload.originalRequest = this.sanitizeForStorage(body); + } + + /** 记录转换后的 Cursor 请求 */ + recordCursorRequest(cursorReq: any): void { + if (Array.isArray(cursorReq.messages)) { + const MAX_MSG = 100000; + this.payload.cursorMessages = cursorReq.messages.map((m: any) => { + // Cursor 消息用 parts 而不是 content + let text = ''; + if (m.parts && Array.isArray(m.parts)) { + text = m.parts.map((p: any) => p.text || '').join('\n'); + } else if (typeof m.content === 'string') { + text = m.content; + } else if (m.content) { + text = JSON.stringify(m.content); + } + const fullContent = text.length > MAX_MSG ? text.substring(0, MAX_MSG) + '\n... [截断]' : text; + return { + role: m.role, + contentPreview: fullContent, + contentLength: text.length, + }; + }); + } + // 存储不含完整消息体的 cursor 请求元信息 + this.payload.cursorRequest = { + model: cursorReq.model, + messageCount: cursorReq.messages?.length, + totalChars: cursorReq.messages?.reduce((sum: number, m: any) => { + if (m.parts && Array.isArray(m.parts)) { + return sum + m.parts.reduce((s: number, p: any) => s + (p.text?.length || 0), 0); + } + const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || ''); + return sum + text.length; + }, 0), + }; + } + + /** 记录模型原始响应 */ + recordRawResponse(text: string): void { + this.payload.rawResponse = text; + } + + /** 记录最终响应 */ + recordFinalResponse(text: string): void { + this.payload.finalResponse = text; + } + + /** 记录 thinking 内容 */ + recordThinking(content: string): void { + this.payload.thinkingContent = content; + this.summary.thinkingChars = content.length; + } + + /** 记录工具调用 */ + recordToolCalls(calls: unknown[]): void { + this.payload.toolCalls = calls; + } + + /** 记录重试响应 */ + recordRetryResponse(attempt: number, response: string, reason: string): void { + if (!this.payload.retryResponses) this.payload.retryResponses = []; + this.payload.retryResponses.push({ attempt, response, reason }); + } + + /** 记录续写响应 */ + recordContinuationResponse(index: number, response: string, dedupedLength: number): void { + if (!this.payload.continuationResponses) this.payload.continuationResponses = []; + this.payload.continuationResponses.push({ index, response: response.substring(0, 2000), dedupedLength }); + } + + /** 去除 base64 图片数据以节省内存 */ + private sanitizeForStorage(obj: any): any { + if (!obj || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(item => this.sanitizeForStorage(item)); + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === 'data' && typeof value === 'string' && (value as string).length > 1000) { + result[key] = `[base64 data: ${(value as string).length} chars]`; + } else if (key === 'source' && typeof value === 'object' && (value as any)?.type === 'base64') { + result[key] = { type: 'base64', media_type: (value as any).media_type, data: `[${((value as any).data?.length || 0)} chars]` }; + } else if (typeof value === 'object') { + result[key] = this.sanitizeForStorage(value); + } else { + result[key] = value; + } + } + return result; + } + + // ---- 摘要更新 ---- + updateSummary(updates: Partial): void { + Object.assign(this.summary, updates); + logEmitter.emit('summary', this.summary); + } + + complete(responseChars: number, stopReason?: string): void { + this.endPhase(); + const duration = Date.now() - this.summary.startTime; + this.summary.endTime = Date.now(); + this.summary.status = 'success'; + this.summary.responseChars = responseChars; + this.summary.stopReason = stopReason; + this.log('info', 'System', 'complete', `完成 (${duration}ms, ${responseChars} chars, stop=${stopReason})`); + logEmitter.emit('summary', this.summary); + + const retryInfo = this.summary.retryCount > 0 ? ` retry=${this.summary.retryCount}` : ''; + const contInfo = this.summary.continuationCount > 0 ? ` cont=${this.summary.continuationCount}` : ''; + const toolInfo = this.summary.toolCallsDetected > 0 ? ` tools_called=${this.summary.toolCallsDetected}` : ''; + const ttftInfo = this.summary.ttft ? ` ttft=${this.summary.ttft}ms` : ''; + console.log(`\x1b[32m⟵\x1b[0m [${this.requestId}] ${duration}ms | ${responseChars} chars | stop=${stopReason || 'end_turn'}${ttftInfo}${retryInfo}${contInfo}${toolInfo}`); + } + + intercepted(reason: string): void { + this.summary.status = 'intercepted'; + this.summary.endTime = Date.now(); + this.log('info', 'System', 'intercept', reason); + logEmitter.emit('summary', this.summary); + console.log(`\x1b[35m⊘\x1b[0m [${this.requestId}] 拦截: ${reason}`); + } + + fail(error: string): void { + this.endPhase(); + this.summary.status = 'error'; + this.summary.endTime = Date.now(); + this.summary.error = error; + this.log('error', 'System', 'error', error); + logEmitter.emit('summary', this.summary); + } +} diff --git a/src/openai-handler.ts b/src/openai-handler.ts index aedd2fa..b933b6d 100644 --- a/src/openai-handler.ts +++ b/src/openai-handler.ts @@ -27,6 +27,7 @@ import type { import { convertToCursorRequest, parseToolCalls, hasToolCalls } from './converter.js'; import { sendCursorRequest, sendCursorRequestFull } from './cursor-client.js'; import { getConfig } from './config.js'; +import { createRequestLogger } from './logger.js'; import { isRefusal, sanitizeResponse, @@ -278,17 +279,37 @@ function extractOpenAIContent(msg: OpenAIMessage): string { export async function handleOpenAIChatCompletions(req: Request, res: Response): Promise { const body = req.body as OpenAIChatRequest; - console.log(`[OpenAI] 收到请求: model=${body.model}, messages=${body.messages?.length}, stream=${body.stream}, tools=${body.tools?.length ?? 0}`); + const log = createRequestLogger({ + method: req.method, + path: req.path, + model: body.model, + stream: !!body.stream, + hasTools: (body.tools?.length ?? 0) > 0, + toolCount: body.tools?.length ?? 0, + messageCount: body.messages?.length ?? 0, + apiFormat: 'openai', + }); + + log.startPhase('receive', '接收请求'); + log.recordOriginalRequest(body); + log.info('OpenAI', 'receive', `收到 OpenAI Chat 请求`, { + model: body.model, + messageCount: body.messages?.length, + stream: body.stream, + toolCount: body.tools?.length ?? 0, + }); try { // Step 1: OpenAI → Anthropic 格式 + log.startPhase('convert', '格式转换 (OpenAI→Anthropic)'); const anthropicReq = convertToAnthropicRequest(body); + log.endPhase(); // 注意:图片预处理已移入 convertToCursorRequest → preprocessImages() 统一处理 // Step 1.6: 身份探针拦截(复用 Anthropic handler 的逻辑) if (isIdentityProbe(anthropicReq)) { - console.log(`[OpenAI] 拦截到身份探针,返回模拟响应`); + log.intercepted('身份探针拦截 (OpenAI)'); const mockText = "I am Claude, an advanced AI programming assistant created by Anthropic. I am ready to help you write code, debug, and answer your technical questions. Please let me know what we should work on!"; if (body.stream) { return handleOpenAIMockStream(res, body, mockText); @@ -307,7 +328,7 @@ export async function handleOpenAIChatCompletions(req: Request, res: Response): } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - console.error(`[OpenAI] 请求处理失败:`, message); + log.fail(message); res.status(500).json({ error: { message, @@ -403,7 +424,7 @@ async function handleOpenAIStream( try { await executeStream(); - console.log(`[OpenAI] 原始响应 (${fullResponse.length} chars, tools=${hasTools}): ${fullResponse.substring(0, 200)}${fullResponse.length > 200 ? '...' : ''}`); + // 日志记录在详细日志中 (Web UI 可见) // ★ Thinking 提取(在拒绝检测之前) const thinkingEnabled = anthropicReq.thinking?.type === 'enabled'; @@ -415,7 +436,7 @@ async function handleOpenAIStream( reasoningContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); } fullResponse = fullResponse.replace(/[\s\S]*?<\/thinking>\s*/g, '').trim(); - console.log(`[OpenAI] 流式:剥离 thinking, 剩余 ${fullResponse.length} chars${thinkingEnabled ? ', 将发送 reasoning_content' : ''}`); + // thinking 剥离记录在详细日志中 } } @@ -428,7 +449,7 @@ async function handleOpenAIStream( while (shouldRetryRefusal() && retryCount < MAX_REFUSAL_RETRIES) { retryCount++; - console.log(`[OpenAI] 检测到拒绝(第${retryCount}次),自动重试...原始: ${fullResponse.substring(0, 100)}`); + // 重试记录在详细日志中 const retryBody = buildRetryRequest(anthropicReq, retryCount - 1); activeCursorReq = await convertToCursorRequest(retryBody); await executeStream(); @@ -436,14 +457,14 @@ async function handleOpenAIStream( if (shouldRetryRefusal()) { if (!hasTools) { if (isToolCapabilityQuestion(anthropicReq)) { - console.log(`[OpenAI] 工具能力询问被拒绝,返回 Claude 能力描述`); + // 记录在详细日志 fullResponse = CLAUDE_TOOLS_RESPONSE; } else { - console.log(`[OpenAI] 重试${MAX_REFUSAL_RETRIES}次后仍被拒绝,返回 Claude 身份回复`); + // 记录在详细日志 fullResponse = CLAUDE_IDENTITY_RESPONSE; } } else { - console.log(`[OpenAI] 工具模式下拒绝且无工具调用,引导模型输出`); + // 记录在详细日志 fullResponse = 'I understand the request. Let me analyze the information and proceed with the appropriate action.'; } } @@ -451,7 +472,7 @@ async function handleOpenAIStream( // 极短响应重试 if (hasTools && fullResponse.trim().length < 10 && retryCount < MAX_REFUSAL_RETRIES) { retryCount++; - console.log(`[OpenAI] 响应过短 (${fullResponse.length} chars),重试第${retryCount}次`); + // 记录在详细日志 activeCursorReq = await convertToCursorRequest(anthropicReq); await executeStream(); } @@ -607,7 +628,7 @@ async function handleOpenAINonStream( let fullText = await sendCursorRequestFull(cursorReq); const hasTools = (body.tools?.length ?? 0) > 0; - console.log(`[OpenAI] 非流式原始响应 (${fullText.length} chars, tools=${hasTools}): ${fullText.substring(0, 300)}${fullText.length > 300 ? '...' : ''}`); + // 日志记录在详细日志中 // ★ Thinking 提取必须在拒绝检测之前 — 否则 thinking 内容中的关键词会触发 isRefusal 误判 const thinkingEnabled = anthropicReq.thinking?.type === 'enabled'; @@ -619,7 +640,7 @@ async function handleOpenAINonStream( reasoningContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); } const stripped = fullText.replace(/[\s\S]*?<\/thinking>\s*/g, '').trim(); - console.log(`[OpenAI] 剥离 thinking: ${fullText.length - stripped.length} chars, 剩余 ${stripped.length} chars${thinkingEnabled ? ', 将返回 reasoning_content' : ''}`); + // thinking 剥离记录 fullText = stripped; } } @@ -629,7 +650,7 @@ async function handleOpenAINonStream( if (shouldRetry()) { for (let attempt = 0; attempt < MAX_REFUSAL_RETRIES; attempt++) { - console.log(`[OpenAI] 非流式:检测到拒绝(第${attempt + 1}次重试)...原始: ${fullText.substring(0, 100)}`); + // 重试记录 const retryBody = buildRetryRequest(anthropicReq, attempt); const retryCursorReq = await convertToCursorRequest(retryBody); fullText = await sendCursorRequestFull(retryCursorReq); @@ -641,13 +662,13 @@ async function handleOpenAINonStream( } if (shouldRetry()) { if (hasTools) { - console.log(`[OpenAI] 非流式:工具模式下拒绝,引导模型输出`); + // 记录在详细日志 fullText = 'I understand the request. Let me analyze the information and proceed with the appropriate action.'; } else if (isToolCapabilityQuestion(anthropicReq)) { - console.log(`[OpenAI] 非流式:工具能力询问被拒绝,返回 Claude 能力描述`); + // 记录在详细日志 fullText = CLAUDE_TOOLS_RESPONSE; } else { - console.log(`[OpenAI] 非流式:重试${MAX_REFUSAL_RETRIES}次后仍被拒绝,返回 Claude 身份回复`); + // 记录在详细日志 fullText = CLAUDE_IDENTITY_RESPONSE; } } @@ -665,7 +686,7 @@ async function handleOpenAINonStream( // 清洗拒绝文本 let cleanText = parsed.cleanText; if (isRefusal(cleanText)) { - console.log(`[OpenAI] 抑制工具模式下的拒绝文本: ${cleanText.substring(0, 100)}...`); + // 记录在详细日志 cleanText = ''; } content = sanitizeResponse(cleanText) || null;