/** * Responses API ↔ Chat Completions 的翻译层(纯函数 + SSE 状态机,零 IO)。 * * Codex 0.155+ 只会发 Responses API;chat-only 端点(老 Ollama / vLLM / Xinference) * 由 chatBridgeService 在本地起代理,请求与响应的格式转换全部集中在这里。 * 转换是有损的,丢弃项一律走 onDiagnostic 留痕,绝不静默。 */ // ── 结构类型(只声明用到的字段,其余原样忽略) ───────────────────────────── export interface ResponsesInputItem { type?: string; role?: string; content?: unknown; call_id?: string; name?: string; arguments?: string; output?: unknown; [key: string]: unknown; } export interface ResponsesTool { type?: string; name?: string; description?: string; parameters?: unknown; [key: string]: unknown; } export interface ResponsesCreateParams { model?: string; instructions?: string | null; input?: string | ResponsesInputItem[]; tools?: ResponsesTool[]; tool_choice?: unknown; max_output_tokens?: number | null; temperature?: number | null; top_p?: number | null; parallel_tool_calls?: boolean; stream?: boolean; [key: string]: unknown; } export interface ChatContentPart { type: string; text?: string; image_url?: { url: string }; } export interface ChatToolCall { id: string; type: 'function'; function: { name: string; arguments: string }; } export interface ChatMessage { role: string; content?: string | ChatContentPart[] | null; tool_calls?: ChatToolCall[]; tool_call_id?: string; } export interface ChatCompletionRequest { model?: string; messages: ChatMessage[]; tools?: Array<{ type: 'function'; function: { name?: string; description?: string; parameters?: unknown } }>; tool_choice?: unknown; max_tokens?: number | null; temperature?: number | null; top_p?: number | null; parallel_tool_calls?: boolean; stream?: boolean; stream_options?: { include_usage?: boolean }; } export interface ChatUsage { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; } export interface ResponsesUsage { input_tokens: number; output_tokens: number; total_tokens: number; } export interface ChatCompletionLike { id?: string; created?: number; model?: string; choices?: Array<{ finish_reason?: string | null; message?: { content?: string | null; tool_calls?: ChatToolCall[]; reasoning_content?: string; }; }>; usage?: ChatUsage; } export interface BridgeSseEvent { event: string; data: Record; } export type DiagnosticFn = (line: string) => void; // ── 小工具 ──────────────────────────────────────────────────────────────── let idCounter = 0; function newId(prefix: string): string { idCounter += 1; return `${prefix}_${Date.now().toString(36)}${idCounter.toString(36)}${Math.random().toString(36).slice(2, 8)}`; } function mapUsage(usage: ChatUsage | null | undefined): ResponsesUsage { return { input_tokens: usage?.prompt_tokens ?? 0, output_tokens: usage?.completion_tokens ?? 0, total_tokens: usage?.total_tokens ?? 0, }; } // ── 请求方向:Responses → Chat ───────────────────────────────────────────── function convertMessageItem(item: ResponsesInputItem, onDiagnostic?: DiagnosticFn): ChatMessage | null { const role = typeof item.role === 'string' && item.role ? item.role : 'user'; const content = item.content; if (typeof content === 'string') return { role, content }; if (Array.isArray(content)) { const parts: ChatContentPart[] = []; for (const part of content) { if (!part || typeof part !== 'object') continue; const record = part as { type?: string; text?: unknown; image_url?: unknown }; if ((record.type === 'input_text' || record.type === 'output_text') && typeof record.text === 'string') { parts.push({ type: 'text', text: record.text }); } else if (record.type === 'input_image' && typeof record.image_url === 'string') { parts.push({ type: 'image_url', image_url: { url: record.image_url } }); } else { onDiagnostic?.(`chat-bridge: 丢弃不支持的 content part(${record.type ?? '未知类型'})`); } } if (!parts.length) return null; // 单文本压成 string,兼容性最好 if (parts.length === 1 && parts[0].type === 'text') return { role, content: parts[0].text ?? '' }; return { role, content: parts }; } return null; } function convertToolChoice(choice: unknown): unknown { if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; if (choice && typeof choice === 'object') { const record = choice as { type?: string; name?: unknown }; if (record.type === 'function' && typeof record.name === 'string') { return { type: 'function', function: { name: record.name } }; } } return undefined; } export function responsesRequestToChat(req: ResponsesCreateParams, onDiagnostic?: DiagnosticFn): ChatCompletionRequest { const messages: ChatMessage[] = []; if (typeof req.instructions === 'string' && req.instructions) { messages.push({ role: 'developer', content: req.instructions }); } const input = req.input; if (typeof input === 'string') { if (input) messages.push({ role: 'user', content: input }); } else if (Array.isArray(input)) { for (const item of input) { if (!item || typeof item !== 'object') continue; // easy-input-message 形式({role, content} 无 type 字段)按 message 处理 const type = typeof item.type === 'string' ? item.type : item.role ? 'message' : null; if (type === 'message') { const converted = convertMessageItem(item, onDiagnostic); if (converted) messages.push(converted); } else if (type === 'function_call' || type === 'custom_tool_call') { messages.push({ role: 'assistant', content: null, tool_calls: [ { id: item.call_id ?? '', type: 'function', function: { name: item.name ?? '', arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? ''), }, }, ], }); } else if (type === 'function_call_output' || type === 'custom_tool_call_output') { messages.push({ role: 'tool', tool_call_id: item.call_id ?? '', content: typeof item.output === 'string' ? item.output : JSON.stringify(item.output ?? ''), }); } else { // reasoning / item_reference / web_search_call 等:chat 协议没有对应物 onDiagnostic?.(`chat-bridge: 丢弃不支持的 input item(${type ?? '未知类型'})`); } } } const request: ChatCompletionRequest = { messages }; if (req.model) request.model = req.model; if (Array.isArray(req.tools)) { const tools = req.tools .filter((tool) => tool && tool.type === 'function') .map((tool) => ({ type: 'function' as const, function: { name: tool.name, description: tool.description, parameters: tool.parameters }, })); if (tools.length) request.tools = tools; } const toolChoice = convertToolChoice(req.tool_choice); if (toolChoice !== undefined) request.tool_choice = toolChoice; if (typeof req.max_output_tokens === 'number') request.max_tokens = req.max_output_tokens; if (typeof req.temperature === 'number') request.temperature = req.temperature; if (typeof req.top_p === 'number') request.top_p = req.top_p; if (typeof req.parallel_tool_calls === 'boolean') request.parallel_tool_calls = req.parallel_tool_calls; if (req.stream) { request.stream = true; // 让上游在最后一个 chunk 带 usage;不支持的端点会忽略,下游有全零兜底 request.stream_options = { include_usage: true }; } return request; } // ── 非流式响应方向:Chat → Responses ─────────────────────────────────────── export function chatResponseToResponses(chat: ChatCompletionLike, req: ResponsesCreateParams): Record { const choice = chat.choices?.[0]; const message = choice?.message ?? {}; const output: Array> = []; if (typeof message.reasoning_content === 'string' && message.reasoning_content) { output.push({ id: newId('rs'), type: 'reasoning', summary: [{ type: 'summary_text', text: message.reasoning_content }], }); } if (typeof message.content === 'string' && message.content) { output.push({ id: newId('msg'), type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: message.content, annotations: [] }], }); } if (Array.isArray(message.tool_calls)) { for (const call of message.tool_calls) { output.push({ id: newId('fc'), type: 'function_call', call_id: call.id ?? '', name: call.function?.name ?? '', arguments: call.function?.arguments ?? '', status: 'completed', }); } } const incomplete = choice?.finish_reason === 'length'; return { id: chat.id || newId('resp'), object: 'response', created_at: chat.created ?? Math.floor(Date.now() / 1000), model: chat.model ?? req.model ?? '', status: incomplete ? 'incomplete' : 'completed', ...(incomplete ? { incomplete_details: { reason: 'max_output_tokens' } } : {}), output, parallel_tool_calls: req.parallel_tool_calls ?? true, tool_choice: req.tool_choice ?? 'auto', tools: req.tools ?? [], usage: mapUsage(chat.usage), }; } // ── SSE 流式状态机:chat.completion.chunk → Responses 语义事件 ───────────── interface TextItemState { itemId: string; outputIndex: number; open: boolean; text: string; } interface ReasoningItemState { itemId: string; outputIndex: number; open: boolean; text: string; } interface ToolCallState { itemId: string; outputIndex: number; callId: string; name: string; argsBuffer: string; open: boolean; } export class ResponsesSseTranslator { private readonly model: string; private readonly onDiagnostic?: DiagnosticFn; private readonly responseId = newId('resp'); private readonly createdAt = Math.floor(Date.now() / 1000); private lineBuffer = ''; private started = false; private completed = false; private nextOutputIndex = 0; private textItem: TextItemState | null = null; private reasoningItem: ReasoningItemState | null = null; private readonly toolCalls = new Map(); private finishReason: string | null = null; private usage: ResponsesUsage | null = null; constructor(req: ResponsesCreateParams, onDiagnostic?: DiagnosticFn) { this.model = req.model ?? ''; this.onDiagnostic = onDiagnostic; } /** 喂入上游 SSE 文本片段,吐出 0..n 个待发送的 Responses SSE 事件 */ push(chunk: string): BridgeSseEvent[] { const events: BridgeSseEvent[] = []; this.lineBuffer += chunk; const lines = this.lineBuffer.split(/\r?\n/u); this.lineBuffer = lines.pop() ?? ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith('data:')) continue; // event:/id:/注释行忽略 const payload = trimmed.slice(5).trim(); if (payload === '[DONE]') { events.push(...this.finish()); continue; } let parsed: unknown; try { parsed = JSON.parse(payload); } catch { this.onDiagnostic?.('chat-bridge: 跳过无法解析的上游 SSE 行'); continue; } events.push(...this.processChunk(parsed as Record)); } return events; } /** 上游流结束(可能没收到 [DONE]):闭合未闭 item 并补 response.completed */ finish(): BridgeSseEvent[] { if (this.completed) return []; this.completed = true; const events: BridgeSseEvent[] = []; if (!this.started) { this.started = true; events.push(...this.openResponse()); } events.push(...this.closeAllItems()); const incomplete = this.finishReason === 'length'; const response: Record = { ...this.responseSkeleton(incomplete ? 'incomplete' : 'completed'), output: this.buildOutput(), usage: this.usage ?? mapUsage(null), }; if (incomplete) response.incomplete_details = { reason: 'max_output_tokens' }; events.push(this.event('response.completed', { response })); return events; } /** 流中途出错:补一个 response.failed 再交给 server 关闭 */ fail(message: string): BridgeSseEvent[] { if (this.completed) return []; this.completed = true; const events: BridgeSseEvent[] = []; if (!this.started) { this.started = true; events.push(...this.openResponse()); } const response: Record = { ...this.responseSkeleton('failed'), output: this.buildOutput(), error: { code: 'upstream_error', message }, usage: this.usage ?? mapUsage(null), }; events.push(this.event('response.failed', { response })); return events; } // ── 内部 ── private event(type: string, data: Record): BridgeSseEvent { return { event: type, data: { type, ...data } }; } private responseSkeleton(status: string): Record { return { id: this.responseId, object: 'response', created_at: this.createdAt, model: this.model, status, output: [], parallel_tool_calls: true, tool_choice: 'auto', tools: [], usage: null, }; } private openResponse(): BridgeSseEvent[] { const response = this.responseSkeleton('in_progress'); return [this.event('response.created', { response }), this.event('response.in_progress', { response })]; } private ensureStarted(): BridgeSseEvent[] { if (this.started) return []; this.started = true; return this.openResponse(); } private processChunk(chunk: Record): BridgeSseEvent[] { const events: BridgeSseEvent[] = []; const usage = chunk.usage as ChatUsage | undefined; if (usage) this.usage = mapUsage(usage); const choices = chunk.choices as Array> | undefined; const choice = choices?.[0]; if (!choice) return events; // usage-only chunk events.push(...this.ensureStarted()); const delta = (choice.delta ?? {}) as { content?: unknown; reasoning_content?: unknown; tool_calls?: Array<{ index?: number; id?: string; function?: { name?: string; arguments?: string } }>; }; if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) { events.push(...this.pushReasoningDelta(delta.reasoning_content)); } if (typeof delta.content === 'string' && delta.content) { events.push(...this.pushTextDelta(delta.content)); } if (Array.isArray(delta.tool_calls)) { for (const call of delta.tool_calls) { events.push(...this.pushToolCallDelta(call)); } } if (typeof choice.finish_reason === 'string' && choice.finish_reason) { this.finishReason = choice.finish_reason; events.push(...this.closeAllItems()); } return events; } private closeReasoningIfOpen(exceptOpen = false): BridgeSseEvent[] { const state = this.reasoningItem; if (!state || !state.open || exceptOpen) return []; state.open = false; return [ this.event('response.reasoning_summary_text.done', { item_id: state.itemId, output_index: state.outputIndex, summary_index: 0, text: state.text, }), this.event('response.reasoning_summary_part.done', { item_id: state.itemId, output_index: state.outputIndex, summary_index: 0, part: { type: 'summary_text', text: state.text }, }), this.event('response.output_item.done', { output_index: state.outputIndex, item: { id: state.itemId, type: 'reasoning', summary: state.text ? [{ type: 'summary_text', text: state.text }] : [], }, }), ]; } private closeTextIfOpen(): BridgeSseEvent[] { const state = this.textItem; if (!state || !state.open) return []; state.open = false; const part = { type: 'output_text', text: state.text, annotations: [] as unknown[] }; return [ this.event('response.output_text.done', { item_id: state.itemId, output_index: state.outputIndex, content_index: 0, text: state.text, }), this.event('response.content_part.done', { item_id: state.itemId, output_index: state.outputIndex, content_index: 0, part, }), this.event('response.output_item.done', { output_index: state.outputIndex, item: { id: state.itemId, type: 'message', role: 'assistant', status: 'completed', content: [part] }, }), ]; } private closeToolCallIfOpen(state: ToolCallState): BridgeSseEvent[] { if (!state.open) return []; state.open = false; return [ this.event('response.function_call_arguments.done', { item_id: state.itemId, output_index: state.outputIndex, arguments: state.argsBuffer, }), this.event('response.output_item.done', { output_index: state.outputIndex, item: { id: state.itemId, type: 'function_call', call_id: state.callId, name: state.name, arguments: state.argsBuffer, status: 'completed', }, }), ]; } private closeAllItems(): BridgeSseEvent[] { const events: BridgeSseEvent[] = []; events.push(...this.closeReasoningIfOpen()); events.push(...this.closeTextIfOpen()); for (const state of this.toolCalls.values()) { events.push(...this.closeToolCallIfOpen(state)); } return events; } private pushReasoningDelta(text: string): BridgeSseEvent[] { const events: BridgeSseEvent[] = []; if (!this.reasoningItem) { this.reasoningItem = { itemId: newId('rs'), outputIndex: this.nextOutputIndex, open: true, text: '' }; this.nextOutputIndex += 1; events.push( this.event('response.output_item.added', { output_index: this.reasoningItem.outputIndex, item: { id: this.reasoningItem.itemId, type: 'reasoning', summary: [] }, }), this.event('response.reasoning_summary_part.added', { item_id: this.reasoningItem.itemId, output_index: this.reasoningItem.outputIndex, summary_index: 0, part: { type: 'summary_text', text: '' }, }), ); } const state = this.reasoningItem; state.text += text; events.push( this.event('response.reasoning_summary_text.delta', { item_id: state.itemId, output_index: state.outputIndex, summary_index: 0, delta: text, }), ); return events; } private pushTextDelta(text: string): BridgeSseEvent[] { const events: BridgeSseEvent[] = []; events.push(...this.closeReasoningIfOpen()); if (!this.textItem) { this.textItem = { itemId: newId('msg'), outputIndex: this.nextOutputIndex, open: true, text: '' }; this.nextOutputIndex += 1; events.push( this.event('response.output_item.added', { output_index: this.textItem.outputIndex, item: { id: this.textItem.itemId, type: 'message', role: 'assistant', status: 'in_progress', content: [] }, }), this.event('response.content_part.added', { item_id: this.textItem.itemId, output_index: this.textItem.outputIndex, content_index: 0, part: { type: 'output_text', text: '', annotations: [] }, }), ); } const state = this.textItem; state.text += text; events.push( this.event('response.output_text.delta', { item_id: state.itemId, output_index: state.outputIndex, content_index: 0, delta: text, }), ); return events; } private pushToolCallDelta(call: { index?: number; id?: string; function?: { name?: string; arguments?: string } }): BridgeSseEvent[] { const events: BridgeSseEvent[] = []; const index = typeof call.index === 'number' ? call.index : 0; let state = this.toolCalls.get(index); if (!state) { // chat 流里 content 总在 tool_calls 之前;保守起见仍先闭合文本与 reasoning events.push(...this.closeReasoningIfOpen()); events.push(...this.closeTextIfOpen()); state = { itemId: newId('fc'), outputIndex: this.nextOutputIndex, callId: call.id ?? '', name: call.function?.name ?? '', argsBuffer: '', open: true, }; this.nextOutputIndex += 1; this.toolCalls.set(index, state); events.push( this.event('response.output_item.added', { output_index: state.outputIndex, item: { id: state.itemId, type: 'function_call', call_id: state.callId, name: state.name, arguments: '', status: 'in_progress' }, }), ); } else { // 后续 chunk 可能补齐 id / name(多数上游只在首帧带) if (call.id && !state.callId) state.callId = call.id; if (call.function?.name && !state.name) state.name = call.function.name; } const argsDelta = call.function?.arguments; if (typeof argsDelta === 'string' && argsDelta) { state.argsBuffer += argsDelta; events.push( this.event('response.function_call_arguments.delta', { item_id: state.itemId, output_index: state.outputIndex, delta: argsDelta, }), ); } return events; } /** 按 output_index 顺序组装最终 output(response.completed / failed 用) */ private buildOutput(): Array> { const items: Array<{ outputIndex: number; item: Record }> = []; if (this.reasoningItem) { items.push({ outputIndex: this.reasoningItem.outputIndex, item: { id: this.reasoningItem.itemId, type: 'reasoning', summary: this.reasoningItem.text ? [{ type: 'summary_text', text: this.reasoningItem.text }] : [], }, }); } if (this.textItem) { items.push({ outputIndex: this.textItem.outputIndex, item: { id: this.textItem.itemId, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: this.textItem.text, annotations: [] }], }, }); } for (const state of this.toolCalls.values()) { items.push({ outputIndex: state.outputIndex, item: { id: state.itemId, type: 'function_call', call_id: state.callId, name: state.name, arguments: state.argsBuffer, status: 'completed', }, }); } items.sort((a, b) => a.outputIndex - b.outputIndex); return items.map((entry) => entry.item); } }