| |
| |
| |
| |
| |
| |
|
|
| 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' |
| ); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| function convertToAnthropicRequest(body: OpenAIChatRequest): AnthropicRequest { |
| const rawMessages: AnthropicMessage[] = []; |
| let systemPrompt: string | undefined; |
|
|
| |
| 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': { |
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| const messages = mergeConsecutiveRoles(rawMessages); |
|
|
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| const tools: AnthropicTool[] | undefined = body.tools?.map((t: OpenAITool | Record<string, unknown>) => { |
| |
| 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: {} }, |
| }; |
| } |
| |
| 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, |
| |
| |
| |
| |
| ...(() => { |
| 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 } } : {}; |
| })(), |
| }; |
| } |
|
|
| |
| |
| |
| 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) { |
| |
| const lastBlocks = toBlocks(last.content); |
| const newBlocks = toBlocks(msg.content); |
| last.content = [...lastBlocks, ...newBlocks]; |
| } else { |
| merged.push({ ...msg }); |
| } |
| } |
| return merged; |
| } |
|
|
| |
| |
| |
| function toBlocks(content: string | AnthropicContentBlock[]): AnthropicContentBlock[] { |
| if (typeof content === 'string') { |
| return content ? [{ type: 'text', text: content }] : []; |
| } |
| return content || []; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| blocks.push({ |
| type: 'image', |
| source: { type: 'url', media_type: 'image/jpeg', data: url } |
| }); |
| } |
| } else if (p.type === 'image' && (p as any).source) { |
| |
| 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) { |
| |
| 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) { |
| |
| 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') { |
| |
| blocks.push(p as unknown as AnthropicContentBlock); |
| } else if (p.type === '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, |
| }); |
|
|
| |
| 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 { |
| |
| log.startPhase('convert', '格式转换 (OpenAI→Anthropic)'); |
| const anthropicReq = convertToAnthropicRequest(body); |
| log.endPhase(); |
|
|
| |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
|
|
| 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; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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'; |
|
|
| |
| 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, |
| }], |
| }); |
| } |
| } |
|
|
| |
| for (let i = 0; i < toolCalls.length; i++) { |
| const tc = toolCalls[i]; |
| const tcId = toolCallId(); |
| const argsStr = JSON.stringify(tc.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, |
| }], |
| }); |
|
|
| |
| 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); |
| |
| 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, |
| }], |
| }); |
| } |
| } |
| } |
|
|
| |
| 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; |
|
|
| |
|
|
| |
| 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; |
| } |
| |
| 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 (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); |
| |
| 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); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| 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(); |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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 } : {}), |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| 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 }, |
| }); |
| } |
| } |
|
|
| |
| |
| |
| 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(); |
| } |
|
|
| |
| |
| |
| 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 })); |
| } |
|
|
| |
| |
| |
| |
| 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]; |
|
|
| |
| writeResponsesSSE(res, 'response.created', buildResponseObject(respId, model, 'in_progress', [])); |
|
|
| |
| writeResponsesSSE(res, 'response.in_progress', buildResponseObject(respId, model, 'in_progress', [])); |
|
|
| |
| writeResponsesSSE(res, 'response.output_item.added', { |
| output_index: outputIndex, |
| item: { |
| id: itemId, |
| 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: [] }, |
| }); |
|
|
| |
| if (fullText) { |
| |
| 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), |
| }); |
| } |
| } |
|
|
| |
| writeResponsesSSE(res, 'response.output_text.done', { |
| output_index: outputIndex, |
| content_index: 0, |
| text: fullText, |
| }); |
|
|
| |
| writeResponsesSSE(res, 'response.content_part.done', { |
| output_index: outputIndex, |
| content_index: 0, |
| part: { type: 'output_text', text: fullText, annotations: [] }, |
| }); |
|
|
| |
| writeResponsesSSE(res, 'response.output_item.done', { |
| output_index: outputIndex, |
| item: messageItem, |
| }); |
|
|
| |
| writeResponsesSSE(res, 'response.completed', buildResponseObject(respId, model, 'completed', allOutputItems, usage)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| let fullResponse = ''; |
| let activeCursorReq = cursorReq; |
| let retryCount = 0; |
|
|
| |
| 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 { } |
| }, 15000); |
|
|
| try { |
| const executeStream = async () => { |
| fullResponse = ''; |
| await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => { |
| if (event.type !== 'text-delta' || !event.delta) return; |
| fullResponse += event.delta; |
| }); |
| }; |
|
|
| await executeStream(); |
|
|
| |
| 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); |
|
|
| |
| const inputTokens = estimateInputTokens(anthropicReq); |
| const outputTokens = Math.ceil(fullResponse.length / 3); |
| const usage = { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: inputTokens + outputTokens }; |
|
|
| |
| if (hasTools && hasToolCalls(fullResponse)) { |
| const { toolCalls, cleanText } = parseToolCalls(fullResponse); |
|
|
| if (toolCalls.length > 0) { |
| toolCallsDetected = toolCalls.length; |
| log.recordToolCalls(toolCalls); |
| log.updateSummary({ toolCallsDetected: toolCalls.length }); |
| |
| 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; |
|
|
| |
| for (const tc of toolCalls) { |
| const callId = toolCallId(); |
| const fcItemId = responsesItemId(); |
| const argsStr = JSON.stringify(tc.arguments); |
|
|
| |
| 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', |
| }, |
| }); |
|
|
| |
| 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), |
| }); |
| } |
|
|
| |
| writeResponsesSSE(res, 'response.function_call_arguments.done', { |
| output_index: outputIndex, |
| arguments: argsStr, |
| }); |
|
|
| |
| 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++; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| |
| 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 { } |
| } finally { |
| clearInterval(keepaliveInterval); |
| } |
|
|
| res.end(); |
| } |
|
|
| |
| |
| |
| 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; |
|
|
| |
| 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'); |
| } |
|
|
| |
| |
| |
| |
| |
| 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 }); |
| } |
|
|
| |
| 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>[]) { |
| |
| 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, |
| }; |
| } |
|
|