|
|
@@ -1,722 +0,0 @@
|
|
|
-/**
|
|
|
- * 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<string, unknown>;
|
|
|
-}
|
|
|
-
|
|
|
-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<string, unknown> {
|
|
|
- const choice = chat.choices?.[0];
|
|
|
- const message = choice?.message ?? {};
|
|
|
- const output: Array<Record<string, unknown>> = [];
|
|
|
-
|
|
|
- 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<number, ToolCallState>();
|
|
|
-
|
|
|
- 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<string, unknown>));
|
|
|
- }
|
|
|
- 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<string, unknown> = {
|
|
|
- ...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<string, unknown> = {
|
|
|
- ...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<string, unknown>): BridgeSseEvent {
|
|
|
- return { event: type, data: { type, ...data } };
|
|
|
- }
|
|
|
-
|
|
|
- private responseSkeleton(status: string): Record<string, unknown> {
|
|
|
- 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 })];
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 不等上游、立刻把 Responses 流开起来:单槽自部署服务光 prefill 就要几分钟,
|
|
|
- * 客户端在那期间一个字节都收不到就会掐断重发。开了之后 push() 不会重复发这两帧。
|
|
|
- */
|
|
|
- begin(): BridgeSseEvent[] {
|
|
|
- return this.ensureStarted();
|
|
|
- }
|
|
|
-
|
|
|
- private ensureStarted(): BridgeSseEvent[] {
|
|
|
- if (this.started) return [];
|
|
|
- this.started = true;
|
|
|
- return this.openResponse();
|
|
|
- }
|
|
|
-
|
|
|
- private processChunk(chunk: Record<string, unknown>): BridgeSseEvent[] {
|
|
|
- const events: BridgeSseEvent[] = [];
|
|
|
- const usage = chunk.usage as ChatUsage | undefined;
|
|
|
- if (usage) this.usage = mapUsage(usage);
|
|
|
-
|
|
|
- const choices = chunk.choices as Array<Record<string, unknown>> | 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<Record<string, unknown>> {
|
|
|
- const items: Array<{ outputIndex: number; item: Record<string, unknown> }> = [];
|
|
|
- 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);
|
|
|
- }
|
|
|
-}
|