mirror of
https://github.com/7836246/cursor2api.git
synced 2026-06-20 13:12:16 +08:00
fix: complete OpenAI logs and default persisted logs to summary
This commit is contained in:
@@ -74,10 +74,12 @@ function parseYamlConfig(defaults: AppConfig): { config: AppConfig; raw: Record<
|
||||
}
|
||||
// ★ 日志文件持久化
|
||||
if (yaml.logging !== undefined) {
|
||||
const persistModes = ['compact', 'full', 'summary'];
|
||||
result.logging = {
|
||||
file_enabled: yaml.logging.file_enabled === true, // 默认关闭
|
||||
dir: yaml.logging.dir || './logs',
|
||||
max_days: typeof yaml.logging.max_days === 'number' ? yaml.logging.max_days : 7,
|
||||
persist_mode: persistModes.includes(yaml.logging.persist_mode) ? yaml.logging.persist_mode : 'summary',
|
||||
};
|
||||
}
|
||||
// ★ 工具处理配置
|
||||
@@ -139,13 +141,21 @@ function applyEnvOverrides(cfg: AppConfig): void {
|
||||
}
|
||||
// Logging 环境变量覆盖
|
||||
if (process.env.LOG_FILE_ENABLED !== undefined) {
|
||||
if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7 };
|
||||
if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' };
|
||||
cfg.logging.file_enabled = process.env.LOG_FILE_ENABLED === 'true' || process.env.LOG_FILE_ENABLED === '1';
|
||||
}
|
||||
if (process.env.LOG_DIR) {
|
||||
if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7 };
|
||||
if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' };
|
||||
cfg.logging.dir = process.env.LOG_DIR;
|
||||
}
|
||||
if (process.env.LOG_PERSIST_MODE) {
|
||||
if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' };
|
||||
cfg.logging.persist_mode = process.env.LOG_PERSIST_MODE === 'full'
|
||||
? 'full'
|
||||
: process.env.LOG_PERSIST_MODE === 'summary'
|
||||
? 'summary'
|
||||
: 'compact';
|
||||
}
|
||||
// 工具透传模式环境变量覆盖
|
||||
if (process.env.TOOLS_PASSTHROUGH !== undefined) {
|
||||
if (!cfg.tools) cfg.tools = { schemaMode: 'full', descriptionMaxLength: 0 };
|
||||
|
||||
@@ -153,7 +153,9 @@ loadLogsFromFiles();
|
||||
|
||||
app.listen(config.port, () => {
|
||||
const auth = config.authTokens?.length ? `${config.authTokens.length} token(s)` : 'open';
|
||||
const logPersist = config.logging?.file_enabled ? `file → ${config.logging.dir}` : 'memory only';
|
||||
const logPersist = config.logging?.file_enabled
|
||||
? `file(${config.logging.persist_mode || 'summary'}) → ${config.logging.dir}`
|
||||
: 'memory only';
|
||||
|
||||
// Tools 配置摘要
|
||||
const toolsCfg = config.tools;
|
||||
|
||||
272
src/logger.ts
272
src/logger.ts
@@ -80,6 +80,14 @@ export interface RequestPayload {
|
||||
retryResponses?: Array<{ attempt: number; response: string; reason: string }>;
|
||||
/** 每次续写的原始响应 */
|
||||
continuationResponses?: Array<{ index: number; response: string; dedupedLength: number }>;
|
||||
/** summary 模式:最后一个用户问题 */
|
||||
question?: string;
|
||||
/** summary 模式:最终回答摘要 */
|
||||
answer?: string;
|
||||
/** summary 模式:回答类型 */
|
||||
answerType?: 'text' | 'tool_calls' | 'empty';
|
||||
/** summary 模式:工具调用名称列表 */
|
||||
toolCallNames?: string[];
|
||||
}
|
||||
|
||||
export interface RequestSummary {
|
||||
@@ -133,12 +141,31 @@ function shortId(): string {
|
||||
|
||||
// ==================== 日志文件持久化 ====================
|
||||
|
||||
const DEFAULT_PERSIST_MODE: 'compact' | 'full' | 'summary' = 'summary';
|
||||
const DISK_SYSTEM_PROMPT_CHARS = 2000;
|
||||
const DISK_MESSAGE_PREVIEW_CHARS = 3000;
|
||||
const DISK_CURSOR_MESSAGE_PREVIEW_CHARS = 2000;
|
||||
const DISK_RESPONSE_CHARS = 8000;
|
||||
const DISK_THINKING_CHARS = 4000;
|
||||
const DISK_TOOL_DESC_CHARS = 500;
|
||||
const DISK_RETRY_CHARS = 2000;
|
||||
const DISK_TOOLCALL_STRING_CHARS = 1200;
|
||||
const DISK_MAX_ARRAY_ITEMS = 20;
|
||||
const DISK_MAX_OBJECT_DEPTH = 5;
|
||||
const DISK_SUMMARY_QUESTION_CHARS = 2000;
|
||||
const DISK_SUMMARY_ANSWER_CHARS = 4000;
|
||||
|
||||
function getLogDir(): string | null {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.logging?.file_enabled) return null;
|
||||
return cfg.logging.dir || './logs';
|
||||
}
|
||||
|
||||
function getPersistMode(): 'compact' | 'full' | 'summary' {
|
||||
const mode = getConfig().logging?.persist_mode;
|
||||
return mode === 'full' || mode === 'summary' || mode === 'compact' ? mode : DEFAULT_PERSIST_MODE;
|
||||
}
|
||||
|
||||
function getLogFilePath(): string | null {
|
||||
const dir = getLogDir();
|
||||
if (!dir) return null;
|
||||
@@ -153,13 +180,256 @@ function ensureLogDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function truncateMiddle(text: string, maxChars: number): string {
|
||||
if (!text || text.length <= maxChars) return text;
|
||||
const omitted = text.length - maxChars;
|
||||
const marker = `\n...[截断 ${omitted} chars]...\n`;
|
||||
const remain = Math.max(16, maxChars - marker.length);
|
||||
const head = Math.ceil(remain * 0.7);
|
||||
const tail = Math.max(8, remain - head);
|
||||
return text.slice(0, head) + marker + text.slice(text.length - tail);
|
||||
}
|
||||
|
||||
function compactUnknownValue(value: unknown, maxStringChars = DISK_TOOLCALL_STRING_CHARS, depth = 0): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === 'string') return truncateMiddle(value, maxStringChars);
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return value;
|
||||
if (depth >= DISK_MAX_OBJECT_DEPTH) {
|
||||
if (Array.isArray(value)) return `[array(${value.length})]`;
|
||||
return '[object]';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.slice(0, DISK_MAX_ARRAY_ITEMS)
|
||||
.map(item => compactUnknownValue(item, maxStringChars, depth + 1));
|
||||
if (value.length > DISK_MAX_ARRAY_ITEMS) {
|
||||
items.push(`[... ${value.length - DISK_MAX_ARRAY_ITEMS} more items]`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
const limit = /content|text|arguments|description|prompt|response|reasoning/i.test(key)
|
||||
? maxStringChars
|
||||
: Math.min(maxStringChars, 400);
|
||||
result[key] = compactUnknownValue(entry, limit, depth + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function extractTextParts(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (!value) return '';
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => extractTextParts(item))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
if (typeof record.output === 'string') return record.output;
|
||||
if (typeof record.content === 'string') return record.content;
|
||||
if (record.content !== undefined) return extractTextParts(record.content);
|
||||
if (record.input !== undefined) return extractTextParts(record.input);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractLastUserQuestion(summary: RequestSummary, payload: RequestPayload): string | undefined {
|
||||
const lastUser = payload.messages?.slice().reverse().find(m => m.role === 'user' && m.contentPreview?.trim());
|
||||
if (lastUser?.contentPreview) {
|
||||
return truncateMiddle(lastUser.contentPreview, DISK_SUMMARY_QUESTION_CHARS);
|
||||
}
|
||||
|
||||
const original = payload.originalRequest && typeof payload.originalRequest === 'object' && !Array.isArray(payload.originalRequest)
|
||||
? payload.originalRequest as Record<string, unknown>
|
||||
: undefined;
|
||||
if (!original) {
|
||||
return summary.title ? truncateMiddle(summary.title, DISK_SUMMARY_QUESTION_CHARS) : undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(original.messages)) {
|
||||
for (let i = original.messages.length - 1; i >= 0; i--) {
|
||||
const item = original.messages[i] as Record<string, unknown>;
|
||||
if (item?.role === 'user') {
|
||||
const text = extractTextParts(item.content);
|
||||
if (text.trim()) return truncateMiddle(text, DISK_SUMMARY_QUESTION_CHARS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof original.input === 'string' && original.input.trim()) {
|
||||
return truncateMiddle(original.input, DISK_SUMMARY_QUESTION_CHARS);
|
||||
}
|
||||
if (Array.isArray(original.input)) {
|
||||
for (let i = original.input.length - 1; i >= 0; i--) {
|
||||
const item = original.input[i] as Record<string, unknown>;
|
||||
if (!item) continue;
|
||||
const role = typeof item.role === 'string' ? item.role : 'user';
|
||||
if (role === 'user') {
|
||||
const text = extractTextParts(item.content ?? item.input ?? item);
|
||||
if (text.trim()) return truncateMiddle(text, DISK_SUMMARY_QUESTION_CHARS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary.title ? truncateMiddle(summary.title, DISK_SUMMARY_QUESTION_CHARS) : undefined;
|
||||
}
|
||||
|
||||
function extractToolCallNames(payload: RequestPayload): string[] {
|
||||
if (!payload.toolCalls?.length) return [];
|
||||
return payload.toolCalls
|
||||
.map(call => {
|
||||
if (call && typeof call === 'object') {
|
||||
const record = call as Record<string, unknown>;
|
||||
if (typeof record.name === 'string') return record.name;
|
||||
const fn = record.function;
|
||||
if (fn && typeof fn === 'object' && typeof (fn as Record<string, unknown>).name === 'string') {
|
||||
return (fn as Record<string, unknown>).name as string;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildSummaryPayload(summary: RequestSummary, payload: RequestPayload): RequestPayload {
|
||||
const question = extractLastUserQuestion(summary, payload);
|
||||
const answerText = payload.finalResponse || payload.rawResponse || '';
|
||||
const toolCallNames = extractToolCallNames(payload);
|
||||
const answer = answerText
|
||||
? truncateMiddle(answerText, DISK_SUMMARY_ANSWER_CHARS)
|
||||
: toolCallNames.length > 0
|
||||
? `[tool_calls] ${toolCallNames.join(', ')}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(question ? { question } : {}),
|
||||
...(answer ? { answer } : {}),
|
||||
answerType: answerText ? 'text' : toolCallNames.length > 0 ? 'tool_calls' : 'empty',
|
||||
...(toolCallNames.length > 0 ? { toolCallNames } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompactOriginalRequest(summary: RequestSummary, payload: RequestPayload): Record<string, unknown> | undefined {
|
||||
const original = payload.originalRequest && typeof payload.originalRequest === 'object' && !Array.isArray(payload.originalRequest)
|
||||
? payload.originalRequest as Record<string, unknown>
|
||||
: undefined;
|
||||
const result: Record<string, unknown> = {
|
||||
model: summary.model,
|
||||
stream: summary.stream,
|
||||
apiFormat: summary.apiFormat,
|
||||
messageCount: summary.messageCount,
|
||||
toolCount: summary.toolCount,
|
||||
};
|
||||
|
||||
if (summary.title) result.title = summary.title;
|
||||
if (payload.systemPrompt) result.systemPromptPreview = truncateMiddle(payload.systemPrompt, DISK_SYSTEM_PROMPT_CHARS);
|
||||
if (payload.messages?.some(m => m.hasImages)) result.hasImages = true;
|
||||
|
||||
const lastUser = payload.messages?.slice().reverse().find(m => m.role === 'user');
|
||||
if (lastUser?.contentPreview) {
|
||||
result.lastUserPreview = truncateMiddle(lastUser.contentPreview, 800);
|
||||
}
|
||||
|
||||
if (original) {
|
||||
for (const key of ['temperature', 'top_p', 'max_tokens', 'max_completion_tokens', 'max_output_tokens']) {
|
||||
const value = original[key];
|
||||
if (value !== undefined && typeof value !== 'object') result[key] = value;
|
||||
}
|
||||
if (typeof original.instructions === 'string') {
|
||||
result.instructions = truncateMiddle(original.instructions, 1200);
|
||||
}
|
||||
if (typeof original.system === 'string') {
|
||||
result.system = truncateMiddle(original.system, DISK_SYSTEM_PROMPT_CHARS);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function compactPayloadForDisk(summary: RequestSummary, payload: RequestPayload): RequestPayload {
|
||||
const compact: RequestPayload = {};
|
||||
|
||||
if (payload.originalRequest !== undefined) {
|
||||
compact.originalRequest = buildCompactOriginalRequest(summary, payload);
|
||||
}
|
||||
if (payload.systemPrompt) {
|
||||
compact.systemPrompt = truncateMiddle(payload.systemPrompt, DISK_SYSTEM_PROMPT_CHARS);
|
||||
}
|
||||
if (payload.messages?.length) {
|
||||
compact.messages = payload.messages.map(msg => ({
|
||||
...msg,
|
||||
contentPreview: truncateMiddle(msg.contentPreview, DISK_MESSAGE_PREVIEW_CHARS),
|
||||
}));
|
||||
}
|
||||
if (payload.tools?.length) {
|
||||
compact.tools = payload.tools.map(tool => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: truncateMiddle(tool.description, DISK_TOOL_DESC_CHARS) } : {}),
|
||||
}));
|
||||
}
|
||||
if (payload.cursorRequest !== undefined) {
|
||||
compact.cursorRequest = payload.cursorRequest;
|
||||
}
|
||||
if (payload.cursorMessages?.length) {
|
||||
compact.cursorMessages = payload.cursorMessages.map(msg => ({
|
||||
...msg,
|
||||
contentPreview: truncateMiddle(msg.contentPreview, DISK_CURSOR_MESSAGE_PREVIEW_CHARS),
|
||||
}));
|
||||
}
|
||||
|
||||
const compactFinalResponse = payload.finalResponse
|
||||
? truncateMiddle(payload.finalResponse, DISK_RESPONSE_CHARS)
|
||||
: undefined;
|
||||
const compactRawResponse = payload.rawResponse
|
||||
? truncateMiddle(payload.rawResponse, DISK_RESPONSE_CHARS)
|
||||
: undefined;
|
||||
|
||||
if (compactFinalResponse) compact.finalResponse = compactFinalResponse;
|
||||
if (compactRawResponse && compactRawResponse !== compactFinalResponse) {
|
||||
compact.rawResponse = compactRawResponse;
|
||||
}
|
||||
if (payload.thinkingContent) {
|
||||
compact.thinkingContent = truncateMiddle(payload.thinkingContent, DISK_THINKING_CHARS);
|
||||
}
|
||||
if (payload.toolCalls?.length) {
|
||||
compact.toolCalls = compactUnknownValue(payload.toolCalls) as unknown[];
|
||||
}
|
||||
if (payload.retryResponses?.length) {
|
||||
compact.retryResponses = payload.retryResponses.map(item => ({
|
||||
...item,
|
||||
response: truncateMiddle(item.response, DISK_RETRY_CHARS),
|
||||
reason: truncateMiddle(item.reason, 300),
|
||||
}));
|
||||
}
|
||||
if (payload.continuationResponses?.length) {
|
||||
compact.continuationResponses = payload.continuationResponses.map(item => ({
|
||||
...item,
|
||||
response: truncateMiddle(item.response, DISK_RETRY_CHARS),
|
||||
}));
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
/** 将已完成的请求写入日志文件 */
|
||||
function persistRequest(summary: RequestSummary, payload: RequestPayload): void {
|
||||
const filepath = getLogFilePath();
|
||||
if (!filepath) return;
|
||||
try {
|
||||
ensureLogDir();
|
||||
const record = { timestamp: Date.now(), summary, payload };
|
||||
const persistMode = getPersistMode();
|
||||
const persistedPayload = persistMode === 'full'
|
||||
? payload
|
||||
: persistMode === 'summary'
|
||||
? buildSummaryPayload(summary, payload)
|
||||
: compactPayloadForDisk(summary, payload);
|
||||
const record = { timestamp: Date.now(), summary, payload: persistedPayload };
|
||||
appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
|
||||
} catch (e) {
|
||||
console.warn('[Logger] 写入日志文件失败:', e);
|
||||
|
||||
@@ -27,7 +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 { createRequestLogger, type RequestLogger } from './logger.js';
|
||||
import { createIncrementalTextStreamer, hasLeadingThinking, splitLeadingThinkingBlocks, stripThinkingTags } from './streaming-text.js';
|
||||
import {
|
||||
autoContinueCursorToolResponseFull,
|
||||
@@ -488,11 +488,12 @@ export async function handleOpenAIChatCompletions(req: Request, res: Response):
|
||||
|
||||
// Step 2: Anthropic → Cursor 格式(复用现有管道)
|
||||
const cursorReq = await convertToCursorRequest(anthropicReq);
|
||||
log.recordCursorRequest(cursorReq);
|
||||
|
||||
if (body.stream) {
|
||||
await handleOpenAIStream(res, cursorReq, body, anthropicReq);
|
||||
await handleOpenAIStream(res, cursorReq, body, anthropicReq, log);
|
||||
} else {
|
||||
await handleOpenAINonStream(res, cursorReq, body, anthropicReq);
|
||||
await handleOpenAINonStream(res, cursorReq, body, anthropicReq, log);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -609,6 +610,7 @@ async function handleOpenAIIncrementalTextStream(
|
||||
body: OpenAIChatRequest,
|
||||
anthropicReq: AnthropicRequest,
|
||||
streamMeta: { id: string; created: number; model: string },
|
||||
log: RequestLogger,
|
||||
): Promise<void> {
|
||||
let activeCursorReq = cursorReq;
|
||||
let retryCount = 0;
|
||||
@@ -739,6 +741,16 @@ async function handleOpenAIIncrementalTextStream(
|
||||
usage: buildOpenAIUsage(anthropicReq, streamer.hasSentText() ? (finalVisibleText || finalRawResponse) : finalTextToSend),
|
||||
});
|
||||
|
||||
log.recordRawResponse(finalRawResponse);
|
||||
if (finalReasoningContent) {
|
||||
log.recordThinking(finalReasoningContent);
|
||||
}
|
||||
const finalRecordedResponse = streamer.hasSentText()
|
||||
? sanitizeResponse(finalVisibleText || finalRawResponse)
|
||||
: finalTextToSend;
|
||||
log.recordFinalResponse(finalRecordedResponse);
|
||||
log.complete(finalRecordedResponse.length, 'stop');
|
||||
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
}
|
||||
@@ -750,6 +762,7 @@ async function handleOpenAIStream(
|
||||
cursorReq: CursorChatRequest,
|
||||
body: OpenAIChatRequest,
|
||||
anthropicReq: AnthropicRequest,
|
||||
log: RequestLogger,
|
||||
): Promise<void> {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -790,7 +803,7 @@ async function handleOpenAIStream(
|
||||
|
||||
try {
|
||||
if (!hasTools && (!body.response_format || body.response_format.type === 'text')) {
|
||||
await handleOpenAIIncrementalTextStream(res, cursorReq, body, anthropicReq, { id, created, model });
|
||||
await handleOpenAIIncrementalTextStream(res, cursorReq, body, anthropicReq, { id, created, model }, log);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -973,6 +986,8 @@ async function handleOpenAIStream(
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
finishReason = 'tool_calls';
|
||||
log.recordToolCalls(toolCalls);
|
||||
log.updateSummary({ toolCallsDetected: toolCalls.length });
|
||||
|
||||
// 发送工具调用前的残余文本 — 如果混合流式已发送则跳过
|
||||
if (!hybridTextSent) {
|
||||
@@ -1083,10 +1098,18 @@ async function handleOpenAIStream(
|
||||
usage: buildOpenAIUsage(anthropicReq, fullResponse),
|
||||
});
|
||||
|
||||
log.recordRawResponse(fullResponse);
|
||||
if (reasoningContent) {
|
||||
log.recordThinking(reasoningContent);
|
||||
}
|
||||
log.recordFinalResponse(fullResponse);
|
||||
log.complete(fullResponse.length, finishReason);
|
||||
|
||||
res.write('data: [DONE]\n\n');
|
||||
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.fail(message);
|
||||
writeOpenAISSE(res, {
|
||||
id, object: 'chat.completion.chunk', created, model,
|
||||
choices: [{
|
||||
@@ -1108,6 +1131,7 @@ async function handleOpenAINonStream(
|
||||
cursorReq: CursorChatRequest,
|
||||
body: OpenAIChatRequest,
|
||||
anthropicReq: AnthropicRequest,
|
||||
log: RequestLogger,
|
||||
): Promise<void> {
|
||||
let activeCursorReq = cursorReq;
|
||||
let fullText = await sendCursorRequestFull(activeCursorReq);
|
||||
@@ -1172,6 +1196,8 @@ async function handleOpenAINonStream(
|
||||
|
||||
if (parsed.toolCalls.length > 0) {
|
||||
finishReason = 'tool_calls';
|
||||
log.recordToolCalls(parsed.toolCalls);
|
||||
log.updateSummary({ toolCallsDetected: parsed.toolCalls.length });
|
||||
// 清洗拒绝文本
|
||||
let cleanText = parsed.cleanText;
|
||||
if (isRefusal(cleanText)) {
|
||||
@@ -1224,6 +1250,13 @@ async function handleOpenAINonStream(
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
|
||||
log.recordRawResponse(fullText);
|
||||
if (reasoningContent) {
|
||||
log.recordThinking(reasoningContent);
|
||||
}
|
||||
log.recordFinalResponse(fullText);
|
||||
log.complete(fullText.length, finishReason);
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
@@ -1300,17 +1333,39 @@ function buildResponseObject(
|
||||
* 而非 data: {"object":"chat.completion.chunk",...} 格式
|
||||
*/
|
||||
export async function handleOpenAIResponses(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const body = req.body;
|
||||
const isStream = (body.stream as boolean) ?? true;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const isStream = (body.stream as boolean) ?? true;
|
||||
const chatBody = responsesToChatCompletions(body);
|
||||
const log = createRequestLogger({
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
model: chatBody.model,
|
||||
stream: isStream,
|
||||
hasTools: (chatBody.tools?.length ?? 0) > 0,
|
||||
toolCount: chatBody.tools?.length ?? 0,
|
||||
messageCount: chatBody.messages?.length ?? 0,
|
||||
apiFormat: 'responses',
|
||||
});
|
||||
log.startPhase('receive', '接收请求');
|
||||
log.recordOriginalRequest(body);
|
||||
log.info('OpenAI', 'receive', '收到 OpenAI Responses 请求', {
|
||||
model: chatBody.model,
|
||||
stream: isStream,
|
||||
toolCount: chatBody.tools?.length ?? 0,
|
||||
messageCount: chatBody.messages?.length ?? 0,
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 1: 转换请求格式 Responses → Chat Completions → Anthropic → Cursor
|
||||
const chatBody = responsesToChatCompletions(body);
|
||||
log.startPhase('convert', '格式转换 (Responses→Chat→Anthropic)');
|
||||
const anthropicReq = convertToAnthropicRequest(chatBody);
|
||||
const cursorReq = await convertToCursorRequest(anthropicReq);
|
||||
log.endPhase();
|
||||
log.recordCursorRequest(cursorReq);
|
||||
|
||||
// 身份探针拦截
|
||||
if (isIdentityProbe(anthropicReq)) {
|
||||
log.intercepted('身份探针拦截 (Responses)');
|
||||
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.";
|
||||
if (isStream) {
|
||||
return handleResponsesStreamMock(res, body, mockText);
|
||||
@@ -1320,12 +1375,13 @@ export async function handleOpenAIResponses(req: Request, res: Response): Promis
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
await handleResponsesStream(res, cursorReq, body, anthropicReq);
|
||||
await handleResponsesStream(res, cursorReq, body, anthropicReq, log);
|
||||
} else {
|
||||
await handleResponsesNonStream(res, cursorReq, body, anthropicReq);
|
||||
await handleResponsesNonStream(res, cursorReq, body, anthropicReq, log);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.fail(message);
|
||||
console.error(`[OpenAI] /v1/responses 处理失败:`, message);
|
||||
const status = err instanceof OpenAIRequestError ? err.status : 500;
|
||||
const type = err instanceof OpenAIRequestError ? err.type : 'server_error';
|
||||
@@ -1471,6 +1527,7 @@ async function handleResponsesStream(
|
||||
cursorReq: CursorChatRequest,
|
||||
body: Record<string, unknown>,
|
||||
anthropicReq: AnthropicRequest,
|
||||
log: RequestLogger,
|
||||
): Promise<void> {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -1482,6 +1539,7 @@ async function handleResponsesStream(
|
||||
const respId = responsesId();
|
||||
const model = (body.model as string) || 'gpt-4';
|
||||
const hasTools = (anthropicReq.tools?.length ?? 0) > 0;
|
||||
let toolCallsDetected = 0;
|
||||
|
||||
// 缓冲完整响应再处理(复用 Chat Completions 的逻辑)
|
||||
let fullResponse = '';
|
||||
@@ -1557,6 +1615,9 @@ async function handleResponsesStream(
|
||||
const { toolCalls, cleanText } = parseToolCalls(fullResponse);
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
toolCallsDetected = toolCalls.length;
|
||||
log.recordToolCalls(toolCalls);
|
||||
log.updateSummary({ toolCallsDetected: toolCalls.length });
|
||||
// 1. response.created + response.in_progress
|
||||
writeResponsesSSE(res, 'response.created', buildResponseObject(respId, model, 'in_progress', []));
|
||||
writeResponsesSSE(res, 'response.in_progress', buildResponseObject(respId, model, 'in_progress', []));
|
||||
@@ -1658,8 +1719,12 @@ async function handleResponsesStream(
|
||||
const msgItemId = responsesItemId();
|
||||
emitResponsesTextStream(res, respId, msgItemId, model, fullResponse, 0, usage);
|
||||
}
|
||||
log.recordRawResponse(fullResponse);
|
||||
log.recordFinalResponse(fullResponse);
|
||||
log.complete(fullResponse.length, toolCallsDetected > 0 ? 'tool_calls' : 'stop');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.fail(message);
|
||||
// 尝试发送错误后的 response.completed,确保 Codex 不会等待超时
|
||||
try {
|
||||
const errorText = `[Error: ${message}]`;
|
||||
@@ -1707,6 +1772,7 @@ async function handleResponsesNonStream(
|
||||
cursorReq: CursorChatRequest,
|
||||
body: Record<string, unknown>,
|
||||
anthropicReq: AnthropicRequest,
|
||||
log: RequestLogger,
|
||||
): Promise<void> {
|
||||
let activeCursorReq = cursorReq;
|
||||
let fullText = await sendCursorRequestFull(activeCursorReq);
|
||||
@@ -1752,9 +1818,13 @@ async function handleResponsesNonStream(
|
||||
const usage = { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: inputTokens + outputTokens };
|
||||
|
||||
const output: Record<string, unknown>[] = [];
|
||||
let toolCallsDetected = 0;
|
||||
|
||||
if (hasTools && hasToolCalls(fullText)) {
|
||||
const { toolCalls, cleanText } = parseToolCalls(fullText);
|
||||
toolCallsDetected = toolCalls.length;
|
||||
log.recordToolCalls(toolCalls);
|
||||
log.updateSummary({ toolCallsDetected: toolCalls.length });
|
||||
for (const tc of toolCalls) {
|
||||
output.push({
|
||||
id: responsesItemId(),
|
||||
@@ -1786,6 +1856,10 @@ async function handleResponsesNonStream(
|
||||
}
|
||||
|
||||
res.json(buildResponseObject(respId, model, 'completed', output, usage));
|
||||
|
||||
log.recordRawResponse(fullText);
|
||||
log.recordFinalResponse(fullText);
|
||||
log.complete(fullText.length, toolCallsDetected > 0 ? 'tool_calls' : 'stop');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface AppConfig {
|
||||
file_enabled: boolean; // 是否启用日志文件持久化
|
||||
dir: string; // 日志文件存储目录
|
||||
max_days: number; // 日志保留天数
|
||||
persist_mode: 'compact' | 'full' | 'summary'; // 落盘模式: compact=精简, full=完整, summary=仅问答摘要
|
||||
};
|
||||
tools?: {
|
||||
schemaMode: 'compact' | 'full' | 'names_only'; // Schema 呈现模式
|
||||
|
||||
Reference in New Issue
Block a user