Spaces:
Sleeping
Sleeping
github-actions[bot]
sync: upstream b70f787 Merge pull request #84 from huangzt/feature/vue-logs-ui
c6dedd5 | /** | |
| * openai-handler.ts - OpenAI Chat Completions API 兼容处理器 | |
| * | |
| * 将 OpenAI 格式请求转换为内部 Anthropic 格式,复用现有 Cursor 交互管道 | |
| * 支持流式和非流式响应、工具调用、Cursor IDE Agent 模式 | |
| */ | |
| import type { Request, Response } from 'express'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| import type { | |
| OpenAIChatRequest, | |
| OpenAIMessage, | |
| OpenAIChatCompletion, | |
| OpenAIChatCompletionChunk, | |
| OpenAIToolCall, | |
| OpenAIContentPart, | |
| OpenAITool, | |
| } from './openai-types.js'; | |
| import type { | |
| AnthropicRequest, | |
| AnthropicMessage, | |
| AnthropicContentBlock, | |
| AnthropicTool, | |
| CursorChatRequest, | |
| CursorSSEEvent, | |
| } from './types.js'; | |
| 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'; | |
| import { createIncrementalTextStreamer, hasLeadingThinking, splitLeadingThinkingBlocks, stripThinkingTags } from './streaming-text.js'; | |
| import { | |
| autoContinueCursorToolResponseFull, | |
| autoContinueCursorToolResponseStream, | |
| isRefusal, | |
| sanitizeResponse, | |
| isIdentityProbe, | |
| isToolCapabilityQuestion, | |
| buildRetryRequest, | |
| extractThinking, | |
| CLAUDE_IDENTITY_RESPONSE, | |
| CLAUDE_TOOLS_RESPONSE, | |
| MAX_REFUSAL_RETRIES, | |
| estimateInputTokens, | |
| } from './handler.js'; | |
| function chatId(): string { | |
| return 'chatcmpl-' + uuidv4().replace(/-/g, '').substring(0, 24); | |
| } | |
| function toolCallId(): string { | |
| return 'call_' + uuidv4().replace(/-/g, '').substring(0, 24); | |
| } | |
| class OpenAIRequestError extends Error { | |
| status: number; | |
| type: string; | |
| code: string; | |
| constructor(message: string, status = 400, type = 'invalid_request_error', code = 'invalid_request') { | |
| super(message); | |
| this.name = 'OpenAIRequestError'; | |
| this.status = status; | |
| this.type = type; | |
| this.code = code; | |
| } | |
| } | |
| function stringifyUnknownContent(value: unknown): string { | |
| if (value === null || value === undefined) return ''; | |
| if (typeof value === 'string') return value; | |
| if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { | |
| return String(value); | |
| } | |
| try { | |
| return JSON.stringify(value); | |
| } catch { | |
| return String(value); | |
| } | |
| } | |
| function unsupportedImageFileError(fileId?: string): OpenAIRequestError { | |
| const suffix = fileId ? ` (file_id: ${fileId})` : ''; | |
| return new OpenAIRequestError( | |
| `Unsupported content part: image_file${suffix}. This proxy does not support OpenAI Files API image references. Please send the image as image_url, input_image, data URI, or a local file path instead.`, | |
| 400, | |
| 'invalid_request_error', | |
| 'unsupported_content_part' | |
| ); | |
| } | |
| // ==================== 请求转换:OpenAI → Anthropic ==================== | |
| /** | |
| * 将 OpenAI Chat Completions 请求转换为内部 Anthropic 格式 | |
| * 这样可以完全复用现有的 convertToCursorRequest 管道 | |
| */ | |
| function convertToAnthropicRequest(body: OpenAIChatRequest): AnthropicRequest { | |
| const rawMessages: AnthropicMessage[] = []; | |
| let systemPrompt: string | undefined; | |
| // ★ response_format 处理:构建温和的 JSON 格式提示(稍后追加到最后一条用户消息) | |
| let jsonFormatSuffix = ''; | |
| if (body.response_format && body.response_format.type !== 'text') { | |
| jsonFormatSuffix = '\n\nRespond in plain JSON format without markdown wrapping.'; | |
| if (body.response_format.type === 'json_schema' && body.response_format.json_schema?.schema) { | |
| jsonFormatSuffix += ` Schema: ${JSON.stringify(body.response_format.json_schema.schema)}`; | |
| } | |
| } | |
| for (const msg of body.messages) { | |
| switch (msg.role) { | |
| case 'system': | |
| systemPrompt = (systemPrompt ? systemPrompt + '\n\n' : '') + extractOpenAIContent(msg); | |
| break; | |
| case 'user': { | |
| // 检查 content 数组中是否有 tool_result 类型的块(Anthropic 风格) | |
| const contentBlocks = extractOpenAIContentBlocks(msg); | |
| if (Array.isArray(contentBlocks)) { | |
| rawMessages.push({ role: 'user', content: contentBlocks }); | |
| } else { | |
| rawMessages.push({ role: 'user', content: contentBlocks || '' }); | |
| } | |
| break; | |
| } | |
| case 'assistant': { | |
| const blocks: AnthropicContentBlock[] = []; | |
| const contentBlocks = extractOpenAIContentBlocks(msg); | |
| if (typeof contentBlocks === 'string' && contentBlocks) { | |
| blocks.push({ type: 'text', text: contentBlocks }); | |
| } else if (Array.isArray(contentBlocks)) { | |
| blocks.push(...contentBlocks); | |
| } | |
| if (msg.tool_calls && msg.tool_calls.length > 0) { | |
| for (const tc of msg.tool_calls) { | |
| let args: Record<string, unknown> = {}; | |
| try { | |
| args = JSON.parse(tc.function.arguments); | |
| } catch { | |
| args = { input: tc.function.arguments }; | |
| } | |
| blocks.push({ | |
| type: 'tool_use', | |
| id: tc.id, | |
| name: tc.function.name, | |
| input: args, | |
| }); | |
| } | |
| } | |
| rawMessages.push({ | |
| role: 'assistant', | |
| content: blocks.length > 0 ? blocks : (typeof contentBlocks === 'string' ? contentBlocks : ''), | |
| }); | |
| break; | |
| } | |
| case 'tool': { | |
| rawMessages.push({ | |
| role: 'user', | |
| content: [{ | |
| type: 'tool_result', | |
| tool_use_id: msg.tool_call_id, | |
| content: extractOpenAIContent(msg), | |
| }] as AnthropicContentBlock[], | |
| }); | |
| break; | |
| } | |
| } | |
| } | |
| // 合并连续同角色消息(Anthropic API 要求 user/assistant 严格交替) | |
| const messages = mergeConsecutiveRoles(rawMessages); | |
| // ★ response_format: 追加 JSON 格式提示到最后一条 user 消息 | |
| if (jsonFormatSuffix) { | |
| for (let i = messages.length - 1; i >= 0; i--) { | |
| if (messages[i].role === 'user') { | |
| const content = messages[i].content; | |
| if (typeof content === 'string') { | |
| messages[i].content = content + jsonFormatSuffix; | |
| } else if (Array.isArray(content)) { | |
| const lastTextBlock = [...content].reverse().find(b => b.type === 'text'); | |
| if (lastTextBlock && lastTextBlock.text) { | |
| lastTextBlock.text += jsonFormatSuffix; | |
| } else { | |
| content.push({ type: 'text', text: jsonFormatSuffix.trim() }); | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| // 转换工具定义:支持 OpenAI 标准格式和 Cursor 扁平格式 | |
| const tools: AnthropicTool[] | undefined = body.tools?.map((t: OpenAITool | Record<string, unknown>) => { | |
| // Cursor IDE 可能发送扁平格式:{ name, description, input_schema } | |
| if ('function' in t && t.function) { | |
| const fn = (t as OpenAITool).function; | |
| return { | |
| name: fn.name, | |
| description: fn.description, | |
| input_schema: fn.parameters || { type: 'object', properties: {} }, | |
| }; | |
| } | |
| // Cursor 扁平格式 | |
| const flat = t as Record<string, unknown>; | |
| return { | |
| name: (flat.name as string) || '', | |
| description: flat.description as string | undefined, | |
| input_schema: (flat.input_schema as Record<string, unknown>) || { type: 'object', properties: {} }, | |
| }; | |
| }); | |
| return { | |
| model: body.model, | |
| messages, | |
| max_tokens: Math.max(body.max_tokens || body.max_completion_tokens || 8192, 8192), | |
| stream: body.stream, | |
| system: systemPrompt, | |
| tools, | |
| temperature: body.temperature, | |
| top_p: body.top_p, | |
| stop_sequences: body.stop | |
| ? (Array.isArray(body.stop) ? body.stop : [body.stop]) | |
| : undefined, | |
| // ★ Thinking 开关:config.yaml 优先级最高 | |
| // enabled=true: 强制注入 thinking(即使客户端没请求) | |
| // enabled=false: 强制关闭 thinking | |
| // 未配置: 跟随客户端(模型名含 'thinking' 或传了 reasoning_effort 才注入) | |
| ...(() => { | |
| const tc = getConfig().thinking; | |
| if (tc && tc.enabled) return { thinking: { type: 'enabled' as const } }; | |
| if (tc && !tc.enabled) return {}; | |
| // 未配置 → 跟随客户端信号 | |
| const modelHint = body.model?.toLowerCase().includes('thinking'); | |
| const effortHint = !!(body as unknown as Record<string, unknown>).reasoning_effort; | |
| return (modelHint || effortHint) ? { thinking: { type: 'enabled' as const } } : {}; | |
| })(), | |
| }; | |
| } | |
| /** | |
| * 合并连续同角色的消息(Anthropic API 要求角色严格交替) | |
| */ | |
| function mergeConsecutiveRoles(messages: AnthropicMessage[]): AnthropicMessage[] { | |
| if (messages.length <= 1) return messages; | |
| const merged: AnthropicMessage[] = []; | |
| for (const msg of messages) { | |
| const last = merged[merged.length - 1]; | |
| if (last && last.role === msg.role) { | |
| // 合并 content | |
| const lastBlocks = toBlocks(last.content); | |
| const newBlocks = toBlocks(msg.content); | |
| last.content = [...lastBlocks, ...newBlocks]; | |
| } else { | |
| merged.push({ ...msg }); | |
| } | |
| } | |
| return merged; | |
| } | |
| /** | |
| * 将 content 统一转为 AnthropicContentBlock 数组 | |
| */ | |
| function toBlocks(content: string | AnthropicContentBlock[]): AnthropicContentBlock[] { | |
| if (typeof content === 'string') { | |
| return content ? [{ type: 'text', text: content }] : []; | |
| } | |
| return content || []; | |
| } | |
| /** | |
| * 从 OpenAI 消息中提取文本或多模态内容块 | |
| * 处理多种客户端格式: | |
| * - 文本块: { type: 'text'|'input_text', text: '...' } | |
| * - OpenAI 标准: { type: 'image_url', image_url: { url: '...' } } | |
| * - Anthropic 透传: { type: 'image', source: { type: 'url', url: '...' } } | |
| * - 部分客户端: { type: 'input_image', image_url: { url: '...' } } | |
| */ | |
| function extractOpenAIContentBlocks(msg: OpenAIMessage): string | AnthropicContentBlock[] { | |
| if (msg.content === null || msg.content === undefined) return ''; | |
| if (typeof msg.content === 'string') return msg.content; | |
| if (Array.isArray(msg.content)) { | |
| const blocks: AnthropicContentBlock[] = []; | |
| for (const p of msg.content as (OpenAIContentPart | Record<string, unknown>)[]) { | |
| if ((p.type === 'text' || p.type === 'input_text') && (p as OpenAIContentPart).text) { | |
| blocks.push({ type: 'text', text: (p as OpenAIContentPart).text! }); | |
| } else if (p.type === 'image_url' && (p as OpenAIContentPart).image_url?.url) { | |
| const url = (p as OpenAIContentPart).image_url!.url; | |
| if (url.startsWith('data:')) { | |
| const match = url.match(/^data:([^;]+);base64,(.+)$/); | |
| if (match) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'base64', media_type: match[1], data: match[2] } | |
| }); | |
| } | |
| } else { | |
| // HTTP(S)/local URL — 统一存储到 source.data,由 preprocessImages() 下载/读取 | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'url', media_type: 'image/jpeg', data: url } | |
| }); | |
| } | |
| } else if (p.type === 'image' && (p as any).source) { | |
| // ★ Anthropic 格式透传:某些客户端混合发送 OpenAI 和 Anthropic 格式 | |
| const source = (p as any).source; | |
| const imageUrl = source.url || source.data; | |
| if (source.type === 'base64' && source.data) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'base64', media_type: source.media_type || 'image/jpeg', data: source.data } | |
| }); | |
| } else if (imageUrl) { | |
| if (imageUrl.startsWith('data:')) { | |
| const match = imageUrl.match(/^data:([^;]+);base64,(.+)$/); | |
| if (match) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'base64', media_type: match[1], data: match[2] } | |
| }); | |
| } | |
| } else { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'url', media_type: source.media_type || 'image/jpeg', data: imageUrl } | |
| }); | |
| } | |
| } | |
| } else if (p.type === 'input_image' && (p as any).image_url?.url) { | |
| // ★ input_image 类型:部分新版 API 客户端使用 | |
| const url = (p as any).image_url.url; | |
| if (url.startsWith('data:')) { | |
| const match = url.match(/^data:([^;]+);base64,(.+)$/); | |
| if (match) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'base64', media_type: match[1], data: match[2] } | |
| }); | |
| } | |
| } else { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'url', media_type: 'image/jpeg', data: url } | |
| }); | |
| } | |
| } else if (p.type === 'image_file' && (p as any).image_file) { | |
| const fileId = (p as any).image_file.file_id as string | undefined; | |
| console.log(`[OpenAI] ⚠️ 收到不支持的 image_file 格式 (file_id: ${fileId || 'unknown'})`); | |
| throw unsupportedImageFileError(fileId); | |
| } else if ((p.type === 'image_url' || p.type === 'input_image') && (p as any).url) { | |
| // ★ 扁平 URL 格式:某些客户端将 url 直接放在顶层而非 image_url.url | |
| const url = (p as any).url as string; | |
| if (url.startsWith('data:')) { | |
| const match = url.match(/^data:([^;]+);base64,(.+)$/); | |
| if (match) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'base64', media_type: match[1], data: match[2] } | |
| }); | |
| } | |
| } else { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'url', media_type: 'image/jpeg', data: url } | |
| }); | |
| } | |
| } else if (p.type === 'tool_use') { | |
| // Anthropic 风格 tool_use 块直接透传 | |
| blocks.push(p as unknown as AnthropicContentBlock); | |
| } else if (p.type === 'tool_result') { | |
| // Anthropic 风格 tool_result 块直接透传 | |
| blocks.push(p as unknown as AnthropicContentBlock); | |
| } else { | |
| // ★ 通用兜底:检查未知类型的块是否包含可识别的图片数据 | |
| const anyP = p as Record<string, unknown>; | |
| const possibleUrl = (anyP.url || anyP.file_path || anyP.path || | |
| (anyP.image_url as any)?.url || anyP.data) as string | undefined; | |
| if (possibleUrl && typeof possibleUrl === 'string') { | |
| const looksLikeImage = /\.(jpg|jpeg|png|gif|webp|bmp|svg)/i.test(possibleUrl) || | |
| possibleUrl.startsWith('data:image/'); | |
| if (looksLikeImage) { | |
| console.log(`[OpenAI] 🔄 未知内容类型 "${p.type}" 中检测到图片引用 → 转为 image block`); | |
| if (possibleUrl.startsWith('data:')) { | |
| const match = possibleUrl.match(/^data:([^;]+);base64,(.+)$/); | |
| if (match) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'base64', media_type: match[1], data: match[2] } | |
| }); | |
| } | |
| } else { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'url', media_type: 'image/jpeg', data: possibleUrl } | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return blocks.length > 0 ? blocks : ''; | |
| } | |
| return stringifyUnknownContent(msg.content); | |
| } | |
| /** | |
| * 仅提取纯文本(用于系统提示词和旧行为) | |
| */ | |
| function extractOpenAIContent(msg: OpenAIMessage): string { | |
| const blocks = extractOpenAIContentBlocks(msg); | |
| if (typeof blocks === 'string') return blocks; | |
| return blocks.filter(b => b.type === 'text').map(b => b.text).join('\n'); | |
| } | |
| // ==================== 主处理入口 ==================== | |
| export async function handleOpenAIChatCompletions(req: Request, res: Response): Promise<void> { | |
| const body = req.body as OpenAIChatRequest; | |
| 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, | |
| }); | |
| // ★ 图片诊断日志:记录每条消息中的 content 格式,帮助定位客户端发送格式 | |
| if (body.messages) { | |
| for (let i = 0; i < body.messages.length; i++) { | |
| const msg = body.messages[i]; | |
| if (typeof msg.content === 'string') { | |
| // 检查字符串中是否包含图片路径特征 | |
| if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)/i.test(msg.content)) { | |
| console.log(`[OpenAI] 📋 消息[${i}] role=${msg.role} content=字符串(${msg.content.length}chars) ⚠️ 包含图片后缀: ${msg.content.substring(0, 200)}`); | |
| } | |
| } else if (Array.isArray(msg.content)) { | |
| const types = (msg.content as any[]).map(p => { | |
| if (p.type === 'image_url') return `image_url(${(p.image_url?.url || p.url || '?').substring(0, 60)})`; | |
| if (p.type === 'image') return `image(${p.source?.type || '?'})`; | |
| if (p.type === 'input_image') return `input_image`; | |
| if (p.type === 'image_file') return `image_file`; | |
| return p.type; | |
| }); | |
| if (types.some(t => t !== 'text')) { | |
| console.log(`[OpenAI] 📋 消息[${i}] role=${msg.role} blocks: [${types.join(', ')}]`); | |
| } | |
| } | |
| } | |
| } | |
| 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)) { | |
| 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); | |
| } else { | |
| return handleOpenAIMockNonStream(res, body, mockText); | |
| } | |
| } | |
| // Step 2: Anthropic → Cursor 格式(复用现有管道) | |
| const cursorReq = await convertToCursorRequest(anthropicReq); | |
| log.recordCursorRequest(cursorReq); | |
| if (body.stream) { | |
| await handleOpenAIStream(res, cursorReq, body, anthropicReq, log); | |
| } else { | |
| await handleOpenAINonStream(res, cursorReq, body, anthropicReq, log); | |
| } | |
| } catch (err: unknown) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| log.fail(message); | |
| const status = err instanceof OpenAIRequestError ? err.status : 500; | |
| const type = err instanceof OpenAIRequestError ? err.type : 'server_error'; | |
| const code = err instanceof OpenAIRequestError ? err.code : 'internal_error'; | |
| res.status(status).json({ | |
| error: { | |
| message, | |
| type, | |
| code, | |
| }, | |
| }); | |
| } | |
| } | |
| // ==================== 身份探针模拟响应 ==================== | |
| function handleOpenAIMockStream(res: Response, body: OpenAIChatRequest, mockText: string): void { | |
| res.writeHead(200, { | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| 'Connection': 'keep-alive', | |
| 'X-Accel-Buffering': 'no', | |
| }); | |
| const id = chatId(); | |
| const created = Math.floor(Date.now() / 1000); | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model: body.model, | |
| choices: [{ index: 0, delta: { role: 'assistant', content: mockText }, finish_reason: null }], | |
| }); | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model: body.model, | |
| choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], | |
| }); | |
| res.write('data: [DONE]\n\n'); | |
| res.end(); | |
| } | |
| function handleOpenAIMockNonStream(res: Response, body: OpenAIChatRequest, mockText: string): void { | |
| res.json({ | |
| id: chatId(), | |
| object: 'chat.completion', | |
| created: Math.floor(Date.now() / 1000), | |
| model: body.model, | |
| choices: [{ | |
| index: 0, | |
| message: { role: 'assistant', content: mockText }, | |
| finish_reason: 'stop', | |
| }], | |
| usage: { prompt_tokens: 15, completion_tokens: 35, total_tokens: 50 }, | |
| }); | |
| } | |
| function writeOpenAITextDelta( | |
| res: Response, | |
| id: string, | |
| created: number, | |
| model: string, | |
| text: string, | |
| ): void { | |
| if (!text) return; | |
| writeOpenAISSE(res, { | |
| id, | |
| object: 'chat.completion.chunk', | |
| created, | |
| model, | |
| choices: [{ | |
| index: 0, | |
| delta: { content: text }, | |
| finish_reason: null, | |
| }], | |
| }); | |
| } | |
| function buildOpenAIUsage( | |
| anthropicReq: AnthropicRequest, | |
| outputText: string, | |
| ): { prompt_tokens: number; completion_tokens: number; total_tokens: number } { | |
| const promptTokens = estimateInputTokens(anthropicReq); | |
| const completionTokens = Math.ceil(outputText.length / 3); | |
| return { | |
| prompt_tokens: promptTokens, | |
| completion_tokens: completionTokens, | |
| total_tokens: promptTokens + completionTokens, | |
| }; | |
| } | |
| function writeOpenAIReasoningDelta( | |
| res: Response, | |
| id: string, | |
| created: number, | |
| model: string, | |
| reasoningContent: string, | |
| ): void { | |
| if (!reasoningContent) return; | |
| writeOpenAISSE(res, { | |
| id, | |
| object: 'chat.completion.chunk', | |
| created, | |
| model, | |
| choices: [{ | |
| index: 0, | |
| delta: { reasoning_content: reasoningContent } as Record<string, unknown>, | |
| finish_reason: null, | |
| }], | |
| }); | |
| } | |
| async function handleOpenAIIncrementalTextStream( | |
| res: Response, | |
| cursorReq: CursorChatRequest, | |
| body: OpenAIChatRequest, | |
| anthropicReq: AnthropicRequest, | |
| streamMeta: { id: string; created: number; model: string }, | |
| log: RequestLogger, | |
| ): Promise<void> { | |
| let activeCursorReq = cursorReq; | |
| let retryCount = 0; | |
| const thinkingEnabled = anthropicReq.thinking?.type === 'enabled'; | |
| let finalRawResponse = ''; | |
| let finalVisibleText = ''; | |
| let finalReasoningContent = ''; | |
| let streamer = createIncrementalTextStreamer({ | |
| transform: sanitizeResponse, | |
| isBlockedPrefix: (text) => isRefusal(text.substring(0, 300)), | |
| }); | |
| let reasoningSent = false; | |
| const executeAttempt = async (): Promise<{ | |
| rawResponse: string; | |
| visibleText: string; | |
| reasoningContent: string; | |
| streamer: ReturnType<typeof createIncrementalTextStreamer>; | |
| }> => { | |
| let rawResponse = ''; | |
| let visibleText = ''; | |
| let leadingBuffer = ''; | |
| let leadingResolved = false; | |
| let reasoningContent = ''; | |
| const attemptStreamer = createIncrementalTextStreamer({ | |
| transform: sanitizeResponse, | |
| isBlockedPrefix: (text) => isRefusal(text.substring(0, 300)), | |
| }); | |
| const flushVisible = (chunk: string): void => { | |
| if (!chunk) return; | |
| visibleText += chunk; | |
| const delta = attemptStreamer.push(chunk); | |
| if (!delta) return; | |
| if (thinkingEnabled && reasoningContent && !reasoningSent) { | |
| writeOpenAIReasoningDelta(res, streamMeta.id, streamMeta.created, streamMeta.model, reasoningContent); | |
| reasoningSent = true; | |
| } | |
| writeOpenAITextDelta(res, streamMeta.id, streamMeta.created, streamMeta.model, delta); | |
| }; | |
| await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => { | |
| if (event.type !== 'text-delta' || !event.delta) return; | |
| rawResponse += event.delta; | |
| if (!leadingResolved) { | |
| leadingBuffer += event.delta; | |
| const split = splitLeadingThinkingBlocks(leadingBuffer); | |
| if (split.startedWithThinking) { | |
| if (!split.complete) return; | |
| reasoningContent = split.thinkingContent; | |
| leadingResolved = true; | |
| leadingBuffer = ''; | |
| flushVisible(split.remainder); | |
| return; | |
| } | |
| leadingResolved = true; | |
| const buffered = leadingBuffer; | |
| leadingBuffer = ''; | |
| flushVisible(buffered); | |
| return; | |
| } | |
| flushVisible(event.delta); | |
| }); | |
| return { | |
| rawResponse, | |
| visibleText, | |
| reasoningContent, | |
| streamer: attemptStreamer, | |
| }; | |
| }; | |
| while (true) { | |
| const attempt = await executeAttempt(); | |
| finalRawResponse = attempt.rawResponse; | |
| finalVisibleText = attempt.visibleText; | |
| finalReasoningContent = attempt.reasoningContent; | |
| streamer = attempt.streamer; | |
| const textForRefusalCheck = finalVisibleText; | |
| if (!streamer.hasSentText() && isRefusal(textForRefusalCheck) && retryCount < MAX_REFUSAL_RETRIES) { | |
| retryCount++; | |
| const retryBody = buildRetryRequest(anthropicReq, retryCount - 1); | |
| activeCursorReq = await convertToCursorRequest(retryBody); | |
| reasoningSent = false; | |
| continue; | |
| } | |
| break; | |
| } | |
| const refusalText = finalVisibleText; | |
| const usedFallback = !streamer.hasSentText() && isRefusal(refusalText); | |
| let finalTextToSend: string; | |
| if (usedFallback) { | |
| finalTextToSend = isToolCapabilityQuestion(anthropicReq) | |
| ? CLAUDE_TOOLS_RESPONSE | |
| : CLAUDE_IDENTITY_RESPONSE; | |
| } else { | |
| finalTextToSend = streamer.finish(); | |
| } | |
| if (!usedFallback && thinkingEnabled && finalReasoningContent && !reasoningSent) { | |
| writeOpenAIReasoningDelta(res, streamMeta.id, streamMeta.created, streamMeta.model, finalReasoningContent); | |
| reasoningSent = true; | |
| } | |
| writeOpenAITextDelta(res, streamMeta.id, streamMeta.created, streamMeta.model, finalTextToSend); | |
| writeOpenAISSE(res, { | |
| id: streamMeta.id, | |
| object: 'chat.completion.chunk', | |
| created: streamMeta.created, | |
| model: streamMeta.model, | |
| choices: [{ | |
| index: 0, | |
| delta: {}, | |
| finish_reason: 'stop', | |
| }], | |
| 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(); | |
| } | |
| // ==================== 流式处理(OpenAI SSE 格式) ==================== | |
| async function handleOpenAIStream( | |
| res: Response, | |
| cursorReq: CursorChatRequest, | |
| body: OpenAIChatRequest, | |
| anthropicReq: AnthropicRequest, | |
| log: RequestLogger, | |
| ): Promise<void> { | |
| res.writeHead(200, { | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| 'Connection': 'keep-alive', | |
| 'X-Accel-Buffering': 'no', | |
| }); | |
| const id = chatId(); | |
| const created = Math.floor(Date.now() / 1000); | |
| const model = body.model; | |
| const hasTools = (body.tools?.length ?? 0) > 0; | |
| // 发送 role delta | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: { role: 'assistant', content: '' }, | |
| finish_reason: null, | |
| }], | |
| }); | |
| let fullResponse = ''; | |
| let sentText = ''; | |
| let activeCursorReq = cursorReq; | |
| let retryCount = 0; | |
| // 统一缓冲模式:先缓冲全部响应,再检测拒绝和处理 | |
| const executeStream = async (onTextDelta?: (delta: string) => void) => { | |
| fullResponse = ''; | |
| await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => { | |
| if (event.type !== 'text-delta' || !event.delta) return; | |
| fullResponse += event.delta; | |
| onTextDelta?.(event.delta); | |
| }); | |
| }; | |
| try { | |
| if (!hasTools && (!body.response_format || body.response_format.type === 'text')) { | |
| await handleOpenAIIncrementalTextStream(res, cursorReq, body, anthropicReq, { id, created, model }, log); | |
| return; | |
| } | |
| // ★ 混合流式:文本增量 + 工具缓冲(与 Anthropic handler 同一设计) | |
| const thinkingEnabled = anthropicReq.thinking?.type === 'enabled'; | |
| const hybridStreamer = createIncrementalTextStreamer({ | |
| warmupChars: 300, // ★ 与拒绝检测窗口对齐 | |
| transform: sanitizeResponse, | |
| isBlockedPrefix: (text) => isRefusal(text.substring(0, 300)), | |
| }); | |
| let toolMarkerDetected = false; | |
| let pendingText = ''; | |
| let hybridThinkingContent = ''; | |
| let hybridLeadingBuffer = ''; | |
| let hybridLeadingResolved = false; | |
| const TOOL_MARKER = '```json action'; | |
| const MARKER_LOOKBACK = TOOL_MARKER.length + 2; | |
| let hybridTextSent = false; | |
| let hybridReasoningSent = false; | |
| const pushToStreamer = (text: string): void => { | |
| if (!text || toolMarkerDetected) return; | |
| pendingText += text; | |
| const idx = pendingText.indexOf(TOOL_MARKER); | |
| if (idx >= 0) { | |
| const before = pendingText.substring(0, idx); | |
| if (before) { | |
| const d = hybridStreamer.push(before); | |
| if (d) { | |
| if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) { | |
| writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent); | |
| hybridReasoningSent = true; | |
| } | |
| writeOpenAITextDelta(res, id, created, model, d); | |
| hybridTextSent = true; | |
| } | |
| } | |
| toolMarkerDetected = true; | |
| pendingText = ''; | |
| return; | |
| } | |
| const safeEnd = pendingText.length - MARKER_LOOKBACK; | |
| if (safeEnd > 0) { | |
| const safe = pendingText.substring(0, safeEnd); | |
| pendingText = pendingText.substring(safeEnd); | |
| const d = hybridStreamer.push(safe); | |
| if (d) { | |
| if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) { | |
| writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent); | |
| hybridReasoningSent = true; | |
| } | |
| writeOpenAITextDelta(res, id, created, model, d); | |
| hybridTextSent = true; | |
| } | |
| } | |
| }; | |
| const processHybridDelta = (delta: string): void => { | |
| if (!hybridLeadingResolved) { | |
| hybridLeadingBuffer += delta; | |
| const split = splitLeadingThinkingBlocks(hybridLeadingBuffer); | |
| if (split.startedWithThinking) { | |
| if (!split.complete) return; | |
| hybridThinkingContent = split.thinkingContent; | |
| hybridLeadingResolved = true; | |
| hybridLeadingBuffer = ''; | |
| pushToStreamer(split.remainder); | |
| return; | |
| } | |
| if (hybridLeadingBuffer.trimStart().length < 10) return; | |
| hybridLeadingResolved = true; | |
| const buffered = hybridLeadingBuffer; | |
| hybridLeadingBuffer = ''; | |
| pushToStreamer(buffered); | |
| return; | |
| } | |
| pushToStreamer(delta); | |
| }; | |
| await executeStream(processHybridDelta); | |
| // flush 残留缓冲 | |
| if (!hybridLeadingResolved && hybridLeadingBuffer) { | |
| hybridLeadingResolved = true; | |
| const split = splitLeadingThinkingBlocks(hybridLeadingBuffer); | |
| if (split.startedWithThinking && split.complete) { | |
| hybridThinkingContent = split.thinkingContent; | |
| pushToStreamer(split.remainder); | |
| } else { | |
| pushToStreamer(hybridLeadingBuffer); | |
| } | |
| } | |
| if (pendingText && !toolMarkerDetected) { | |
| const d = hybridStreamer.push(pendingText); | |
| if (d) { | |
| if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) { | |
| writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent); | |
| hybridReasoningSent = true; | |
| } | |
| writeOpenAITextDelta(res, id, created, model, d); | |
| hybridTextSent = true; | |
| } | |
| pendingText = ''; | |
| } | |
| const hybridRemaining = hybridStreamer.finish(); | |
| if (hybridRemaining) { | |
| if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) { | |
| writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent); | |
| hybridReasoningSent = true; | |
| } | |
| writeOpenAITextDelta(res, id, created, model, hybridRemaining); | |
| hybridTextSent = true; | |
| } | |
| // ★ Thinking 提取(在拒绝检测之前) | |
| let reasoningContent: string | undefined = hybridThinkingContent || undefined; | |
| if (hasLeadingThinking(fullResponse)) { | |
| const { thinkingContent: extracted, strippedText } = extractThinking(fullResponse); | |
| if (extracted) { | |
| if (thinkingEnabled && !reasoningContent) { | |
| reasoningContent = extracted; | |
| } | |
| fullResponse = strippedText; | |
| } | |
| } | |
| // 拒绝检测 + 自动重试 | |
| const shouldRetryRefusal = () => { | |
| if (hybridTextSent) return false; // 已发文字,不可重试 | |
| if (!isRefusal(fullResponse)) return false; | |
| if (hasTools && hasToolCalls(fullResponse)) return false; | |
| return true; | |
| }; | |
| while (shouldRetryRefusal() && retryCount < MAX_REFUSAL_RETRIES) { | |
| retryCount++; | |
| const retryBody = buildRetryRequest(anthropicReq, retryCount - 1); | |
| activeCursorReq = await convertToCursorRequest(retryBody); | |
| await executeStream(); // 重试不传回调 | |
| } | |
| if (shouldRetryRefusal()) { | |
| if (!hasTools) { | |
| if (isToolCapabilityQuestion(anthropicReq)) { | |
| fullResponse = CLAUDE_TOOLS_RESPONSE; | |
| } else { | |
| fullResponse = CLAUDE_IDENTITY_RESPONSE; | |
| } | |
| } else { | |
| fullResponse = 'I understand the request. Let me analyze the information and proceed with the appropriate action.'; | |
| } | |
| } | |
| // 极短响应重试 | |
| if (hasTools && fullResponse.trim().length < 10 && retryCount < MAX_REFUSAL_RETRIES) { | |
| retryCount++; | |
| activeCursorReq = await convertToCursorRequest(anthropicReq); | |
| await executeStream(); | |
| } | |
| if (hasTools) { | |
| fullResponse = await autoContinueCursorToolResponseStream(activeCursorReq, fullResponse, hasTools); | |
| } | |
| let finishReason: 'stop' | 'tool_calls' = 'stop'; | |
| // ★ 发送 reasoning_content(仅在混合流式未发送时) | |
| if (reasoningContent && !hybridReasoningSent) { | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: { reasoning_content: reasoningContent } as Record<string, unknown>, | |
| finish_reason: null, | |
| }], | |
| }); | |
| } | |
| if (hasTools && hasToolCalls(fullResponse)) { | |
| const { toolCalls, cleanText } = parseToolCalls(fullResponse); | |
| if (toolCalls.length > 0) { | |
| finishReason = 'tool_calls'; | |
| log.recordToolCalls(toolCalls); | |
| log.updateSummary({ toolCallsDetected: toolCalls.length }); | |
| // 发送工具调用前的残余文本 — 如果混合流式已发送则跳过 | |
| if (!hybridTextSent) { | |
| let cleanOutput = isRefusal(cleanText) ? '' : cleanText; | |
| cleanOutput = sanitizeResponse(cleanOutput); | |
| if (cleanOutput) { | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: { content: cleanOutput }, | |
| finish_reason: null, | |
| }], | |
| }); | |
| } | |
| } | |
| // 增量流式发送工具调用:先发 name+id,再分块发 arguments | |
| for (let i = 0; i < toolCalls.length; i++) { | |
| const tc = toolCalls[i]; | |
| const tcId = toolCallId(); | |
| const argsStr = JSON.stringify(tc.arguments); | |
| // 第一帧:发送 name + id, arguments 为空 | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: { | |
| ...(i === 0 ? { content: null } : {}), | |
| tool_calls: [{ | |
| index: i, | |
| id: tcId, | |
| type: 'function', | |
| function: { name: tc.name, arguments: '' }, | |
| }], | |
| }, | |
| finish_reason: null, | |
| }], | |
| }); | |
| // 后续帧:分块发送 arguments (128 字节/帧) | |
| const CHUNK_SIZE = 128; | |
| for (let j = 0; j < argsStr.length; j += CHUNK_SIZE) { | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: { | |
| tool_calls: [{ | |
| index: i, | |
| function: { arguments: argsStr.slice(j, j + CHUNK_SIZE) }, | |
| }], | |
| }, | |
| finish_reason: null, | |
| }], | |
| }); | |
| } | |
| } | |
| } else { | |
| // 误报:发送清洗后的文本(如果混合流式未发送) | |
| if (!hybridTextSent) { | |
| let textToSend = fullResponse; | |
| if (isRefusal(fullResponse)) { | |
| textToSend = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?'; | |
| } else { | |
| textToSend = sanitizeResponse(fullResponse); | |
| } | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: { content: textToSend }, | |
| finish_reason: null, | |
| }], | |
| }); | |
| } | |
| } | |
| } else { | |
| // 无工具模式或无工具调用 — 如果混合流式未发送则统一清洗后发送 | |
| if (!hybridTextSent) { | |
| let sanitized = sanitizeResponse(fullResponse); | |
| // ★ response_format 后处理:剥离 markdown 代码块包裹 | |
| if (body.response_format && body.response_format.type !== 'text') { | |
| sanitized = stripMarkdownJsonWrapper(sanitized); | |
| } | |
| if (sanitized) { | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: { content: sanitized }, | |
| finish_reason: null, | |
| }], | |
| }); | |
| } | |
| } | |
| } | |
| // 发送完成 chunk(带 usage,兼容依赖最终 usage 帧的 OpenAI 客户端/代理) | |
| writeOpenAISSE(res, { | |
| id, object: 'chat.completion.chunk', created, model, | |
| choices: [{ | |
| index: 0, | |
| delta: {}, | |
| finish_reason: finishReason, | |
| }], | |
| 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: [{ | |
| index: 0, | |
| delta: { content: `\n\n[Error: ${message}]` }, | |
| finish_reason: 'stop', | |
| }], | |
| }); | |
| res.write('data: [DONE]\n\n'); | |
| } | |
| res.end(); | |
| } | |
| // ==================== 非流式处理 ==================== | |
| async function handleOpenAINonStream( | |
| res: Response, | |
| cursorReq: CursorChatRequest, | |
| body: OpenAIChatRequest, | |
| anthropicReq: AnthropicRequest, | |
| log: RequestLogger, | |
| ): Promise<void> { | |
| let activeCursorReq = cursorReq; | |
| let fullText = await sendCursorRequestFull(activeCursorReq); | |
| const hasTools = (body.tools?.length ?? 0) > 0; | |
| // 日志记录在详细日志中 | |
| // ★ Thinking 提取必须在拒绝检测之前 — 否则 thinking 内容中的关键词会触发 isRefusal 误判 | |
| const thinkingEnabled = anthropicReq.thinking?.type === 'enabled'; | |
| let reasoningContent: string | undefined; | |
| if (hasLeadingThinking(fullText)) { | |
| const { thinkingContent: extracted, strippedText } = extractThinking(fullText); | |
| if (extracted) { | |
| if (thinkingEnabled) { | |
| reasoningContent = extracted; | |
| } | |
| // thinking 剥离记录 | |
| fullText = strippedText; | |
| } | |
| } | |
| // 拒绝检测 + 自动重试(在 thinking 提取之后,只检测实际输出内容) | |
| const shouldRetry = () => isRefusal(fullText) && !(hasTools && hasToolCalls(fullText)); | |
| if (shouldRetry()) { | |
| for (let attempt = 0; attempt < MAX_REFUSAL_RETRIES; attempt++) { | |
| // 重试记录 | |
| const retryBody = buildRetryRequest(anthropicReq, attempt); | |
| const retryCursorReq = await convertToCursorRequest(retryBody); | |
| activeCursorReq = retryCursorReq; | |
| fullText = await sendCursorRequestFull(activeCursorReq); | |
| // 重试响应也需要先剥离 thinking | |
| if (hasLeadingThinking(fullText)) { | |
| fullText = extractThinking(fullText).strippedText; | |
| } | |
| if (!shouldRetry()) break; | |
| } | |
| if (shouldRetry()) { | |
| if (hasTools) { | |
| // 记录在详细日志 | |
| fullText = 'I understand the request. Let me analyze the information and proceed with the appropriate action.'; | |
| } else if (isToolCapabilityQuestion(anthropicReq)) { | |
| // 记录在详细日志 | |
| fullText = CLAUDE_TOOLS_RESPONSE; | |
| } else { | |
| // 记录在详细日志 | |
| fullText = CLAUDE_IDENTITY_RESPONSE; | |
| } | |
| } | |
| } | |
| if (hasTools) { | |
| fullText = await autoContinueCursorToolResponseFull(activeCursorReq, fullText, hasTools); | |
| } | |
| let content: string | null = fullText; | |
| let toolCalls: OpenAIToolCall[] | undefined; | |
| let finishReason: 'stop' | 'tool_calls' = 'stop'; | |
| if (hasTools) { | |
| const parsed = parseToolCalls(fullText); | |
| 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)) { | |
| // 记录在详细日志 | |
| cleanText = ''; | |
| } | |
| content = sanitizeResponse(cleanText) || null; | |
| toolCalls = parsed.toolCalls.map(tc => ({ | |
| id: toolCallId(), | |
| type: 'function' as const, | |
| function: { | |
| name: tc.name, | |
| arguments: JSON.stringify(tc.arguments), | |
| }, | |
| })); | |
| } else { | |
| // 无工具调用,检查拒绝 | |
| if (isRefusal(fullText)) { | |
| content = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?'; | |
| } else { | |
| content = sanitizeResponse(fullText); | |
| } | |
| } | |
| } else { | |
| // 无工具模式:清洗响应 | |
| content = sanitizeResponse(fullText); | |
| // ★ response_format 后处理:剥离 markdown 代码块包裹 | |
| if (body.response_format && body.response_format.type !== 'text' && content) { | |
| content = stripMarkdownJsonWrapper(content); | |
| } | |
| } | |
| const response: OpenAIChatCompletion = { | |
| id: chatId(), | |
| object: 'chat.completion', | |
| created: Math.floor(Date.now() / 1000), | |
| model: body.model, | |
| choices: [{ | |
| index: 0, | |
| message: { | |
| role: 'assistant', | |
| content, | |
| ...(toolCalls ? { tool_calls: toolCalls } : {}), | |
| ...(reasoningContent ? { reasoning_content: reasoningContent } as Record<string, unknown> : {}), | |
| }, | |
| finish_reason: finishReason, | |
| }], | |
| usage: buildOpenAIUsage(anthropicReq, fullText), | |
| }; | |
| res.json(response); | |
| log.recordRawResponse(fullText); | |
| if (reasoningContent) { | |
| log.recordThinking(reasoningContent); | |
| } | |
| log.recordFinalResponse(fullText); | |
| log.complete(fullText.length, finishReason); | |
| } | |
| // ==================== 工具函数 ==================== | |
| /** | |
| * 剥离 Markdown 代码块包裹,返回裸 JSON 字符串 | |
| * 处理 ```json\n...\n``` 和 ```\n...\n``` 两种格式 | |
| */ | |
| function stripMarkdownJsonWrapper(text: string): string { | |
| if (!text) return text; | |
| const trimmed = text.trim(); | |
| const match = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n\s*```$/); | |
| if (match) { | |
| return match[1].trim(); | |
| } | |
| return text; | |
| } | |
| function writeOpenAISSE(res: Response, data: OpenAIChatCompletionChunk): void { | |
| res.write(`data: ${JSON.stringify(data)}\n\n`); | |
| if (typeof (res as unknown as { flush: () => void }).flush === 'function') { | |
| (res as unknown as { flush: () => void }).flush(); | |
| } | |
| } | |
| // ==================== /v1/responses 支持 ==================== | |
| /** | |
| * 写入 Responses API SSE 事件 | |
| * 格式:event: {eventType}\ndata: {json}\n\n | |
| * 注意:与 Chat Completions 的 "data: {json}\n\n" 不同,Responses API 需要 event: 前缀 | |
| */ | |
| function writeResponsesSSE(res: Response, eventType: string, data: Record<string, unknown>): void { | |
| res.write(`event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`); | |
| if (typeof (res as unknown as { flush: () => void }).flush === 'function') { | |
| (res as unknown as { flush: () => void }).flush(); | |
| } | |
| } | |
| function responsesId(): string { | |
| return 'resp_' + uuidv4().replace(/-/g, '').substring(0, 24); | |
| } | |
| function responsesItemId(): string { | |
| return 'item_' + uuidv4().replace(/-/g, '').substring(0, 24); | |
| } | |
| /** | |
| * 构建 Responses API 的 response 对象骨架 | |
| */ | |
| function buildResponseObject( | |
| id: string, | |
| model: string, | |
| status: 'in_progress' | 'completed', | |
| output: Record<string, unknown>[], | |
| usage?: { input_tokens: number; output_tokens: number; total_tokens: number }, | |
| ): Record<string, unknown> { | |
| return { | |
| id, | |
| object: 'response', | |
| created_at: Math.floor(Date.now() / 1000), | |
| status, | |
| model, | |
| output, | |
| ...(usage ? { usage } : {}), | |
| }; | |
| } | |
| /** | |
| * 处理 OpenAI Codex / Responses API 的 /v1/responses 请求 | |
| * | |
| * ★ 关键差异:Responses API 的流式格式与 Chat Completions 完全不同 | |
| * Codex 期望接收 event: response.created / response.output_text.delta / response.completed 等事件 | |
| * 而非 data: {"object":"chat.completion.chunk",...} 格式 | |
| */ | |
| export async function handleOpenAIResponses(req: Request, res: Response): Promise<void> { | |
| 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 | |
| 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); | |
| } else { | |
| return handleResponsesNonStreamMock(res, body, mockText); | |
| } | |
| } | |
| if (isStream) { | |
| await handleResponsesStream(res, cursorReq, body, anthropicReq, log); | |
| } else { | |
| 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'; | |
| const code = err instanceof OpenAIRequestError ? err.code : 'internal_error'; | |
| res.status(status).json({ | |
| error: { message, type, code }, | |
| }); | |
| } | |
| } | |
| /** | |
| * 模拟身份响应 — 流式 (Responses API SSE 格式) | |
| */ | |
| function handleResponsesStreamMock(res: Response, body: Record<string, unknown>, mockText: string): void { | |
| res.writeHead(200, { | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| 'Connection': 'keep-alive', | |
| 'X-Accel-Buffering': 'no', | |
| }); | |
| const respId = responsesId(); | |
| const itemId = responsesItemId(); | |
| const model = (body.model as string) || 'gpt-4'; | |
| emitResponsesTextStream(res, respId, itemId, model, mockText, 0, { input_tokens: 15, output_tokens: 35, total_tokens: 50 }); | |
| res.end(); | |
| } | |
| /** | |
| * 模拟身份响应 — 非流式 (Responses API JSON 格式) | |
| */ | |
| function handleResponsesNonStreamMock(res: Response, body: Record<string, unknown>, mockText: string): void { | |
| const respId = responsesId(); | |
| const itemId = responsesItemId(); | |
| const model = (body.model as string) || 'gpt-4'; | |
| res.json(buildResponseObject(respId, model, 'completed', [{ | |
| id: itemId, | |
| type: 'message', | |
| role: 'assistant', | |
| status: 'completed', | |
| content: [{ type: 'output_text', text: mockText, annotations: [] }], | |
| }], { input_tokens: 15, output_tokens: 35, total_tokens: 50 })); | |
| } | |
| /** | |
| * 发射完整的 Responses API 文本流事件序列 | |
| * 包含从 response.created 到 response.completed 的完整生命周期 | |
| */ | |
| function emitResponsesTextStream( | |
| res: Response, | |
| respId: string, | |
| itemId: string, | |
| model: string, | |
| fullText: string, | |
| outputIndex: number, | |
| usage: { input_tokens: number; output_tokens: number; total_tokens: number }, | |
| toolCallItems?: Record<string, unknown>[], | |
| ): void { | |
| // 所有输出项(文本 + 工具调用) | |
| const messageItem: Record<string, unknown> = { | |
| id: itemId, | |
| type: 'message', | |
| role: 'assistant', | |
| status: 'completed', | |
| content: [{ type: 'output_text', text: fullText, annotations: [] }], | |
| }; | |
| const allOutputItems = toolCallItems ? [...toolCallItems, messageItem] : [messageItem]; | |
| // 1. response.created | |
| writeResponsesSSE(res, 'response.created', buildResponseObject(respId, model, 'in_progress', [])); | |
| // 2. response.in_progress | |
| writeResponsesSSE(res, 'response.in_progress', buildResponseObject(respId, model, 'in_progress', [])); | |
| // 3. 文本 output item | |
| writeResponsesSSE(res, 'response.output_item.added', { | |
| output_index: outputIndex, | |
| item: { | |
| id: itemId, | |
| type: 'message', | |
| role: 'assistant', | |
| status: 'in_progress', | |
| content: [], | |
| }, | |
| }); | |
| // 4. content part | |
| writeResponsesSSE(res, 'response.content_part.added', { | |
| output_index: outputIndex, | |
| content_index: 0, | |
| part: { type: 'output_text', text: '', annotations: [] }, | |
| }); | |
| // 5. 文本增量 | |
| if (fullText) { | |
| // 分块发送,模拟流式体验 (每块约 100 字符) | |
| const CHUNK_SIZE = 100; | |
| for (let i = 0; i < fullText.length; i += CHUNK_SIZE) { | |
| writeResponsesSSE(res, 'response.output_text.delta', { | |
| output_index: outputIndex, | |
| content_index: 0, | |
| delta: fullText.slice(i, i + CHUNK_SIZE), | |
| }); | |
| } | |
| } | |
| // 6. response.output_text.done | |
| writeResponsesSSE(res, 'response.output_text.done', { | |
| output_index: outputIndex, | |
| content_index: 0, | |
| text: fullText, | |
| }); | |
| // 7. response.content_part.done | |
| writeResponsesSSE(res, 'response.content_part.done', { | |
| output_index: outputIndex, | |
| content_index: 0, | |
| part: { type: 'output_text', text: fullText, annotations: [] }, | |
| }); | |
| // 8. response.output_item.done (message) | |
| writeResponsesSSE(res, 'response.output_item.done', { | |
| output_index: outputIndex, | |
| item: messageItem, | |
| }); | |
| // 9. response.completed — ★ 这是 Codex 等待的关键事件 | |
| writeResponsesSSE(res, 'response.completed', buildResponseObject(respId, model, 'completed', allOutputItems, usage)); | |
| } | |
| /** | |
| * Responses API 流式处理 | |
| * | |
| * ★ 与 Chat Completions 流式的核心区别: | |
| * 1. 使用 event: 前缀的 SSE 事件(不是 data-only) | |
| * 2. 必须发送 response.completed 事件,否则 Codex 报 "stream closed before response.completed" | |
| * 3. 工具调用用 function_call 类型的 output item 表示 | |
| */ | |
| async function handleResponsesStream( | |
| res: Response, | |
| cursorReq: CursorChatRequest, | |
| body: Record<string, unknown>, | |
| anthropicReq: AnthropicRequest, | |
| log: RequestLogger, | |
| ): Promise<void> { | |
| res.writeHead(200, { | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| 'Connection': 'keep-alive', | |
| 'X-Accel-Buffering': 'no', | |
| }); | |
| 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 = ''; | |
| let activeCursorReq = cursorReq; | |
| let retryCount = 0; | |
| // ★ 流式保活:防止网关 504 | |
| const keepaliveInterval = setInterval(() => { | |
| try { | |
| res.write(': keepalive\n\n'); | |
| if (typeof (res as unknown as { flush: () => void }).flush === 'function') { | |
| (res as unknown as { flush: () => void }).flush(); | |
| } | |
| } catch { /* connection already closed */ } | |
| }, 15000); | |
| try { | |
| const executeStream = async () => { | |
| fullResponse = ''; | |
| await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => { | |
| if (event.type !== 'text-delta' || !event.delta) return; | |
| fullResponse += event.delta; | |
| }); | |
| }; | |
| await executeStream(); | |
| // Thinking 提取 | |
| if (hasLeadingThinking(fullResponse)) { | |
| const { strippedText } = extractThinking(fullResponse); | |
| fullResponse = strippedText; | |
| } | |
| // 拒绝检测 + 自动重试 | |
| const shouldRetryRefusal = () => { | |
| if (!isRefusal(fullResponse)) return false; | |
| if (hasTools && hasToolCalls(fullResponse)) return false; | |
| return true; | |
| }; | |
| while (shouldRetryRefusal() && retryCount < MAX_REFUSAL_RETRIES) { | |
| retryCount++; | |
| const retryBody = buildRetryRequest(anthropicReq, retryCount - 1); | |
| activeCursorReq = await convertToCursorRequest(retryBody); | |
| await executeStream(); | |
| if (hasLeadingThinking(fullResponse)) { | |
| fullResponse = extractThinking(fullResponse).strippedText; | |
| } | |
| } | |
| if (shouldRetryRefusal()) { | |
| if (isToolCapabilityQuestion(anthropicReq)) { | |
| fullResponse = CLAUDE_TOOLS_RESPONSE; | |
| } else { | |
| fullResponse = CLAUDE_IDENTITY_RESPONSE; | |
| } | |
| } | |
| if (hasTools) { | |
| fullResponse = await autoContinueCursorToolResponseStream(activeCursorReq, fullResponse, hasTools); | |
| } | |
| // 清洗响应 | |
| fullResponse = sanitizeResponse(fullResponse); | |
| // 计算 usage | |
| const inputTokens = estimateInputTokens(anthropicReq); | |
| const outputTokens = Math.ceil(fullResponse.length / 3); | |
| const usage = { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: inputTokens + outputTokens }; | |
| // ★ 工具调用解析 + Responses API 格式输出 | |
| if (hasTools && hasToolCalls(fullResponse)) { | |
| 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', [])); | |
| const allOutputItems: Record<string, unknown>[] = []; | |
| let outputIndex = 0; | |
| // 2. 每个工具调用 → function_call output item | |
| for (const tc of toolCalls) { | |
| const callId = toolCallId(); | |
| const fcItemId = responsesItemId(); | |
| const argsStr = JSON.stringify(tc.arguments); | |
| // output_item.added (function_call) | |
| writeResponsesSSE(res, 'response.output_item.added', { | |
| output_index: outputIndex, | |
| item: { | |
| id: fcItemId, | |
| type: 'function_call', | |
| name: tc.name, | |
| call_id: callId, | |
| arguments: '', | |
| status: 'in_progress', | |
| }, | |
| }); | |
| // function_call_arguments.delta — 分块发送 | |
| const CHUNK_SIZE = 128; | |
| for (let j = 0; j < argsStr.length; j += CHUNK_SIZE) { | |
| writeResponsesSSE(res, 'response.function_call_arguments.delta', { | |
| output_index: outputIndex, | |
| delta: argsStr.slice(j, j + CHUNK_SIZE), | |
| }); | |
| } | |
| // function_call_arguments.done | |
| writeResponsesSSE(res, 'response.function_call_arguments.done', { | |
| output_index: outputIndex, | |
| arguments: argsStr, | |
| }); | |
| // output_item.done (function_call) | |
| const completedFcItem = { | |
| id: fcItemId, | |
| type: 'function_call', | |
| name: tc.name, | |
| call_id: callId, | |
| arguments: argsStr, | |
| status: 'completed', | |
| }; | |
| writeResponsesSSE(res, 'response.output_item.done', { | |
| output_index: outputIndex, | |
| item: completedFcItem, | |
| }); | |
| allOutputItems.push(completedFcItem); | |
| outputIndex++; | |
| } | |
| // 3. 如果有纯文本部分,也发送 message output item | |
| let textContent = sanitizeResponse(isRefusal(cleanText) ? '' : cleanText); | |
| if (textContent) { | |
| const msgItemId = responsesItemId(); | |
| writeResponsesSSE(res, 'response.output_item.added', { | |
| output_index: outputIndex, | |
| item: { id: msgItemId, type: 'message', role: 'assistant', status: 'in_progress', content: [] }, | |
| }); | |
| writeResponsesSSE(res, 'response.content_part.added', { | |
| output_index: outputIndex, content_index: 0, | |
| part: { type: 'output_text', text: '', annotations: [] }, | |
| }); | |
| writeResponsesSSE(res, 'response.output_text.delta', { | |
| output_index: outputIndex, content_index: 0, delta: textContent, | |
| }); | |
| writeResponsesSSE(res, 'response.output_text.done', { | |
| output_index: outputIndex, content_index: 0, text: textContent, | |
| }); | |
| writeResponsesSSE(res, 'response.content_part.done', { | |
| output_index: outputIndex, content_index: 0, | |
| part: { type: 'output_text', text: textContent, annotations: [] }, | |
| }); | |
| const msgItem = { | |
| id: msgItemId, type: 'message', role: 'assistant', status: 'completed', | |
| content: [{ type: 'output_text', text: textContent, annotations: [] }], | |
| }; | |
| writeResponsesSSE(res, 'response.output_item.done', { output_index: outputIndex, item: msgItem }); | |
| allOutputItems.push(msgItem); | |
| } | |
| // 4. response.completed — ★ Codex 等待的关键事件 | |
| writeResponsesSSE(res, 'response.completed', buildResponseObject(respId, model, 'completed', allOutputItems, usage)); | |
| } else { | |
| // 工具调用解析失败(误报)→ 作为纯文本发送 | |
| const msgItemId = responsesItemId(); | |
| emitResponsesTextStream(res, respId, msgItemId, model, fullResponse, 0, usage); | |
| } | |
| } else { | |
| // 纯文本响应 | |
| 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}]`; | |
| const errorItemId = responsesItemId(); | |
| writeResponsesSSE(res, 'response.created', buildResponseObject(respId, model, 'in_progress', [])); | |
| writeResponsesSSE(res, 'response.output_item.added', { | |
| output_index: 0, | |
| item: { id: errorItemId, type: 'message', role: 'assistant', status: 'in_progress', content: [] }, | |
| }); | |
| writeResponsesSSE(res, 'response.content_part.added', { | |
| output_index: 0, content_index: 0, | |
| part: { type: 'output_text', text: '', annotations: [] }, | |
| }); | |
| writeResponsesSSE(res, 'response.output_text.delta', { | |
| output_index: 0, content_index: 0, delta: errorText, | |
| }); | |
| writeResponsesSSE(res, 'response.output_text.done', { | |
| output_index: 0, content_index: 0, text: errorText, | |
| }); | |
| writeResponsesSSE(res, 'response.content_part.done', { | |
| output_index: 0, content_index: 0, | |
| part: { type: 'output_text', text: errorText, annotations: [] }, | |
| }); | |
| writeResponsesSSE(res, 'response.output_item.done', { | |
| output_index: 0, | |
| item: { id: errorItemId, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: errorText, annotations: [] }] }, | |
| }); | |
| writeResponsesSSE(res, 'response.completed', buildResponseObject(respId, model, 'completed', [{ | |
| id: errorItemId, type: 'message', role: 'assistant', status: 'completed', | |
| content: [{ type: 'output_text', text: errorText, annotations: [] }], | |
| }], { input_tokens: 0, output_tokens: 10, total_tokens: 10 })); | |
| } catch { /* ignore double error */ } | |
| } finally { | |
| clearInterval(keepaliveInterval); | |
| } | |
| res.end(); | |
| } | |
| /** | |
| * Responses API 非流式处理 | |
| */ | |
| async function handleResponsesNonStream( | |
| res: Response, | |
| cursorReq: CursorChatRequest, | |
| body: Record<string, unknown>, | |
| anthropicReq: AnthropicRequest, | |
| log: RequestLogger, | |
| ): Promise<void> { | |
| let activeCursorReq = cursorReq; | |
| let fullText = await sendCursorRequestFull(activeCursorReq); | |
| const hasTools = (anthropicReq.tools?.length ?? 0) > 0; | |
| // Thinking 提取 | |
| if (hasLeadingThinking(fullText)) { | |
| fullText = extractThinking(fullText).strippedText; | |
| } | |
| // 拒绝检测 + 重试 | |
| const shouldRetry = () => isRefusal(fullText) && !(hasTools && hasToolCalls(fullText)); | |
| if (shouldRetry()) { | |
| for (let attempt = 0; attempt < MAX_REFUSAL_RETRIES; attempt++) { | |
| const retryBody = buildRetryRequest(anthropicReq, attempt); | |
| const retryCursorReq = await convertToCursorRequest(retryBody); | |
| activeCursorReq = retryCursorReq; | |
| fullText = await sendCursorRequestFull(activeCursorReq); | |
| if (hasLeadingThinking(fullText)) { | |
| fullText = extractThinking(fullText).strippedText; | |
| } | |
| if (!shouldRetry()) break; | |
| } | |
| if (shouldRetry()) { | |
| if (isToolCapabilityQuestion(anthropicReq)) { | |
| fullText = CLAUDE_TOOLS_RESPONSE; | |
| } else { | |
| fullText = CLAUDE_IDENTITY_RESPONSE; | |
| } | |
| } | |
| } | |
| if (hasTools) { | |
| fullText = await autoContinueCursorToolResponseFull(activeCursorReq, fullText, hasTools); | |
| } | |
| fullText = sanitizeResponse(fullText); | |
| const respId = responsesId(); | |
| const model = (body.model as string) || 'gpt-4'; | |
| const inputTokens = estimateInputTokens(anthropicReq); | |
| const outputTokens = Math.ceil(fullText.length / 3); | |
| 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(), | |
| type: 'function_call', | |
| name: tc.name, | |
| call_id: toolCallId(), | |
| arguments: JSON.stringify(tc.arguments), | |
| status: 'completed', | |
| }); | |
| } | |
| const textContent = sanitizeResponse(isRefusal(cleanText) ? '' : cleanText); | |
| if (textContent) { | |
| output.push({ | |
| id: responsesItemId(), | |
| type: 'message', | |
| role: 'assistant', | |
| status: 'completed', | |
| content: [{ type: 'output_text', text: textContent, annotations: [] }], | |
| }); | |
| } | |
| } else { | |
| output.push({ | |
| id: responsesItemId(), | |
| type: 'message', | |
| role: 'assistant', | |
| status: 'completed', | |
| content: [{ type: 'output_text', text: fullText, annotations: [] }], | |
| }); | |
| } | |
| res.json(buildResponseObject(respId, model, 'completed', output, usage)); | |
| log.recordRawResponse(fullText); | |
| log.recordFinalResponse(fullText); | |
| log.complete(fullText.length, toolCallsDetected > 0 ? 'tool_calls' : 'stop'); | |
| } | |
| /** | |
| * 将 OpenAI Responses API 格式转换为 Chat Completions 格式 | |
| * | |
| * Responses API 使用 `input` 而非 `messages`,格式与 Chat Completions 不同 | |
| */ | |
| export function responsesToChatCompletions(body: Record<string, unknown>): OpenAIChatRequest { | |
| const messages: OpenAIMessage[] = []; | |
| // 系统指令 | |
| if (body.instructions && typeof body.instructions === 'string') { | |
| messages.push({ role: 'system', content: body.instructions }); | |
| } | |
| // 转换 input | |
| const input = body.input; | |
| if (typeof input === 'string') { | |
| messages.push({ role: 'user', content: input }); | |
| } else if (Array.isArray(input)) { | |
| for (const item of input as Record<string, unknown>[]) { | |
| // function_call_output 没有 role 字段,必须先检查 type | |
| if (item.type === 'function_call_output') { | |
| messages.push({ | |
| role: 'tool', | |
| content: stringifyUnknownContent(item.output), | |
| tool_call_id: (item.call_id as string) || '', | |
| }); | |
| continue; | |
| } | |
| const role = (item.role as string) || 'user'; | |
| if (role === 'system' || role === 'developer') { | |
| const text = extractOpenAIContent({ | |
| role: 'system', | |
| content: (item.content as string | OpenAIContentPart[] | null) ?? null, | |
| } as OpenAIMessage); | |
| messages.push({ role: 'system', content: text }); | |
| } else if (role === 'user') { | |
| const rawContent = (item.content as string | OpenAIContentPart[] | null) ?? null; | |
| const normalizedContent = typeof rawContent === 'string' | |
| ? rawContent | |
| : Array.isArray(rawContent) && rawContent.every(b => b.type === 'input_text') | |
| ? rawContent.map(b => b.text || '').join('\n') | |
| : rawContent; | |
| messages.push({ | |
| role: 'user', | |
| content: normalizedContent || '', | |
| }); | |
| } else if (role === 'assistant') { | |
| const blocks = Array.isArray(item.content) ? item.content as Array<Record<string, unknown>> : []; | |
| const text = blocks.filter(b => b.type === 'output_text').map(b => b.text as string).join('\n'); | |
| // 检查是否有工具调用 | |
| const toolCallBlocks = blocks.filter(b => b.type === 'function_call'); | |
| const toolCalls: OpenAIToolCall[] = toolCallBlocks.map(b => ({ | |
| id: (b.call_id as string) || toolCallId(), | |
| type: 'function' as const, | |
| function: { | |
| name: (b.name as string) || '', | |
| arguments: (b.arguments as string) || '{}', | |
| }, | |
| })); | |
| messages.push({ | |
| role: 'assistant', | |
| content: text || null, | |
| ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), | |
| }); | |
| } | |
| } | |
| } | |
| // 转换工具定义 | |
| const tools: OpenAITool[] | undefined = Array.isArray(body.tools) | |
| ? (body.tools as Array<Record<string, unknown>>).map(t => { | |
| if (t.type === 'function') { | |
| return { | |
| type: 'function' as const, | |
| function: { | |
| name: (t.name as string) || '', | |
| description: t.description as string | undefined, | |
| parameters: t.parameters as Record<string, unknown> | undefined, | |
| }, | |
| }; | |
| } | |
| return { | |
| type: 'function' as const, | |
| function: { | |
| name: (t.name as string) || '', | |
| description: t.description as string | undefined, | |
| parameters: t.parameters as Record<string, unknown> | undefined, | |
| }, | |
| }; | |
| }) | |
| : undefined; | |
| return { | |
| model: (body.model as string) || 'gpt-4', | |
| messages, | |
| stream: (body.stream as boolean) ?? true, | |
| temperature: body.temperature as number | undefined, | |
| max_tokens: (body.max_output_tokens as number) || 8192, | |
| tools, | |
| }; | |
| } | |